Merge pull request 'Phase 2: trusted Auto-Match refs + accept/reject log' (#90) from feature/face-accuracy-phase2-automatch into master
CI / skip-ci-check (push) Successful in 30s
CI / docker-ci (push) Successful in 32s
CI / python-lint (push) Successful in 33s
CI / secret-scan (push) Successful in 41s
CI / viewer-unit (push) Successful in 1m46s
CI / e2e (push) Failing after 1m56s
CI / admin-unit (push) Successful in 2m2s

This commit was merged in pull request #90.
This commit is contained in:
2026-08-05 10:28:12 -05:00
13 changed files with 485 additions and 87 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ Living plan for product quality, auth/email reliability, and automation.
### Face recognition accuracy (2026-08)
- [x] **Phase 1 — quality sharpness** — Laplacian variance computed on the face crop (was kernel constant); unit tests in `tests/test_face_quality_score.py`; error-tag checklist in `docs/FACE_MATCH_ERROR_TAGGING.md`
- [ ] **Phase 2 — trusted refs + stricter Auto-Match + accept/reject log**
- [x] **Phase 2 — trusted refs + stricter Auto-Match + accept/reject log** — ref quality floor 0.5; browse/run tolerances 0.5/0.4; auto-accept default 85%; `match_decisions` table + logging on Save / auto-accept; tests in `tests/test_auto_match_phase2.py`
- [ ] **Phase 3 — recalibrate confidence on labeled pairs; re-score corpus**
- [ ] **Phase 4 — multi-ref / ensemble embeddings**
- [ ] **Phase 5 — harder reject of junk detections (tiny/blur/pose)**
+12
View File
@@ -177,6 +177,18 @@ export interface AutoMatchResponse {
export interface AcceptMatchesRequest {
face_ids: number[]
accepted_matches?: Array<{
face_id: number
similarity?: number
distance?: number
reference_face_id?: number
}>
rejected_matches?: Array<{
face_id: number
similarity?: number
distance?: number
reference_face_id?: number
}>
}
export interface MaintenanceFaceItem {
+23 -2
View File
@@ -73,8 +73,29 @@ export const peopleApi = {
const res = await apiClient.get<PersonVideosResponse>(`/api/v1/people/${personId}/videos`)
return res.data
},
acceptMatches: async (personId: number, faceIds: number[]): Promise<IdentifyFaceResponse> => {
const res = await apiClient.post<IdentifyFaceResponse>(`/api/v1/people/${personId}/accept-matches`, { face_ids: faceIds })
acceptMatches: async (
personId: number,
body: {
face_ids: number[]
accepted_matches?: Array<{
face_id: number
similarity?: number
distance?: number
reference_face_id?: number
}>
rejected_matches?: Array<{
face_id: number
similarity?: number
distance?: number
reference_face_id?: number
}>
} | number[],
): Promise<IdentifyFaceResponse> => {
const payload = Array.isArray(body) ? { face_ids: body } : body
const res = await apiClient.post<IdentifyFaceResponse>(
`/api/v1/people/${personId}/accept-matches`,
payload,
)
return res.data
},
delete: async (personId: number): Promise<void> => {
+27 -8
View File
@@ -10,15 +10,16 @@ import { useDeveloperMode } from '../context/DeveloperModeContext'
import { useToast } from '../context/ToastContext'
import { useConfirm } from '../context/ConfirmContext'
const DEFAULT_TOLERANCE = 0.6 // Default for regular auto-match (more lenient)
const RUN_AUTO_MATCH_TOLERANCE = 0.5 // Tolerance for Run auto-match button (stricter)
const DEFAULT_TOLERANCE = 0.5 // Browse Auto-Match (Phase 2: stricter than 0.6)
const RUN_AUTO_MATCH_TOLERANCE = 0.4 // Run auto-match button (stricter still)
const DEFAULT_AUTO_ACCEPT_THRESHOLD = 85
export default function AutoMatch() {
const { isDeveloperMode } = useDeveloperMode()
const { showToast } = useToast()
const { confirm } = useConfirm()
const [tolerance, setTolerance] = useState(DEFAULT_TOLERANCE)
const [autoAcceptThreshold, setAutoAcceptThreshold] = useState(70)
const [autoAcceptThreshold, setAutoAcceptThreshold] = useState(DEFAULT_AUTO_ACCEPT_THRESHOLD)
const [isActive, setIsActive] = useState(false)
const [people, setPeople] = useState<AutoMatchPersonSummary[]>([])
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonSummary[]>([])
@@ -611,11 +612,29 @@ export default function AutoMatch() {
setSaving(true)
try {
const faceIds = currentMatches
.filter((match: AutoMatchFaceItem) => selectedFaces[match.id] === true)
.map((match: AutoMatchFaceItem) => match.id)
const accepted = currentMatches.filter(
(match: AutoMatchFaceItem) => selectedFaces[match.id] === true
)
const rejected = currentMatches.filter(
(match: AutoMatchFaceItem) => selectedFaces[match.id] !== true
)
const refId = currentPerson.reference_face_id
await peopleApi.acceptMatches(currentPerson.person_id, faceIds)
await peopleApi.acceptMatches(currentPerson.person_id, {
face_ids: accepted.map((m) => m.id),
accepted_matches: accepted.map((m) => ({
face_id: m.id,
similarity: m.similarity,
distance: m.distance,
reference_face_id: refId,
})),
rejected_matches: rejected.map((m) => ({
face_id: m.id,
similarity: m.similarity,
distance: m.distance,
reference_face_id: refId,
})),
})
// Update original selected faces to current state
const newOriginal: Record<number, boolean> = {}
@@ -624,7 +643,7 @@ export default function AutoMatch() {
})
setOriginalSelectedFaces(prev => ({ ...prev, ...newOriginal }))
showToast(`Saved ${faceIds.length} match(es)`, 'success')
showToast(`Saved ${accepted.length} match(es)`, 'success')
} catch (error) {
console.error('Save failed:', error)
showToast('Failed to save matches. Please try again.', 'error')
+22 -2
View File
@@ -49,6 +49,7 @@ from backend.services.face_service import (
find_similar_faces,
get_auto_match_people_list,
list_unidentified_faces,
record_match_decisions,
)
from backend.services.face_service import (
get_auto_match_person_matches as get_person_matches_service,
@@ -612,7 +613,7 @@ def auto_match_faces(
"""Start auto-match process with tolerance threshold and optional auto-acceptance.
Matches desktop auto-match workflow exactly:
1. Gets all identified people (one face per person, best quality >= 0.3)
1. Gets all identified people (one face per person, best quality >= MIN_AUTO_MATCH_REFERENCE_QUALITY)
2. For each person, finds similar unidentified faces (confidence >= 40%)
3. Returns matches grouped by person, sorted by person name
@@ -620,7 +621,7 @@ def auto_match_faces(
- Only processes persons with frontal or tilted reference faces (not profile)
- Only processes persons with reference face quality > 50% (quality_score > 0.5)
- Only matches with frontal or tilted unidentified faces (not profile)
- Only auto-accepts matches with similarity >= threshold
- Only auto-accepts matches with similarity >= threshold (default 85%)
- Only auto-accepts faces with quality > 50% (quality_score > 0.5)
"""
from backend.db.models import Person, Photo
@@ -649,6 +650,7 @@ def auto_match_faces(
# 3. Quality must be > 50% (quality_score > 0.5)
qualifying_faces = []
accept_log_items = []
for face, distance, confidence_pct in similar_faces:
# Check similarity threshold
if confidence_pct < request.auto_accept_threshold:
@@ -662,6 +664,14 @@ def auto_match_faces(
continue
qualifying_faces.append(face.id)
accept_log_items.append(
{
"face_id": face.id,
"similarity": float(confidence_pct),
"distance": float(distance),
"reference_face_id": reference_face_id,
}
)
# Auto-accept qualifying faces
if qualifying_faces:
@@ -670,6 +680,16 @@ def auto_match_faces(
db, person_id, qualifying_faces
)
auto_accepted_faces += identified_count
if identified_count:
record_match_decisions(
db,
decision="accept",
source="auto_accept",
person_id=person_id,
items=accept_log_items,
user_id=None,
commit=True,
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
+58 -4
View File
@@ -21,7 +21,7 @@ from backend.schemas.people import (
PersonUpdateRequest,
PersonWithFacesResponse,
)
from backend.services.face_service import accept_auto_match_matches
from backend.services.face_service import accept_auto_match_matches, record_match_decisions
router = APIRouter(prefix="/people", tags=["people"])
@@ -278,9 +278,63 @@ def accept_matches(
user_id = current_user["user_id"]
try:
identified_count, updated_count = accept_auto_match_matches(
db, person_id, request.face_ids, user_id=user_id
)
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Person {person_id} not found",
)
if request.face_ids:
accept_auto_match_matches(
db, person_id, request.face_ids, user_id=user_id
)
# Log accepts (with optional scores from client)
score_by_face = {
item.face_id: item for item in (request.accepted_matches or [])
}
accept_items = []
for face_id in request.face_ids:
scored = score_by_face.get(face_id)
accept_items.append(
{
"face_id": face_id,
"similarity": scored.similarity if scored else None,
"distance": scored.distance if scored else None,
"reference_face_id": scored.reference_face_id if scored else None,
}
)
if accept_items:
record_match_decisions(
db,
decision="accept",
source="auto_match",
person_id=person_id,
items=accept_items,
user_id=user_id,
commit=True,
)
if request.rejected_matches:
reject_items = [
{
"face_id": item.face_id,
"similarity": item.similarity,
"distance": item.distance,
"reference_face_id": item.reference_face_id,
}
for item in request.rejected_matches
]
record_match_decisions(
db,
decision="reject",
source="auto_match",
person_id=person_id,
items=reject_items,
user_id=user_id,
commit=True,
)
except ValueError as e:
if "not found" in str(e).lower():
raise HTTPException(
+46
View File
@@ -406,6 +406,51 @@ def ensure_photo_person_linkage_table(inspector) -> None:
print("✅ Created photo_person_linkage table")
def ensure_match_decisions_table(inspector) -> None:
"""Ensure match_decisions table exists for Auto-Match accept/reject logging."""
if "match_decisions" in inspector.get_table_names():
print("️ match_decisions table already exists")
return
print("🔄 Creating match_decisions table...")
with engine.connect() as connection:
with connection.begin():
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS match_decisions (
id SERIAL PRIMARY KEY,
decision TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'auto_match',
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
face_id INTEGER NOT NULL REFERENCES faces(id) ON DELETE CASCADE,
reference_face_id INTEGER REFERENCES faces(id) ON DELETE SET NULL,
similarity NUMERIC,
distance NUMERIC,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
for idx_name, idx_cols in [
("idx_match_decisions_decision", "decision"),
("idx_match_decisions_source", "source"),
("idx_match_decisions_person", "person_id"),
("idx_match_decisions_face", "face_id"),
("idx_match_decisions_created", "created_at"),
("idx_match_decisions_person_face", "person_id, face_id"),
("idx_match_decisions_decision_created", "decision, created_at"),
]:
try:
connection.execute(
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON match_decisions({idx_cols})")
)
except Exception:
pass
print("✅ Created match_decisions table")
def ensure_people_contact_columns(inspector) -> None:
"""Ensure people table has optional email/phone contact columns."""
if "people" not in inspector.get_table_names():
@@ -756,6 +801,7 @@ async def lifespan(app: FastAPI):
ensure_face_excluded_column(inspector)
ensure_role_permissions_table(inspector)
ensure_people_contact_columns(inspector)
ensure_match_decisions_table(inspector)
# Setup auth database tables for both frontends (viewer and admin)
if auth_engine is not None:
+9
View File
@@ -31,4 +31,13 @@ CONFIDENCE_CALIBRATION_METHOD = "empirical" # "empirical", "linear", or "sigmoi
# Faces smaller than this are excluded from auto-match to avoid generic encodings
MIN_AUTO_MATCH_FACE_SIZE_RATIO = 0.005 # 0.5% of image area
# Auto-match reference / accept gates (Phase 2 accuracy)
# Reference faces below this quality are not used for matching
MIN_AUTO_MATCH_REFERENCE_QUALITY = 0.5 # was 0.3 — prefer clearer refs
# Default Auto-Accept threshold (%) — align UI copy (~85%) with code
DEFAULT_AUTO_ACCEPT_THRESHOLD = 85.0
# Browse vs Run tolerances (UI defaults; lower = stricter)
DEFAULT_BROWSE_TOLERANCE = 0.5
DEFAULT_RUN_TOLERANCE = 0.4
+25
View File
@@ -291,3 +291,28 @@ class RolePermission(Base):
Index("idx_role_permissions_role_feature", "role", "feature_key"),
)
class MatchDecision(Base):
"""Admin accept/reject of an Auto-Match (or Identify) suggestion.
Used to retune thresholds (Phase 3) from real JRCC decisions — no PII beyond IDs.
"""
__tablename__ = "match_decisions"
id = Column(Integer, primary_key=True, autoincrement=True)
decision = Column(Text, nullable=False, index=True) # accept | reject
source = Column(Text, nullable=False, default="auto_match", index=True) # auto_match | identify | auto_accept
person_id = Column(Integer, ForeignKey("people.id"), nullable=False, index=True)
face_id = Column(Integer, ForeignKey("faces.id"), nullable=False, index=True)
reference_face_id = Column(Integer, ForeignKey("faces.id"), nullable=True, index=True)
similarity = Column(Numeric, nullable=True) # calibrated confidence 0100 when known
distance = Column(Numeric, nullable=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
__table_args__ = (
Index("idx_match_decisions_person_face", "person_id", "face_id"),
Index("idx_match_decisions_decision_created", "decision", "created_at"),
)
+25 -1
View File
@@ -218,7 +218,12 @@ class AutoMatchRequest(BaseModel):
tolerance: float = Field(0.5, ge=0.0, le=1.0, description="Tolerance threshold (lower = stricter matching)")
auto_accept: bool = Field(False, description="Enable automatic acceptance of matching faces")
auto_accept_threshold: float = Field(70.0, ge=0.0, le=100.0, description="Similarity threshold for auto-acceptance (0-100%)")
auto_accept_threshold: float = Field(
85.0,
ge=0.0,
le=100.0,
description="Similarity threshold for auto-acceptance (0-100%)",
)
use_distance_based_thresholds: bool = Field(False, description="Use distance-based confidence thresholds (stricter for borderline distances)")
@@ -303,12 +308,31 @@ class AutoMatchResponse(BaseModel):
skipped_matches: int = Field(0, description="Number of matches skipped (didn't meet criteria)")
class MatchDecisionScore(BaseModel):
"""Optional scores for an accept/reject decision log entry."""
model_config = ConfigDict(protected_namespaces=())
face_id: int
similarity: float | None = Field(None, description="Calibrated confidence 0100 when known")
distance: float | None = None
reference_face_id: int | None = None
class AcceptMatchesRequest(BaseModel):
"""Request to accept matches for a person."""
model_config = ConfigDict(protected_namespaces=())
face_ids: list[int] = Field(..., min_items=0, description="Face IDs to identify with this person")
accepted_matches: list[MatchDecisionScore] | None = Field(
None,
description="Optional scores for accepted faces (for match_decisions log)",
)
rejected_matches: list[MatchDecisionScore] | None = Field(
None,
description="Faces reviewed but not accepted (logged as reject)",
)
class MaintenanceFaceItem(BaseModel):
+123 -66
View File
@@ -34,11 +34,12 @@ from backend.config import (
DEFAULT_FACE_TOLERANCE,
MAX_FACE_SIZE,
MIN_AUTO_MATCH_FACE_SIZE_RATIO,
MIN_AUTO_MATCH_REFERENCE_QUALITY,
MIN_FACE_CONFIDENCE,
MIN_FACE_SIZE,
USE_CALIBRATED_CONFIDENCE,
)
from backend.db.models import Face, Person, Photo, PhotoTagLinkage, Tag
from backend.db.models import Face, MatchDecision, Person, Photo, PhotoTagLinkage, Tag
from src.utils.exif_utils import EXIFOrientationHandler
from src.utils.pose_detection import RETINAFACE_AVAILABLE, PoseDetector
@@ -2260,6 +2261,118 @@ def calculate_batch_similarities(
return pairs
def _auto_match_pose_order():
"""SQLAlchemy CASE: frontal first, then tilted, then profile (last)."""
return case(
(func.lower(Face.pose_mode).like("%profile%"), 2),
(func.lower(Face.pose_mode).like("%tilted%"), 1),
(func.lower(Face.pose_mode).like("frontal%"), 0),
else_=2,
)
def query_auto_match_reference_faces(
db: Session,
*,
min_quality: float | None = None,
person_id: int | None = None,
) -> List[Face]:
"""Identified faces eligible as Auto-Match references, ordered best-first.
Order: person_id, quality DESC, pose (frontal → tilted → profile).
Caller groups to one face per person (first row wins).
"""
quality_floor = (
MIN_AUTO_MATCH_REFERENCE_QUALITY if min_quality is None else min_quality
)
pose_order = _auto_match_pose_order()
query = (
db.query(Face)
.join(Photo, Face.photo_id == Photo.id)
.filter(Face.person_id.isnot(None))
.filter(Face.quality_score >= quality_floor)
)
if person_id is not None:
query = query.filter(Face.person_id == person_id)
return query.order_by(Face.person_id, Face.quality_score.desc(), pose_order.asc()).all()
def pick_best_reference_face_per_person(
identified_faces: List[Face],
) -> Dict[int, Face]:
"""First face per person_id (assumes list already ordered best-first)."""
person_faces: Dict[int, Face] = {}
for face in identified_faces:
if face.person_id is not None and face.person_id not in person_faces:
person_faces[face.person_id] = face
return person_faces
def record_match_decision(
db: Session,
*,
decision: str,
source: str,
person_id: int,
face_id: int,
reference_face_id: int | None = None,
similarity: float | None = None,
distance: float | None = None,
user_id: int | None = None,
commit: bool = False,
) -> MatchDecision:
"""Append one accept/reject row for later threshold calibration."""
row = MatchDecision(
decision=decision,
source=source,
person_id=person_id,
face_id=face_id,
reference_face_id=reference_face_id,
similarity=similarity,
distance=distance,
user_id=user_id,
)
db.add(row)
if commit:
db.commit()
db.refresh(row)
return row
def record_match_decisions(
db: Session,
*,
decision: str,
source: str,
person_id: int,
items: List[dict],
user_id: int | None = None,
commit: bool = True,
) -> int:
"""Bulk-log match decisions. Each item: face_id + optional similarity/distance/reference_face_id."""
count = 0
for item in items:
face_id = item.get("face_id")
if face_id is None:
continue
record_match_decision(
db,
decision=decision,
source=source,
person_id=person_id,
face_id=int(face_id),
reference_face_id=item.get("reference_face_id"),
similarity=item.get("similarity"),
distance=item.get("distance"),
user_id=user_id,
commit=False,
)
count += 1
if commit and count:
db.commit()
return count
def find_auto_match_matches(
db: Session,
tolerance: float = 0.5,
@@ -2269,7 +2382,7 @@ def find_auto_match_matches(
"""Find auto-match matches for all identified people, matching desktop logic exactly.
Desktop flow (from auto_match_panel.py _start_auto_match):
1. Get all identified faces (one per person, best quality >= 0.3)
1. Get all identified faces (one per person, best quality >= MIN_AUTO_MATCH_REFERENCE_QUALITY)
2. Group by person and get best quality face per person
3. For each person, find similar unidentified faces using _get_filtered_similar_faces
4. Return matches grouped by person
@@ -2285,30 +2398,7 @@ def find_auto_match_matches(
if tolerance is None:
tolerance = DEFAULT_FACE_TOLERANCE
# Get all identified faces (one per person) to use as reference faces
# Desktop query:
# SELECT f.id, f.person_id, f.photo_id, f.location, p.filename, f.quality_score,
# f.face_confidence, f.detector_backend, f.model_name
# FROM faces f
# JOIN photos p ON f.photo_id = p.id
# WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3
# ORDER BY f.person_id, f.quality_score DESC, pose_mode (frontal first, then tilted, then profile)
# Add pose mode ordering: frontal first, then tilted, then profile last
pose_order = case(
(func.lower(Face.pose_mode).like('%profile%'), 2), # Profile = 2 (last)
(func.lower(Face.pose_mode).like('%tilted%'), 1), # Tilted = 1 (second)
(func.lower(Face.pose_mode).like('frontal%'), 0), # Frontal = 0 (first)
else_=2 # Default to last for unknown poses
)
identified_faces: List[Face] = (
db.query(Face)
.join(Photo, Face.photo_id == Photo.id)
.filter(Face.person_id.isnot(None))
.filter(Face.quality_score >= 0.3)
.order_by(Face.person_id, Face.quality_score.desc(), pose_order.asc())
.all()
)
identified_faces = query_auto_match_reference_faces(db)
if not identified_faces:
return []
@@ -2323,12 +2413,7 @@ def find_auto_match_matches(
if not identified_faces:
return []
# Group by person and get the best quality face per person (matching desktop)
person_faces: Dict[int, Face] = {}
for face in identified_faces:
person_id = face.person_id
if person_id not in person_faces:
person_faces[person_id] = face
person_faces = pick_best_reference_face_per_person(identified_faces)
# Convert to ordered list to ensure consistent ordering
# Desktop sorts by person name for consistent, user-friendly ordering
@@ -2417,23 +2502,7 @@ def get_auto_match_people_list(
if unidentified_count == 0:
return []
# Get all identified faces (one per person) to use as reference faces
# Same logic as find_auto_match_matches but without finding matches
pose_order = case(
(func.lower(Face.pose_mode).like('%profile%'), 2), # Profile = 2 (last)
(func.lower(Face.pose_mode).like('%tilted%'), 1), # Tilted = 1 (second)
(func.lower(Face.pose_mode).like('frontal%'), 0), # Frontal = 0 (first)
else_=2 # Default to last for unknown poses
)
identified_faces: List[Face] = (
db.query(Face)
.join(Photo, Face.photo_id == Photo.id)
.filter(Face.person_id.isnot(None))
.filter(Face.quality_score >= 0.3)
.order_by(Face.person_id, Face.quality_score.desc(), pose_order.asc())
.all()
)
identified_faces = query_auto_match_reference_faces(db)
if not identified_faces:
return []
@@ -2448,12 +2517,7 @@ def get_auto_match_people_list(
if not identified_faces:
return []
# Group by person and get the best quality face per person
person_faces: Dict[int, Face] = {}
for face in identified_faces:
person_id = face.person_id
if person_id not in person_faces:
person_faces[person_id] = face
person_faces = pick_best_reference_face_per_person(identified_faces)
# Convert to ordered list with person names
person_faces_list = []
@@ -2503,17 +2567,10 @@ def get_auto_match_person_matches(
Returns:
List of (face, distance, confidence_pct) tuples
"""
from backend.db.models import Face
# Get reference face for this person (best quality >= 0.3)
reference_face = (
db.query(Face)
.filter(Face.person_id == person_id)
.filter(Face.quality_score >= 0.3)
.order_by(Face.quality_score.desc())
.first()
)
refs = query_auto_match_reference_faces(db, person_id=person_id)
if not refs:
return []
reference_face = pick_best_reference_face_per_person(refs).get(person_id)
if not reference_face:
return []
+5 -3
View File
@@ -34,6 +34,8 @@ date | source (auto-match|identify) | face_id | suggested_person_id | true_perso
## After ~100 rows
Summarize counts by tag. Feed into Phase 23 (stricter Auto-Match thresholds,
confidence recalibration). Keep the sheet private; only aggregates belong in
docs/PRs.
Summarize counts by tag. Feed into Phase 3 (confidence recalibration). Keep the
sheet private; only aggregates belong in docs/PRs.
Phase 2 also logs admin accept/reject (and auto-accept) into `match_decisions`
for the same recalibration work — use both the manual sheet and that table.
+109
View File
@@ -0,0 +1,109 @@
"""Unit tests for Auto-Match Phase 2 helpers (no DeepFace / DB required)."""
from __future__ import annotations
from types import SimpleNamespace
from backend.config import (
DEFAULT_AUTO_ACCEPT_THRESHOLD,
DEFAULT_BROWSE_TOLERANCE,
DEFAULT_RUN_TOLERANCE,
MIN_AUTO_MATCH_REFERENCE_QUALITY,
)
from backend.services.face_service import (
pick_best_reference_face_per_person,
record_match_decisions,
)
def _face(person_id: int | None, quality: float, face_id: int = 1):
return SimpleNamespace(id=face_id, person_id=person_id, quality_score=quality)
class TestPhase2ConfigDefaults:
def test_reference_quality_stricter_than_legacy(self):
assert MIN_AUTO_MATCH_REFERENCE_QUALITY >= 0.5
def test_auto_accept_aligns_with_ui_copy(self):
assert DEFAULT_AUTO_ACCEPT_THRESHOLD >= 85.0
def test_run_tolerance_stricter_than_browse(self):
assert DEFAULT_RUN_TOLERANCE < DEFAULT_BROWSE_TOLERANCE
assert DEFAULT_BROWSE_TOLERANCE <= 0.5
class TestPickBestReferenceFacePerPerson:
def test_keeps_first_face_per_person(self):
faces = [
_face(1, 0.9, face_id=10),
_face(1, 0.8, face_id=11),
_face(2, 0.7, face_id=20),
]
picked = pick_best_reference_face_per_person(faces)
assert set(picked.keys()) == {1, 2}
assert picked[1].id == 10
assert picked[2].id == 20
def test_skips_unidentified(self):
faces = [_face(None, 0.9, face_id=1), _face(3, 0.6, face_id=3)]
picked = pick_best_reference_face_per_person(faces)
assert list(picked.keys()) == [3]
assert picked[3].id == 3
def test_empty(self):
assert pick_best_reference_face_per_person([]) == {}
class TestRecordMatchDecisions:
def test_bulk_adds_and_skips_missing_face_id(self):
added: list = []
class FakeSession:
def add(self, row):
added.append(row)
def commit(self):
pass
count = record_match_decisions(
FakeSession(), # type: ignore[arg-type]
decision="accept",
source="auto_match",
person_id=42,
items=[
{"face_id": 1, "similarity": 91.0, "distance": 0.2, "reference_face_id": 9},
{"similarity": 50.0}, # missing face_id — skipped
{"face_id": 2, "similarity": 88.0},
],
user_id=7,
commit=True,
)
assert count == 2
assert len(added) == 2
assert added[0].decision == "accept"
assert added[0].person_id == 42
assert added[0].face_id == 1
assert float(added[0].similarity) == 91.0
assert added[0].user_id == 7
assert added[1].face_id == 2
def test_reject_source(self):
added: list = []
class FakeSession:
def add(self, row):
added.append(row)
def commit(self):
pass
record_match_decisions(
FakeSession(), # type: ignore[arg-type]
decision="reject",
source="auto_match",
person_id=1,
items=[{"face_id": 99, "similarity": 72.0}],
commit=True,
)
assert added[0].decision == "reject"
assert added[0].source == "auto_match"