mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
- /fotos/encontre + POST /api/fotos/selfie: só o vetor 128-d, sem imagem, sem score e sem nomes de terceiros; fail-closed pela flag photoAlbum.selfieSearchEnabled (default false) e pelos Consents por chave estável (busca-selfie-fotos/busca-selfie-indice, não seedados — a superfície nasce fechada até o aval jurídico/DPIA). - Índice A/C do gate (PR #1370): só quem aderiu é indexado; descriptors do acervo são transitórios. Collection faceSubject (admin-only, sem vínculo com Contact), vínculos matchedPhotos e marker faces.checkedKey no archivePhoto (modelo + enrolledAt). - Engine @vladmandic/face-api nos dois lados: browser lazy com modelos same-origin (copy-face-vision-assets no prebuild/predev/Docker) e CLI node-wasm sem tfjs-node. CLIs ops-only faces:enroll / faces:index --apply|--verify com guardas C231/C232. - Opt-out self-service por reconhecimento (leave-index) + canal de remoção do C233; rate limit próprio (namespace selfie:<hash>) e bounded body extraído do beacon. - UI portada classe-a-classe do design estendido (paridade certificada, tier primário); e2e frontendFotosSelfie (manifest+curado+projeto, engine stubada) e canário do engine real; runbook §C234, contrato em AGENTS-public.md e changelog. - Migration aditiva add_face_subject (SQL revisado); hard-stops aprovados na sessão.
266 lines
9.1 KiB
JavaScript
266 lines
9.1 KiB
JavaScript
/**
|
|
* C234 — indexes the approved photo archive for the selfie search: each photo
|
|
* is downloaded from the private storage, prepared small and handed to the SAME
|
|
* face-api engine the browser runs; the descriptors are compared against the
|
|
* enrolled subjects and thrown away — only the photo↔subject links and the
|
|
* photo's revision marker are persisted (A/C scope of the gate, PR #1370: no
|
|
* anonymous face index anywhere). Adding/re-enrolling a subject changes the
|
|
* revision, so the next run reprocesses every approved photo.
|
|
*
|
|
* Modes:
|
|
* pnpm faces:index plan/dry-run (default) — the queue; no engine, no write
|
|
* pnpm faces:index --apply processes the queue; requires
|
|
* FACE_INDEX_CONFIRM=1 outside local dev, the
|
|
* declared TEQO_ENV and the complete S3_* set
|
|
* when the target media is remote
|
|
* pnpm faces:index --verify read-only inventory (indexed/stale/consent)
|
|
*
|
|
* Options: --limit <n> (canary), --refresh (ignores the marker), --out <dir>
|
|
* (receipts, default `data/face`), --help.
|
|
*
|
|
* Runbook: on the workstation, against the stack env — see
|
|
* docs/ops/teqo-1313-deploy.md.
|
|
*/
|
|
import { join } from 'node:path'
|
|
|
|
import { getPayload } from 'payload'
|
|
|
|
import { FACE_INDEX_CONSENT_KEY } from '../src/lib/campaignConsentKeys.ts'
|
|
import { FACE_SEARCH_MODEL } from '../src/lib/faceSearch.ts'
|
|
|
|
import {
|
|
TEQO_ENV_DATABASE_BY_ENV,
|
|
assertEnvironmentDatabaseTarget,
|
|
assertWriteConfirm,
|
|
databaseTarget,
|
|
dieWithLabel,
|
|
loadCliEnv,
|
|
mirroredMediaRequired,
|
|
writeRepoFile,
|
|
} from './lib/cli.mjs'
|
|
import { createNodeFaceEngine } from './lib/faceEngine.mjs'
|
|
import {
|
|
FACE_INDEX_DEFAULT_OUT_DIR,
|
|
faceIndexReportStamp,
|
|
formatFaceIndexInventory,
|
|
formatFaceIndexReport,
|
|
parseFaceIndexCliArgs,
|
|
summarizeFaceIndexInventory,
|
|
summarizeFaceIndexResults,
|
|
} from './lib/faceIndexPlan.mjs'
|
|
|
|
loadCliEnv()
|
|
|
|
const die = dieWithLabel('faces:index')
|
|
|
|
const WRITE_CONFIRM_FLAG = 'FACE_INDEX_CONFIRM'
|
|
|
|
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
|
|
.map(([environment, database]) => `${environment} (${database})`)
|
|
.join(' | ')
|
|
|
|
const HELP = `
|
|
Uso: pnpm faces:index [opções]
|
|
|
|
Indexa as fotos APROVADAS do acervo para a busca por selfie: por foto, detecta
|
|
os rostos com os mesmos modelos do navegador, compara com os descritores das
|
|
pessoas inscritas e grava só os vínculos (foto ↔ pessoa) + a revisão da foto.
|
|
Os descritores dos rostos do acervo são descartados — não existe índice anônimo.
|
|
|
|
Modos (mutuamente exclusivos; o default é o plano):
|
|
(sem flag) plano/dry-run — fila e revisão; sem engine nem escrita
|
|
--apply processa a fila (engine local + escrita transacional)
|
|
--verify inventário read-only (indexadas/desatualizadas/consentimento)
|
|
|
|
Opções:
|
|
--limit <n> --apply: processa no máximo n fotos (canário)
|
|
--refresh ignora o marcador de revisão e reprocessa tudo
|
|
--out <dir> diretório dos recibos (default ${FACE_INDEX_DEFAULT_OUT_DIR})
|
|
--help esta ajuda
|
|
|
|
Ambiente:
|
|
DATABASE_URL alvo (obrigatório em todos os modos)
|
|
|
|
--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1, TEQO_ENV=${envLines}
|
|
casando o banco exato e as quatro envs S3_* quando a mídia for remota. O plano e o
|
|
--verify são read-only.
|
|
`
|
|
|
|
const writeReport = async (options, report, suffix = '') => {
|
|
const stamp = faceIndexReportStamp(report.runAt)
|
|
const relativePath = join(options.out, 'reports', `face-index-${stamp}${suffix}.json`)
|
|
const body = JSON.stringify(report, null, 2)
|
|
await writeRepoFile({
|
|
label: 'faces:index',
|
|
root: process.cwd(),
|
|
relativePath,
|
|
body,
|
|
})
|
|
return join(process.cwd(), relativePath)
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseFaceIndexCliArgs(process.argv.slice(2))
|
|
if (options.help) {
|
|
console.log(HELP)
|
|
process.exit(0)
|
|
}
|
|
|
|
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
|
|
|
|
const mode = options.apply ? 'apply' : options.verify ? 'verify' : 'plan'
|
|
const runAt = new Date().toISOString()
|
|
console.log(`[faces:index] alvo: ${databaseTarget()} | modo: ${mode}`)
|
|
|
|
if (mode === 'apply') {
|
|
assertWriteConfirm({
|
|
label: 'faces:index',
|
|
flag: WRITE_CONFIRM_FLAG,
|
|
command: 'pnpm faces:index --apply',
|
|
})
|
|
assertEnvironmentDatabaseTarget()
|
|
const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts')
|
|
const storage = resolveS3StorageEnv(process.env)
|
|
if (mirroredMediaRequired({ s3Enabled: storage.enabled })) {
|
|
die(
|
|
'escrita em produção/remoto exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas o acervo não está acessível no alvo.',
|
|
)
|
|
}
|
|
}
|
|
|
|
const config = (await import('../src/payload.config.ts')).default
|
|
const { listArchivePhotoFaceIndexQueue, indexArchivePhotoFaces } =
|
|
await import('../src/utilities/faceSubjects/faceSubjectPhotoIndex.ts')
|
|
const { listFaceSubjects } = await import('../src/utilities/faceSubjects/faceSubjectReads.ts')
|
|
const { getConsentByKey } = await import('../src/utilities/campaignConsent.ts')
|
|
|
|
const payload = await getPayload({ config })
|
|
const startedAt = Date.now()
|
|
|
|
if (mode === 'verify') {
|
|
const [queue, subjects, consent] = await Promise.all([
|
|
listArchivePhotoFaceIndexQueue({ payload }),
|
|
listFaceSubjects(payload),
|
|
getConsentByKey(payload, FACE_INDEX_CONSENT_KEY),
|
|
])
|
|
const inventory = summarizeFaceIndexInventory({
|
|
queue,
|
|
subjects,
|
|
currentConsentHash: consent?.contentHash ?? null,
|
|
})
|
|
const report = {
|
|
runAt,
|
|
mode,
|
|
target: databaseTarget(),
|
|
model: FACE_SEARCH_MODEL,
|
|
...inventory,
|
|
durationMs: Date.now() - startedAt,
|
|
}
|
|
const reportPath = await writeReport(options, report, '-verify')
|
|
for (const line of formatFaceIndexInventory(inventory, FACE_SEARCH_MODEL)) console.log(line)
|
|
console.log(`\n[faces:index] recibo JSON: ${reportPath}`)
|
|
|
|
const problems = []
|
|
if (!consent) {
|
|
problems.push(
|
|
`consentimento de adesão ausente (chave ${FACE_INDEX_CONSENT_KEY}) — a busca responde vazio para todos`,
|
|
)
|
|
}
|
|
if (inventory.staleConsentSubjects > 0) {
|
|
problems.push(
|
|
`${inventory.staleConsentSubjects} sujeito(s) com consentimento vencido — re-consentir e re-enrollar`,
|
|
)
|
|
}
|
|
if (inventory.stale > 0) {
|
|
problems.push(`${inventory.stale} foto(s) desatualizada(s) — rode o --apply`)
|
|
}
|
|
if (problems.length > 0) die(problems.join('; '))
|
|
process.exit(0)
|
|
}
|
|
|
|
const queue = await listArchivePhotoFaceIndexQueue({
|
|
payload,
|
|
refresh: options.refresh,
|
|
limit: mode === 'plan' ? undefined : (options.limit ?? undefined),
|
|
})
|
|
|
|
if (mode === 'plan') {
|
|
const report = {
|
|
runAt,
|
|
mode,
|
|
target: databaseTarget(),
|
|
model: FACE_SEARCH_MODEL,
|
|
queue: {
|
|
indexKey: queue.indexKey,
|
|
totalApproved: queue.totalApproved,
|
|
stale: queue.stale,
|
|
eligibleSubjects: queue.eligibleSubjects,
|
|
items: queue.items.length,
|
|
},
|
|
summary: { indexed: 0, failed: 0, linkedPhotos: 0, linksBySubject: [], failures: [] },
|
|
preview: queue.items.slice(0, options.limit ?? 20).map((item) => ({ photoId: item.id })),
|
|
durationMs: Date.now() - startedAt,
|
|
}
|
|
const reportPath = await writeReport(options, report)
|
|
for (const line of formatFaceIndexReport(report)) console.log(line)
|
|
console.log(`\n[faces:index] recibo JSON: ${reportPath}`)
|
|
process.exit(0)
|
|
}
|
|
|
|
if (queue.eligibleSubjects === 0) {
|
|
die(
|
|
'nenhuma pessoa elegível no índice — rode o faces:enroll antes (o lote não tem o que procurar).',
|
|
)
|
|
}
|
|
|
|
const engine = await createNodeFaceEngine(process.cwd()).catch((error) => {
|
|
die(error.message)
|
|
})
|
|
|
|
const results = []
|
|
for (const item of queue.items) {
|
|
const result = await indexArchivePhotoFaces({
|
|
payload,
|
|
item,
|
|
indexKey: queue.indexKey,
|
|
analyze: engine.detectPixels,
|
|
})
|
|
results.push({ photoId: item.id, ...result })
|
|
const detail =
|
|
result.status === 'failed'
|
|
? ` [${result.stage}]: ${result.error}`
|
|
: result.matchedSubjects.length > 0
|
|
? ` → ${result.matchedSubjects.length} vínculo(s)`
|
|
: ''
|
|
console.log(`[faces:index] ${result.status}${detail} foto ${item.id}`)
|
|
}
|
|
|
|
const summary = summarizeFaceIndexResults(results)
|
|
const report = {
|
|
runAt,
|
|
mode,
|
|
target: databaseTarget(),
|
|
model: FACE_SEARCH_MODEL,
|
|
queue: {
|
|
indexKey: queue.indexKey,
|
|
totalApproved: queue.totalApproved,
|
|
stale: queue.stale,
|
|
eligibleSubjects: queue.eligibleSubjects,
|
|
items: queue.items.length,
|
|
},
|
|
summary,
|
|
durationMs: Date.now() - startedAt,
|
|
}
|
|
const reportPath = await writeReport(options, report)
|
|
for (const line of formatFaceIndexReport(report)) console.log(line)
|
|
console.log(`\n[faces:index] recibo JSON: ${reportPath}`)
|
|
|
|
if (summary.indexed === 0 && results.length > 0) {
|
|
die('nenhuma foto foi processada com sucesso — falhas listadas no recibo.')
|
|
}
|
|
process.exit(0)
|
|
}
|
|
|
|
main().catch((error) => {
|
|
die(error?.message || String(error))
|
|
})
|