feat: Implement auto-match people and person matches API with frontend integration

This commit introduces new API endpoints for retrieving a list of people for auto-matching and fetching matches for specific individuals. The frontend has been updated to utilize these endpoints, allowing for lazy loading of matches and improved state management. The AutoMatch component now supports caching of matches and session storage for user settings, enhancing performance and user experience. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09
2025-11-13 15:19:16 -05:00
parent 4f0b72ee5f
commit c661aeeda6
5 changed files with 669 additions and 36 deletions
+44
View File
@@ -122,6 +122,29 @@ export interface AutoMatchPersonItem {
total_matches: number
}
export interface AutoMatchPersonSummary {
person_id: number
person_name: string
reference_face_id: number
reference_photo_id: number
reference_photo_filename: string
reference_location: string
reference_pose_mode?: string
face_count: number
total_matches: number
}
export interface AutoMatchPeopleResponse {
people: AutoMatchPersonSummary[]
total_people: number
}
export interface AutoMatchPersonMatchesResponse {
person_id: number
matches: AutoMatchFaceItem[]
total_matches: number
}
export interface AutoMatchResponse {
people: AutoMatchPersonItem[]
total_people: number
@@ -215,6 +238,27 @@ export const facesApi = {
const response = await apiClient.post<AutoMatchResponse>('/api/v1/faces/auto-match', request)
return response.data
},
getAutoMatchPeople: async (params?: {
filter_frontal_only?: boolean
}): Promise<AutoMatchPeopleResponse> => {
const response = await apiClient.get<AutoMatchPeopleResponse>('/api/v1/faces/auto-match/people', {
params,
})
return response.data
},
getAutoMatchPersonMatches: async (
personId: number,
params?: {
tolerance?: number
filter_frontal_only?: boolean
}
): Promise<AutoMatchPersonMatchesResponse> => {
const response = await apiClient.get<AutoMatchPersonMatchesResponse>(
`/api/v1/faces/auto-match/people/${personId}/matches`,
{ params }
)
return response.data
},
getMaintenanceFaces: async (params: {
page?: number
page_size?: number
+294 -33
View File
@@ -1,5 +1,8 @@
import { useState, useEffect, useMemo } from 'react'
import facesApi, { AutoMatchResponse, AutoMatchPersonItem, AutoMatchFaceItem } from '../api/faces'
import { useState, useEffect, useMemo, useRef } from 'react'
import facesApi, {
AutoMatchPersonSummary,
AutoMatchFaceItem
} from '../api/faces'
import peopleApi from '../api/people'
import { apiClient } from '../api/client'
import { useDeveloperMode } from '../context/DeveloperModeContext'
@@ -11,8 +14,10 @@ export default function AutoMatch() {
const [tolerance, setTolerance] = useState(DEFAULT_TOLERANCE)
const [autoAcceptThreshold, setAutoAcceptThreshold] = useState(70)
const [isActive, setIsActive] = useState(false)
const [people, setPeople] = useState<AutoMatchPersonItem[]>([])
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonItem[]>([])
const [people, setPeople] = useState<AutoMatchPersonSummary[]>([])
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonSummary[]>([])
// Store matches separately, keyed by person_id
const [matchesCache, setMatchesCache] = useState<Record<number, AutoMatchFaceItem[]>>({})
const [currentIndex, setCurrentIndex] = useState(0)
const [searchQuery, setSearchQuery] = useState('')
const [selectedFaces, setSelectedFaces] = useState<Record<number, boolean>>({})
@@ -22,17 +27,87 @@ export default function AutoMatch() {
const [hasNoResults, setHasNoResults] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
// SessionStorage keys for persisting state and settings
const STATE_KEY = 'automatch_state'
const SETTINGS_KEY = 'automatch_settings'
// Track if initial load has happened
const initialLoadRef = useRef(false)
// Track if settings have been loaded from sessionStorage
const [settingsLoaded, setSettingsLoaded] = useState(false)
// Track if state has been restored from sessionStorage
const [stateRestored, setStateRestored] = useState(false)
// Track if initial restoration is complete (prevents reload effects from firing during restoration)
const restorationCompleteRef = useRef(false)
const currentPerson = useMemo(() => {
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
return activePeople[currentIndex]
}, [filteredPeople, people, currentIndex])
const currentMatches = useMemo(() => {
return currentPerson?.matches || []
}, [currentPerson])
if (!currentPerson) return []
return matchesCache[currentPerson.person_id] || []
}, [currentPerson, matchesCache])
// Shared function for auto-load and refresh
const loadAutoMatch = async () => {
// Load matches for a specific person (lazy loading)
const loadPersonMatches = async (personId: number) => {
// Skip if already cached
if (matchesCache[personId]) {
return
}
try {
const response = await facesApi.getAutoMatchPersonMatches(personId, {
tolerance,
filter_frontal_only: false
})
setMatchesCache(prev => ({
...prev,
[personId]: response.matches
}))
// Update total_matches in people list
setPeople(prev => prev.map(p =>
p.person_id === personId
? { ...p, total_matches: response.total_matches }
: p
))
// If no matches found, remove person from list (matching original behavior)
// Original endpoint only returns people who have matches
if (response.total_matches === 0) {
setPeople(prev => {
const removedIndex = prev.findIndex(p => p.person_id === personId)
// Adjust current index if needed
if (removedIndex !== -1) {
setCurrentIndex(currentIdx => {
if (currentIdx >= removedIndex) {
return Math.max(0, currentIdx - 1)
}
return currentIdx
})
}
return prev.filter(p => p.person_id !== personId)
})
setFilteredPeople(prev => prev.filter(p => p.person_id !== personId))
}
} catch (error) {
console.error('Failed to load matches for person:', error)
// Set empty matches on error, and remove person from list
setMatchesCache(prev => ({
...prev,
[personId]: []
}))
// Remove person if matches failed to load (assume no matches)
setPeople(prev => prev.filter(p => p.person_id !== personId))
setFilteredPeople(prev => prev.filter(p => p.person_id !== personId))
}
}
// Shared function for auto-load and refresh (loads people list only - fast)
const loadAutoMatch = async (clearState: boolean = false) => {
if (tolerance < 0 || tolerance > 1) {
return
}
@@ -40,9 +115,15 @@ export default function AutoMatch() {
setBusy(true)
setIsRefreshing(true)
try {
const response = await facesApi.autoMatch({
tolerance,
auto_accept: false // Don't auto-accept on load/refresh, only on button click
// Clear saved state if explicitly requested (Refresh button)
if (clearState) {
sessionStorage.removeItem(STATE_KEY)
setMatchesCache({}) // Clear matches cache
}
// Load people list only (fast - no match calculations)
const response = await facesApi.getAutoMatchPeople({
filter_frontal_only: false
})
if (response.people.length === 0) {
@@ -62,6 +143,11 @@ export default function AutoMatch() {
setSelectedFaces({})
setOriginalSelectedFaces({})
setIsActive(true)
// Load matches for first person immediately
if (response.people.length > 0) {
await loadPersonMatches(response.people[0].person_id)
}
} catch (error) {
console.error('Auto-match failed:', error)
} finally {
@@ -70,9 +156,181 @@ export default function AutoMatch() {
}
}
// Auto-start auto-match when component mounts or tolerance changes (without auto-accept)
// Load settings from sessionStorage on mount
useEffect(() => {
loadAutoMatch()
try {
const saved = sessionStorage.getItem(SETTINGS_KEY)
if (saved) {
const settings = JSON.parse(saved)
if (settings.tolerance !== undefined) setTolerance(settings.tolerance)
if (settings.autoAcceptThreshold !== undefined) setAutoAcceptThreshold(settings.autoAcceptThreshold)
}
} catch (error) {
console.error('Error loading settings from sessionStorage:', error)
} finally {
setSettingsLoaded(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Load state from sessionStorage on mount (people, current index, selected faces)
// Note: This effect runs after settings are loaded, so tolerance is already set
useEffect(() => {
if (!settingsLoaded) return // Wait for settings to load first
try {
const saved = sessionStorage.getItem(STATE_KEY)
if (saved) {
const state = JSON.parse(saved)
// Only restore state if tolerance matches (cached state is for current tolerance)
if (state.people && Array.isArray(state.people) && state.people.length > 0 &&
state.tolerance === tolerance) {
setPeople(state.people)
if (state.currentIndex !== undefined) {
setCurrentIndex(Math.min(state.currentIndex, state.people.length - 1))
}
if (state.selectedFaces && typeof state.selectedFaces === 'object') {
setSelectedFaces(state.selectedFaces)
}
if (state.originalSelectedFaces && typeof state.originalSelectedFaces === 'object') {
setOriginalSelectedFaces(state.originalSelectedFaces)
}
if (state.matchesCache && typeof state.matchesCache === 'object') {
setMatchesCache(state.matchesCache)
}
if (state.isActive !== undefined) {
setIsActive(state.isActive)
}
if (state.hasNoResults !== undefined) {
setHasNoResults(state.hasNoResults)
}
// Mark that we restored state, so we don't reload
initialLoadRef.current = true
// Mark restoration as complete after state is restored
setTimeout(() => {
restorationCompleteRef.current = true
}, 50)
} else if (state.tolerance !== undefined && state.tolerance !== tolerance) {
// Tolerance changed, clear old cache
sessionStorage.removeItem(STATE_KEY)
}
}
} catch (error) {
console.error('Error loading state from sessionStorage:', error)
} finally {
setStateRestored(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsLoaded])
// Save state to sessionStorage whenever it changes (but only after initial restore)
useEffect(() => {
if (!stateRestored) return // Don't save during initial restore
try {
const state = {
people,
currentIndex,
selectedFaces,
originalSelectedFaces,
matchesCache,
isActive,
hasNoResults,
tolerance, // Include tolerance to validate cache on restore
}
sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
} catch (error) {
console.error('Error saving state to sessionStorage:', error)
}
}, [people, currentIndex, selectedFaces, originalSelectedFaces, matchesCache, isActive, hasNoResults, tolerance, stateRestored])
// Save state on unmount (when navigating away) - use refs to capture latest values
const peopleRef = useRef(people)
const currentIndexRef = useRef(currentIndex)
const selectedFacesRef = useRef(selectedFaces)
const originalSelectedFacesRef = useRef(originalSelectedFaces)
const matchesCacheRef = useRef(matchesCache)
const isActiveRef = useRef(isActive)
const hasNoResultsRef = useRef(hasNoResults)
const toleranceRef = useRef(tolerance)
// Update refs whenever state changes
useEffect(() => {
peopleRef.current = people
currentIndexRef.current = currentIndex
selectedFacesRef.current = selectedFaces
originalSelectedFacesRef.current = originalSelectedFaces
matchesCacheRef.current = matchesCache
isActiveRef.current = isActive
hasNoResultsRef.current = hasNoResults
toleranceRef.current = tolerance
}, [people, currentIndex, selectedFaces, originalSelectedFaces, matchesCache, isActive, hasNoResults, tolerance])
// Save state on unmount (when navigating away)
useEffect(() => {
return () => {
try {
const state = {
people: peopleRef.current,
currentIndex: currentIndexRef.current,
selectedFaces: selectedFacesRef.current,
originalSelectedFaces: originalSelectedFacesRef.current,
matchesCache: matchesCacheRef.current,
isActive: isActiveRef.current,
hasNoResults: hasNoResultsRef.current,
tolerance: toleranceRef.current, // Include tolerance to validate cache on restore
}
sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
} catch (error) {
console.error('Error saving state on unmount:', error)
}
}
}, [])
// Save settings to sessionStorage whenever they change (but only after initial load)
useEffect(() => {
if (!settingsLoaded) return // Don't save during initial load
try {
const settings = {
tolerance,
autoAcceptThreshold,
}
sessionStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
} catch (error) {
console.error('Error saving settings to sessionStorage:', error)
}
}, [tolerance, autoAcceptThreshold, settingsLoaded])
// Initial load on mount (after settings and state are loaded)
useEffect(() => {
if (!initialLoadRef.current && settingsLoaded && stateRestored) {
initialLoadRef.current = true
// Only load if we didn't restore state (no people means we need to load)
if (people.length === 0) {
loadAutoMatch()
// If we're loading fresh, mark restoration as complete immediately
restorationCompleteRef.current = true
} else {
// If state was restored, restorationCompleteRef is already set in the state restoration effect
// But ensure it's set in case state restoration didn't happen
if (!restorationCompleteRef.current) {
setTimeout(() => {
restorationCompleteRef.current = true
}, 50)
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsLoaded, stateRestored])
// Reload when tolerance changes (immediate reload)
// But only if restoration is complete (prevents reload during initial restoration)
useEffect(() => {
if (initialLoadRef.current && restorationCompleteRef.current) {
// Clear matches cache when tolerance changes (matches depend on tolerance)
setMatchesCache({})
loadAutoMatch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tolerance])
@@ -134,7 +392,8 @@ export default function AutoMatch() {
}
// Reload faces after auto-accept to remove auto-accepted faces from the list
await loadAutoMatch()
// Clear cache to get fresh data after auto-accept
await loadAutoMatch(true)
return
}
@@ -213,16 +472,33 @@ export default function AutoMatch() {
}
}
// Load matches when current person changes (lazy loading)
useEffect(() => {
if (currentPerson && restorationCompleteRef.current) {
loadPersonMatches(currentPerson.person_id)
// Preload matches for next person in background
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
if (currentIndex + 1 < activePeople.length) {
const nextPerson = activePeople[currentIndex + 1]
loadPersonMatches(nextPerson.person_id)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPerson?.person_id, currentIndex])
// Restore selected faces when navigating to a different person
useEffect(() => {
if (currentPerson) {
const matches = matchesCache[currentPerson.person_id] || []
const restored: Record<number, boolean> = {}
currentPerson.matches.forEach(match => {
matches.forEach(match => {
restored[match.id] = originalSelectedFaces[match.id] || false
})
setSelectedFaces(restored)
}
}, [currentIndex, filteredPeople.length, people.length]) // Only when person changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentIndex, filteredPeople.length, people.length, currentPerson?.person_id, matchesCache])
const goBack = () => {
if (currentIndex > 0) {
@@ -255,9 +531,10 @@ export default function AutoMatch() {
<div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="flex items-center gap-4">
<button
onClick={loadAutoMatch}
onClick={() => loadAutoMatch(true)}
disabled={busy}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
title="Refresh and start from beginning"
>
{isRefreshing ? 'Refreshing...' : '🔄 Refresh'}
</button>
@@ -507,22 +784,6 @@ export default function AutoMatch() {
Person {currentIndex + 1} of {activePeople.length}
{currentPerson && `${currentPerson.total_matches} matches`}
</div>
<button
onClick={() => {
if (confirm('Are you sure you want to exit auto-match?')) {
setIsActive(false)
setPeople([])
setFilteredPeople([])
setCurrentIndex(0)
setSelectedFaces({})
setOriginalSelectedFaces({})
setSearchQuery('')
}
}}
className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200"
>
Exit Auto-Match
</button>
</div>
</>
)}