/** * Entity Resolution Review Page * * Review and verify matches between WCMS and LinkedIn profiles. * Side-by-side comparison with match/not-match/uncertain decisions. * * Features: * - Paginated list of profiles with pending reviews * - Side-by-side profile comparison * - Review decision buttons with keyboard shortcuts * - Progress statistics dashboard * - Match signal filtering * - Compact UI to minimize scrolling */ import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useLanguage } from '../contexts/LanguageContext'; import { CheckCircle, XCircle, HelpCircle, ChevronLeft, ChevronRight, User, Building2, Mail, Globe, Link2, AlertCircle, AlertTriangle, Loader2, RefreshCw, Filter, Key, Star } from 'lucide-react'; import './EntityReviewPage.css'; // API base URL const API_BASE = import.meta.env.VITE_API_URL || ''; // Types interface MatchCandidate { linkedin_ppid: string; linkedin_name: string; linkedin_slug: string | null; confidence_score: number; confidence_original?: number; match_signals: string[]; reviewed: boolean; review_decision: string | null; reviewed_by: string | null; reviewed_at: string | null; is_likely_wrong_person?: boolean; wrong_person_reason?: string | null; email_birth_year?: number | null; } interface ProfileSummary { ppid: string; name: string; email: string | null; email_domain: string | null; potential_matches: number; reviewed_count: number; pending_count: number; } interface ProfileDetail { ppid: string; name: string; email: string | null; email_domain: string | null; wcms_identifiers: Record | null; wcms_activity: Record | null; match_candidates: MatchCandidate[]; annotation_date: string | null; } interface LinkedInProfile { ppid: string; name: string; linkedin_slug: string | null; profile_data: Record; affiliations: Array>; web_claims: Array>; } interface ReviewStats { total_profiles: number; profiles_with_candidates: number; total_candidates: number; reviewed_candidates: number; pending_candidates: number; review_progress_percent: number; likely_wrong_person?: number; confidence_scoring_version?: string; decisions: { match: number; not_match: number; uncertain: number; }; } // Text translations const TEXT = { pageTitle: { nl: 'Entiteitsresolutie Review', en: 'Entity Resolution Review' }, pageSubtitle: { nl: 'Verifieer matches tussen WCMS en LinkedIn profielen', en: 'Verify matches between WCMS and LinkedIn profiles' }, loading: { nl: 'Laden...', en: 'Loading...' }, noProfiles: { nl: 'Geen profielen te reviewen', en: 'No profiles to review' }, wcmsProfile: { nl: 'WCMS Profiel', en: 'WCMS Profile' }, linkedinProfile: { nl: 'LinkedIn Profiel', en: 'LinkedIn Profile' }, matchCandidate: { nl: 'Match Kandidaat', en: 'Match Candidate' }, confidence: { nl: 'Vertrouwen', en: 'Confidence' }, matchSignals: { nl: 'Match Signalen', en: 'Match Signals' }, match: { nl: 'Match', en: 'Match' }, notMatch: { nl: 'Geen Match', en: 'Not Match' }, uncertain: { nl: 'Onzeker', en: 'Uncertain' }, skip: { nl: 'Overslaan', en: 'Skip' }, reviewed: { nl: 'Beoordeeld', en: 'Reviewed' }, pending: { nl: 'In afwachting', en: 'Pending' }, progress: { nl: 'Voortgang', en: 'Progress' }, totalProfiles: { nl: 'Totaal profielen', en: 'Total profiles' }, totalCandidates: { nl: 'Totaal kandidaten', en: 'Total candidates' }, reviewedCandidates: { nl: 'Beoordeeld', en: 'Reviewed' }, pendingCandidates: { nl: 'In afwachting', en: 'Pending' }, decisions: { nl: 'Beslissingen', en: 'Decisions' }, selectProfile: { nl: 'Selecteer een profiel om te beginnen', en: 'Select a profile to begin' }, selectCandidate: { nl: 'Selecteer een kandidaat om te vergelijken', en: 'Select a candidate to compare' }, email: { nl: 'E-mail', en: 'Email' }, domain: { nl: 'Domein', en: 'Domain' }, affiliations: { nl: 'Affiliaties', en: 'Affiliations' }, savingDecision: { nl: 'Beslissing opslaan...', en: 'Saving decision...' }, decisionSaved: { nl: 'Beslissing opgeslagen', en: 'Decision saved' }, error: { nl: 'Fout', en: 'Error' }, wrongPerson: { nl: 'Verkeerde Persoon', en: 'Wrong Person' }, wrongPersonWarning: { nl: 'Waarschijnlijk verkeerde persoon!', en: 'Likely wrong person!' }, likelyWrongPerson: { nl: 'Waarschijnlijk verkeerd', en: 'Likely Wrong' }, highConfidence: { nl: 'Hoog vertrouwen', en: 'High Confidence' }, showHighConfidence: { nl: 'Toon hoog vertrouwen (>80%)', en: 'Show high confidence (>80%)' }, hideWrongPerson: { nl: 'Verberg verkeerde persoon', en: 'Hide wrong person' }, birthYearMismatch: { nl: 'Geboortejaar komt niet overeen', en: 'Birth year mismatch' }, }; export default function EntityReviewPage() { const { language } = useLanguage(); const navigate = useNavigate(); const t = (key: keyof typeof TEXT) => TEXT[key][language as 'nl' | 'en'] || TEXT[key].en; // Auth check const [isAuthenticated, setIsAuthenticated] = useState(null); const reviewToken = sessionStorage.getItem('review_token'); useEffect(() => { if (!reviewToken) { navigate('/review-login', { state: { from: '/review' } }); } else { setIsAuthenticated(true); } }, [reviewToken, navigate]); // State const [profiles, setProfiles] = useState([]); const [stats, setStats] = useState(null); const [selectedProfile, setSelectedProfile] = useState(null); const [selectedCandidate, setSelectedCandidate] = useState(null); const [linkedinProfile, setLinkedinProfile] = useState(null); const [loading, setLoading] = useState(true); const [loadingProfile, setLoadingProfile] = useState(false); const [loadingLinkedin, setLoadingLinkedin] = useState(false); const [savingDecision, setSavingDecision] = useState(false); const [error, setError] = useState(null); const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [pageInput, setPageInput] = useState('1'); const pageSize = 20; // Filtering state const [showFilters, setShowFilters] = useState(false); const [minSignals, setMinSignals] = useState(1); const [requiredSignals, setRequiredSignals] = useState([]); const [showHighConfidenceOnly, setShowHighConfidenceOnly] = useState(false); const [hideWrongPerson, setHideWrongPerson] = useState(false); const [availableSignals, setAvailableSignals] = useState<{signal_types: string[], signal_counts: Record}>({signal_types: [], signal_counts: {}}); // Fetch available signal types const fetchSignalTypes = useCallback(async () => { try { const response = await fetch(`${API_BASE}/api/review/signal-types`); if (response.ok) { const data = await response.json(); setAvailableSignals(data); } } catch (err) { console.error('Failed to fetch signal types:', err); } }, []); // Fetch profiles list with filters const fetchProfiles = useCallback(async () => { setLoading(true); setError(null); try { let url = `${API_BASE}/api/review/candidates?page=${page}&page_size=${pageSize}&filter_status=pending&sort_by=confidence&min_signals=${minSignals}`; if (requiredSignals.length > 0) { url += `&signal_types=${requiredSignals.join(',')}`; } if (showHighConfidenceOnly) { url += `&min_confidence=0.8`; } const response = await fetch(url); if (!response.ok) throw new Error('Failed to fetch profiles'); const data = await response.json(); setProfiles(data.profiles); setTotalPages(Math.ceil(data.total / pageSize)); } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { setLoading(false); } }, [page, minSignals, requiredSignals, showHighConfidenceOnly]); // Fetch stats const fetchStats = useCallback(async () => { try { const response = await fetch(`${API_BASE}/api/review/stats`); if (!response.ok) throw new Error('Failed to fetch stats'); const data = await response.json(); setStats(data); } catch (err) { console.error('Failed to fetch stats:', err); } }, []); // Fetch profile detail const fetchProfileDetail = useCallback(async (ppid: string) => { setLoadingProfile(true); setSelectedCandidate(null); setLinkedinProfile(null); try { const response = await fetch(`${API_BASE}/api/review/profile/${encodeURIComponent(ppid)}`); if (!response.ok) throw new Error('Failed to fetch profile'); const data = await response.json(); setSelectedProfile(data); // Auto-select first pending candidate const pendingCandidate = data.match_candidates?.find((c: MatchCandidate) => !c.reviewed); if (pendingCandidate) { setSelectedCandidate(pendingCandidate); fetchLinkedinProfile(pendingCandidate.linkedin_ppid); } } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { setLoadingProfile(false); } }, []); // Fetch LinkedIn profile for comparison const fetchLinkedinProfile = useCallback(async (ppid: string) => { setLoadingLinkedin(true); try { const response = await fetch(`${API_BASE}/api/review/linkedin/${encodeURIComponent(ppid)}`); if (!response.ok) throw new Error('Failed to fetch LinkedIn profile'); const data = await response.json(); setLinkedinProfile(data); } catch (err) { console.error('Failed to fetch LinkedIn profile:', err); } finally { setLoadingLinkedin(false); } }, []); // Save review decision const saveDecision = useCallback(async (decision: 'match' | 'not_match' | 'uncertain') => { if (!selectedProfile || !selectedCandidate) return; setSavingDecision(true); try { const response = await fetch(`${API_BASE}/api/review/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ wcms_ppid: selectedProfile.ppid, linkedin_ppid: selectedCandidate.linkedin_ppid, decision: decision, }), }); if (!response.ok) throw new Error('Failed to save decision'); // Refresh data await fetchProfileDetail(selectedProfile.ppid); await fetchStats(); } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { setSavingDecision(false); } }, [selectedProfile, selectedCandidate, fetchProfileDetail, fetchStats]); // Initial load useEffect(() => { if (isAuthenticated) { fetchProfiles(); fetchStats(); fetchSignalTypes(); } }, [fetchProfiles, fetchStats, fetchSignalTypes, isAuthenticated]); // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (savingDecision || !selectedCandidate || !isAuthenticated) return; switch (e.key) { case 'm': case 'M': e.preventDefault(); saveDecision('match'); break; case 'n': case 'N': e.preventDefault(); saveDecision('not_match'); break; case 'u': case 'U': e.preventDefault(); saveDecision('uncertain'); break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [saveDecision, savingDecision, selectedCandidate, isAuthenticated]); // Sync page input with page state useEffect(() => { setPageInput(page.toString()); }, [page]); // Handle page input submission const handlePageInputSubmit = (e: React.FormEvent | React.KeyboardEvent) => { e.preventDefault(); const newPage = parseInt(pageInput, 10); if (!isNaN(newPage) && newPage >= 1 && newPage <= totalPages) { setPage(newPage); } else { setPageInput(page.toString()); // Reset to current page if invalid } }; // Handle logout const handleLogout = () => { sessionStorage.removeItem('review_token'); sessionStorage.removeItem('review_username'); navigate('/review-login'); }; // If not authenticated yet, show loading if (isAuthenticated === null) { return (
Authenticating...
); } // Format confidence score as percentage with color const formatConfidence = (score: number) => { const percent = Math.round(score * 100); const color = percent >= 80 ? 'text-green-600' : percent >= 60 ? 'text-yellow-600' : 'text-red-600'; return {percent}%; }; return (
{/* Header */}

