teqo-backup/src/lib/faceSearch.ts
Francisco Solla cd4942d9e2 feat(C242/C243): abre a busca por selfie a qualquer visitante (escopo B) + seção na homepage
O escopo A/C do gate (PR #1370) é superado por decisão do dono (2026-10-01, aval jurídico declarado): o índice vira anônimo (archivePhotoFace, foto + descritor 128-d, sem nome/vínculo), o enrollment por pessoa morre (faceSubject/faces:enroll removidos) e qualquer visitante encontra as próprias fotos aprovadas.

- faces:index persiste os descritores por foto (transação substitui as linhas + marker = modelo); desaprovar/remover/deletar foto apaga os descritores (hooks + FK cascade)
- POST /api/fotos/selfie casa o vetor da consulta contra o índice em memória (TTL 60 s) e devolve só a interseção com o read aprovado do C233, sem score/nome de terceiro; leave-index apaga os descritores que casarem
- Consentimentos seguem fail-closed: busca-selfie-fotos (consulta) e busca-selfie-indice (aviso público do índice), textos versionados em scripts/lib/faceConsentTexts.mjs e provisionados por pnpm seed:face-consents
- pnpm archive:publish aprova o acervo em lote (draft -> approved; removed nunca) com guardas e recibo
- C243: seção condicional SelfieSearchHomeSection (port do design hi-fi, cenas 390/1280) entre a Central e a newsletter; sem engine/captura na home e sem CTA morto quando fechada
- docs: planos C242/C243, AGENTS-public, runbook §C242, changelog

Provas: gate:fast 457 arquivos/5.055 testes; int full 109/1.075; e2e frontendFotosSelfie 9/9; knip verde.
2026-10-01 12:54:47 -03:00

171 lines
5.9 KiB
TypeScript

/**
* C242 — pure contract of the selfie search over the public photo album
* (scope B: the anonymous descriptor index of the approved archive, decision
* of 2026-10-01, supersedes the A/C enrollment of C234).
*
* The visitor's face descriptor is computed on their own device (the selfie
* never leaves it); the server only ever sees the 128-float vector and answers
* with photos, never with a score. This module owns the wire vocabulary (the
* descriptor length, the model id, the intents), the distance math, the match
* over photo descriptors (which by SHAPE cannot carry a similarity number),
* the result view model (no third-party names) and the deterministic stub the
* e2e build uses in place of the engine.
*
* The anonymous photo descriptors live in `archivePhotoFace` (photo-level, no
* identity); a match never resolves a person — only photos. The visitor's
* vector is never persisted: it lives in the request only.
*/
import type { ArchivePhotoPublicItem } from '@/lib/archivePhotoPublicCatalog'
/** face-api's faceRecognitionNet descriptor (128 floats, euclidean space). */
export const FACE_SEARCH_DESCRIPTOR_LENGTH = 128
/** The engine+model the indexed vectors belong to — a match across models is void. */
export const FACE_SEARCH_MODEL = 'face-api@1.7.15/faceRecognitionNet'
/**
* Internal similarity threshold (face-api's usual 0.6 is a permissive default;
* the feature prefers missing a match over showing the wrong person). Never
* serialized: no caller of this contract receives a distance.
*/
export const FACE_SEARCH_MAX_DISTANCE = 0.45
/** Ceiling of photos in one answer; the oldest beyond it stay only in the album. */
export const FACE_SEARCH_RESULT_LIMIT = 60
/**
* Detector tuning shared by the browser engine and the CLI engine: they must
* load the same three nets with the same options, otherwise the descriptors of
* the selfie and of the archive would live in different spaces.
*/
export const FACE_DETECTOR_INPUT_SIZE = 512
export const FACE_DETECTOR_SCORE_THRESHOLD = 0.3
/** Same-origin directory the browser engine loads the model files from. */
export const FACE_MODEL_DIR = '/fotos/modelos'
export const FACE_SEARCH_INTENTS = ['search', 'leave-index'] as const
/**
* A stored/requested descriptor: exactly `FACE_SEARCH_DESCRIPTOR_LENGTH` finite
* numbers, or nothing (fail-closed — a wrong-length or poisoned vector never
* reaches the math).
*/
export const readFaceVector = (value: unknown): number[] | null => {
if (!Array.isArray(value) || value.length !== FACE_SEARCH_DESCRIPTOR_LENGTH) return null
const vector: number[] = []
for (const component of value) {
if (typeof component !== 'number' || !Number.isFinite(component)) return null
vector.push(component)
}
return vector
}
/**
* Euclidean distance of same-length descriptors; a length mismatch is never a
* match. Accepts typed arrays so the cached index can stay in `Float32Array`.
*/
export const faceEuclideanDistance = (a: ArrayLike<number>, b: ArrayLike<number>): number => {
if (a.length !== b.length || a.length === 0) return Number.POSITIVE_INFINITY
let sum = 0
for (let index = 0; index < a.length; index += 1) {
const delta = a[index] - b[index]
sum += delta * delta
}
return Math.sqrt(sum)
}
/**
* One indexed face of the approved archive: the photo it was found in and its
* descriptor. The row has no identity by construction — this is the scope B
* (anonymous) contract.
*/
export type FaceDescriptorEntry = {
photoId: number
vector: ArrayLike<number>
}
/**
* The distinct photo ids with at least one indexed face strictly under
* `maxDistance` from the query vector, in the order the photos appear in the
* index. `FACE_SEARCH_MAX_DISTANCE` is the only threshold: no caller ever
* receives a distance or a score.
*/
export const findFaceDescriptorPhotoIds = (
vector: ArrayLike<number>,
descriptors: readonly FaceDescriptorEntry[],
): number[] => {
const matched: number[] = []
const seen = new Set<number>()
for (const entry of descriptors) {
if (seen.has(entry.photoId)) continue
if (faceEuclideanDistance(vector, entry.vector) >= FACE_SEARCH_MAX_DISTANCE) continue
seen.add(entry.photoId)
matched.push(entry.photoId)
}
return matched
}
/**
* What one matched photo looks like to the visitor: the album's public view
* minus the third-party names (`people`/`peopleLabel`) and minus anything
* numeric — the shape is the guardrail.
*/
export type FaceSearchPhotoView = {
id: number
alt: string
title: string
takenOn: string | null
shortDateLabel: string | null
municipalityName: string | null
sceneLabel: string | null
metaLabel: string
thumbnailPath: string
mediaPath: string
downloadPath: string
downloadFilename: string
}
export const toFaceSearchPhotoView = (item: ArchivePhotoPublicItem): FaceSearchPhotoView => ({
id: item.id,
alt: item.alt,
title: item.title,
takenOn: item.takenOn,
shortDateLabel: item.shortDateLabel,
municipalityName: item.municipalityName,
sceneLabel: item.sceneLabel,
metaLabel: item.metaLabel,
thumbnailPath: item.thumbnailPath,
mediaPath: item.mediaPath,
downloadPath: item.downloadPath,
downloadFilename: item.downloadFilename,
})
/**
* Deterministic stand-in for the on-device engine (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`,
* e2e builds only): the same bytes always derive the same descriptor, so the
* spec can seed an indexed face whose vector equals the fixture's without ever
* loading a model. Never used outside the stub seam.
*/
export const faceStubDescriptorFromBytes = (bytes: Uint8Array): number[] => {
let hash = 0x811c9dc5
for (const byte of bytes) {
hash ^= byte
hash = Math.imul(hash, 0x01000193) >>> 0
}
let state = hash || 0x9e3779b9
const vector: number[] = []
for (let index = 0; index < FACE_SEARCH_DESCRIPTOR_LENGTH; index += 1) {
state ^= state << 13
state ^= state >>> 17
state ^= state << 5
state >>>= 0
vector.push((state / 0xffffffff) * 2 - 1)
}
return vector
}