{t('pageTitle')}

{/* Compact Stats Dashboard */} {stats && (
{stats.profiles_with_candidates.toLocaleString()} Profielen
{stats.total_candidates.toLocaleString()} Kandidaten
{stats.reviewed_candidates.toLocaleString()} Beoordeeld
{stats.pending_candidates.toLocaleString()} In afwachting
{stats.likely_wrong_person !== undefined && stats.likely_wrong_person > 0 && (
{stats.likely_wrong_person.toLocaleString()} {t('likelyWrongPerson')}
)}
{stats.review_progress_percent}%
)} {/* Filter Panel */} {showFilters && (
{availableSignals.signal_types.map((signal) => ( ))}
)} {/* Main Content */}
{/* Profile List Sidebar */} {/* Comparison Panel */}
{!selectedProfile ? (

{t('selectProfile')}

) : loadingProfile ? (
{t('loading')}
) : (
{/* WCMS Profile Card */}

{t('wcmsProfile')}

Name {selectedProfile.name}
{selectedProfile.email && (
{t('email')} {selectedProfile.email}
)} {selectedProfile.email_domain && (
{t('domain')} {selectedProfile.email_domain}
)} {selectedProfile.wcms_identifiers && (
WCMS IDs {JSON.stringify(selectedProfile.wcms_identifiers, null, 2)}
)}
{/* Candidate selector */}

{t('matchCandidate')}s ({selectedProfile.match_candidates.length})

    {selectedProfile.match_candidates .filter(c => !hideWrongPerson || !c.is_likely_wrong_person) .map((candidate) => (
  • = 0.8 ? 'high-confidence' : ''}`} onClick={() => { setSelectedCandidate(candidate); fetchLinkedinProfile(candidate.linkedin_ppid); }} >
    {candidate.is_likely_wrong_person ? ( ) : candidate.confidence_score >= 0.8 ? ( ) : ( )} {candidate.linkedin_name} {formatConfidence(candidate.confidence_score)}
    {candidate.is_likely_wrong_person && (
    {t('wrongPersonWarning')}
    )} {candidate.reviewed && ( {candidate.review_decision} )}
  • ))}
{/* LinkedIn Profile Card */}

{t('linkedinProfile')}

{!selectedCandidate ? (

{t('selectCandidate')}

) : loadingLinkedin ? (
) : linkedinProfile ? (
{/* Wrong Person Alert Banner */} {selectedCandidate.is_likely_wrong_person && (
{t('wrongPersonWarning')} {selectedCandidate.wrong_person_reason && (

{selectedCandidate.wrong_person_reason}

)}
)} {/* High Confidence Banner */} {selectedCandidate.confidence_score >= 0.8 && !selectedCandidate.is_likely_wrong_person && (
{t('highConfidence')}

Score: {Math.round(selectedCandidate.confidence_score * 100)}%

)}
Name {linkedinProfile.name}
{linkedinProfile.linkedin_slug && ( )} {/* Match Signals */} {selectedCandidate.match_signals.length > 0 && (
{t('matchSignals')}
{selectedCandidate.match_signals.map((signal, i) => ( {signal} ))}
)} {/* Affiliations */} {linkedinProfile.affiliations.length > 0 && (
{t('affiliations')}
    {linkedinProfile.affiliations.slice(0, 5).map((aff, i) => (
  • {(aff as Record).organization_name || (aff as Record).company || JSON.stringify(aff)}
  • ))}
)}
) : null} {/* Decision Buttons */} {selectedCandidate && !selectedCandidate.reviewed && (
)} {savingDecision && (
{t('savingDecision')}
)}
)}
); }