mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C244): aviso do índice curado, seed de figuras e enrollment
This commit is contained in:
parent
e46ed57e2c
commit
ee3dda757b
10 changed files with 686 additions and 1 deletions
|
|
@ -40,8 +40,10 @@
|
|||
"content:instagram:prune": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/prune-instagram-content.mjs",
|
||||
"archive:catalog": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/catalog-archive-photos.mjs",
|
||||
"faces:index": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/index-archive-faces.mjs",
|
||||
"faces:enroll-figure": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/enroll-face-figure.mjs",
|
||||
"archive:publish": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/publish-archive-photos.mjs",
|
||||
"seed:face-consents": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-consents.mjs",
|
||||
"seed:face-figures": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-figures.mjs",
|
||||
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
|
||||
"campaign:staging:test-account": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/bootstrap-staging-test-account.mjs",
|
||||
"db:check:push-chain": "node scripts/check-push-chain.mjs",
|
||||
|
|
|
|||
257
scripts/enroll-face-figure.mjs
Normal file
257
scripts/enroll-face-figure.mjs
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* C244 — enrolls the reference descriptors of one curated public figure of the
|
||||
* album's "Pessoa pública" facet. Each `--image` is a portrait (official/archive
|
||||
* photo, curated by a human); the CLI prepares it exactly like the batch
|
||||
* (`prepareFaceImage`) and detects faces with the SAME face-api engine the
|
||||
* browser and `faces:index` use, so reference and archive vectors share one
|
||||
* space. The reference is refused unless the image yields EXACTLY one face
|
||||
* (crop the portrait); the descriptor never leaves the server, and no score is
|
||||
* ever produced.
|
||||
*
|
||||
* Modes:
|
||||
* pnpm faces:enroll-figure … plan/dry-run (default) — resolves the
|
||||
* figure and the images; no engine, no write
|
||||
* pnpm faces:enroll-figure … --apply detects and writes the references;
|
||||
* requires FACE_FIGURE_CONFIRM=1 outside
|
||||
* local dev and the declared TEQO_ENV
|
||||
*
|
||||
* Options: --figure <slug> --name "Nome" (required to create) [--full-name]
|
||||
* [--source "Origem"] --image <path> (repeatable) [--replace] [--out <dir>]
|
||||
* [--help]. The facet only lights up after the runbook's bust of the
|
||||
* `archivePhotos` tag (the CLI runs outside Next).
|
||||
*/
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { FACE_SEARCH_MODEL, readFaceVector } from '../src/lib/faceSearch.ts'
|
||||
|
||||
import {
|
||||
TEQO_ENV_DATABASE_BY_ENV,
|
||||
assertEnvironmentDatabaseTarget,
|
||||
assertWriteConfirm,
|
||||
databaseTarget,
|
||||
dieWithLabel,
|
||||
loadCliEnv,
|
||||
writeRepoFile,
|
||||
} from './lib/cli.mjs'
|
||||
import { createNodeFaceEngine } from './lib/faceEngine.mjs'
|
||||
import {
|
||||
FACE_FIGURE_DEFAULT_OUT_DIR,
|
||||
faceFigureReportStamp,
|
||||
formatEnrollFigureReport,
|
||||
parseEnrollFigureArgs,
|
||||
} from './lib/faceFigurePlan.mjs'
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const die = dieWithLabel('faces:enroll-figure')
|
||||
|
||||
const WRITE_CONFIRM_FLAG = 'FACE_FIGURE_CONFIRM'
|
||||
|
||||
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
|
||||
.map(([environment, database]) => `${environment} (${database})`)
|
||||
.join(' | ')
|
||||
|
||||
const HELP = `
|
||||
Uso: pnpm faces:enroll-figure --figure <slug> [opções]
|
||||
|
||||
Enrola os descritores de referência de uma figura pública curada do filtro
|
||||
"Pessoa pública" do álbum (C244). Cada --image precisa ter EXATAMENTE um rosto
|
||||
(retrato oficial/arquivo); o descriptor nunca sai do servidor.
|
||||
|
||||
Opções:
|
||||
--figure <slug> slug da figura (valor de ?pessoa=<slug>)
|
||||
--name "Nome" nome público; obrigatório quando a figura ainda não existe
|
||||
--full-name "…" proveniência (não aparece no site)
|
||||
--source "Origem" origem das imagens (default: retrato: <arquivo>)
|
||||
--image <arquivo> retrato a enrolar (repetível; 1–3 por figura)
|
||||
--replace substitui as referências existentes pelas novas
|
||||
--apply grava (sem a flag é plano/dry-run, sem engine)
|
||||
--out <dir> diretório dos recibos (default ${FACE_FIGURE_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 e TEQO_ENV=${envLines}
|
||||
casando o banco exato. Ao final, buste a tag archivePhotos:
|
||||
POST /api/revalidate?tag=archivePhotos (x-revalidate-secret)
|
||||
`
|
||||
|
||||
const findFigure = (payload, slug) =>
|
||||
payload.find({
|
||||
collection: 'faceFigure',
|
||||
where: { slug: { equals: slug } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const writeReport = async (options, report) => {
|
||||
const stamp = faceFigureReportStamp(report.runAt)
|
||||
const relativePath = join(options.out, 'reports', `face-figure-${stamp}.json`)
|
||||
await writeRepoFile({
|
||||
label: 'faces:enroll-figure',
|
||||
root: process.cwd(),
|
||||
relativePath,
|
||||
body: JSON.stringify(report, null, 2),
|
||||
})
|
||||
return join(process.cwd(), relativePath)
|
||||
}
|
||||
|
||||
const ensureImagesExist = async (images) => {
|
||||
for (const image of images) {
|
||||
try {
|
||||
const info = await stat(image)
|
||||
if (!info.isFile()) throw new Error('não é um arquivo')
|
||||
} catch (error) {
|
||||
die(`imagem inacessível "${image}": ${error?.message || error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseEnrollFigureArgs(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' : 'plan'
|
||||
const runAt = new Date().toISOString()
|
||||
const startedAt = Date.now()
|
||||
console.log(`[faces:enroll-figure] alvo: ${databaseTarget()} | modo: ${mode}`)
|
||||
|
||||
if (options.apply) {
|
||||
assertWriteConfirm({
|
||||
label: 'faces:enroll-figure',
|
||||
flag: WRITE_CONFIRM_FLAG,
|
||||
command: 'pnpm faces:enroll-figure --figure <slug> --image <arquivo> --apply',
|
||||
})
|
||||
assertEnvironmentDatabaseTarget()
|
||||
}
|
||||
|
||||
await ensureImagesExist(options.images)
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
const existingResult = await findFigure(payload, options.figure)
|
||||
const existing = existingResult.docs[0] ?? null
|
||||
if (!existing && !options.name) {
|
||||
die(`figura "${options.figure}" não existe; informe --name para criá-la.`)
|
||||
}
|
||||
|
||||
const referencesBefore = existing?.references?.length ?? 0
|
||||
const action = existing ? 'atualizar' : 'criar'
|
||||
|
||||
if (!options.apply) {
|
||||
const report = {
|
||||
runAt,
|
||||
mode,
|
||||
target: databaseTarget(),
|
||||
model: FACE_SEARCH_MODEL,
|
||||
figure: options.figure,
|
||||
action,
|
||||
referencesBefore,
|
||||
referencesAfter: referencesBefore,
|
||||
images: options.images.map((image) => basename(image)),
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report)
|
||||
for (const line of formatEnrollFigureReport(report)) console.log(line)
|
||||
console.log(`\n[faces:enroll-figure] recibo JSON: ${reportPath}`)
|
||||
console.log('[faces:enroll-figure] dry-run — use --apply para gravar.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const { prepareFaceImage } = await import('../src/utilities/faceIndex/faceDescriptorIndex.ts')
|
||||
const engine = await createNodeFaceEngine(process.cwd()).catch((error) => {
|
||||
die(error?.message || String(error))
|
||||
})
|
||||
|
||||
const added = []
|
||||
for (const image of options.images) {
|
||||
const prepared = await prepareFaceImage(image)
|
||||
const descriptors = await engine.detectPixels(prepared)
|
||||
if (descriptors.length !== 1) {
|
||||
die(
|
||||
`"${image}": ${descriptors.length} rosto(s) detectado(s); a referência exige exatamente 1 (recorte o retrato e tente de novo).`,
|
||||
)
|
||||
}
|
||||
const vector = readFaceVector(descriptors[0])
|
||||
if (!vector) die(`"${image}": descriptor inválido.`)
|
||||
added.push({
|
||||
vector,
|
||||
model: FACE_SEARCH_MODEL,
|
||||
source: options.source ?? `retrato: ${basename(image)}`,
|
||||
addedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
const current = options.replace ? [] : (existing?.references ?? [])
|
||||
const seen = new Set(current.map((reference) => JSON.stringify(reference.vector)))
|
||||
const additions = []
|
||||
for (const reference of added) {
|
||||
const key = JSON.stringify(reference.vector)
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
additions.push(reference)
|
||||
}
|
||||
const references = [...current, ...additions]
|
||||
|
||||
if (existing) {
|
||||
await payload.update({
|
||||
collection: 'faceFigure',
|
||||
id: existing.id,
|
||||
data: {
|
||||
...(options.name ? { name: options.name } : {}),
|
||||
...(options.fullName ? { fullName: options.fullName } : {}),
|
||||
references,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
} else {
|
||||
await payload.create({
|
||||
collection: 'faceFigure',
|
||||
data: {
|
||||
name: options.name,
|
||||
slug: options.figure,
|
||||
...(options.fullName ? { fullName: options.fullName } : {}),
|
||||
active: true,
|
||||
references,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
||||
const report = {
|
||||
runAt,
|
||||
mode,
|
||||
target: databaseTarget(),
|
||||
model: FACE_SEARCH_MODEL,
|
||||
figure: options.figure,
|
||||
action,
|
||||
referencesBefore,
|
||||
referencesAfter: references.length,
|
||||
added: additions.length,
|
||||
images: options.images.map((image) => basename(image)),
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report)
|
||||
for (const line of formatEnrollFigureReport(report)) console.log(line)
|
||||
console.log(`\n[faces:enroll-figure] recibo JSON: ${reportPath}`)
|
||||
console.log(
|
||||
'[faces:enroll-figure] OK — buste a tag archivePhotos para a faceta refletir as referências.',
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
die(error?.message || String(error))
|
||||
})
|
||||
|
|
@ -43,7 +43,8 @@ export const FACE_SEARCH_CONSENT_PARAGRAPHS = [
|
|||
|
||||
export const FACE_INDEX_CONSENT_PARAGRAPHS = [
|
||||
'Aviso público sobre o índice de fotos: as fotos públicas aprovadas do acervo podem conter rostos de várias pessoas. Para que cada pessoa encontre as próprias fotos pela busca por selfie, o sistema mantém um índice biométrico anônimo — vetores matemáticos extraídos dos rostos presentes nessas fotos, sem nome, sem vínculo com cadastro de pessoas e sem qualquer identificação de quem aparece.',
|
||||
'O índice serve apenas para localizar as fotos da própria pessoa que faz a busca. Ele nunca é usado para identificar, nomear ou listar terceiros, e nunca cruza os dados biométricos com contatos, lideranças ou apoiadores.',
|
||||
'O índice serve para localizar fotos: encontrar as fotos da própria pessoa que faz a busca e alimentar o filtro “Pessoa pública” do álbum. Esse filtro identifica exclusivamente as figuras públicas do catálogo curado e aprovado do mandato, com aval jurídico registrado; qualquer rosto fora desse catálogo continua anônimo no índice, sem nome e sem vínculo com cadastro, e nunca é usado para identificar, nomear ou listar terceiros, nem cruza os dados biométricos com contatos, lideranças ou apoiadores.',
|
||||
'O catálogo de figuras é curado e auditável: cada figura tem descritores de referência de retratos oficiais ou do arquivo do mandato, revisados por curadoria humana, e pode ser desativada a qualquer momento, deixando de aparecer no filtro.',
|
||||
'Qualquer pessoa pode retirar seu rosto do índice a qualquer momento, sem custo: use a opção “Minha presença” na página da busca e siga as instruções. A retirada apaga do índice as referências biométricas correspondentes, e o rosto deixa de ser encontrado.',
|
||||
'Para pedir a remoção de uma foto específica, use o canal indicado no álbum público. Fotos que não estejam aprovadas para o público não fazem parte do índice.',
|
||||
]
|
||||
|
|
|
|||
50
scripts/lib/faceFigureCatalogSeed.mjs
Normal file
50
scripts/lib/faceFigureCatalogSeed.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* C244 — the initial curated catalog of public figures for the album's
|
||||
* "Pessoa pública" facet: the six named figures of the owner's decision
|
||||
* (2026-10-01) plus the S30 state-deputy roster already in the campaign
|
||||
* catalog. Names are the public display labels; the slug is `slugify(name)`
|
||||
* and is the `?pessoa=<slug>` URL value, so it must stay stable. The roster
|
||||
* is shared with `src/lib/publicFigureCatalog.ts` (the ficha picker) — same
|
||||
* source (`stateDeputyCatalog`), different purpose: here it is the face
|
||||
* catalog, there the text "quem aparece na peça".
|
||||
*
|
||||
* The seed only creates the identity rows (no references): a figure only shows
|
||||
* up in the facet after a reference is enrolled with `pnpm faces:enroll-figure`
|
||||
* and recognized in an approved photo. Júlio Pinheiro is both a named figure
|
||||
* and a roster entry — the dedupe by slug keeps the curated spelling.
|
||||
*
|
||||
* Pure planning data: no I/O, unit-testable.
|
||||
*/
|
||||
import { slugify } from '../../src/lib/slug.ts'
|
||||
import { stateDeputyCatalog } from '../../src/lib/stateDeputyCatalog.ts'
|
||||
|
||||
/** The named figures of the owner's catalog decision (2026-10-01). */
|
||||
export const FACE_FIGURE_NAMED_ENTRIES = Object.freeze([
|
||||
{ name: 'Jorge Solla', fullName: 'Jorge Solla' },
|
||||
{ name: 'Lula', fullName: 'Luiz Inácio Lula da Silva' },
|
||||
{ name: 'Jerônimo', fullName: 'Jerônimo Rodrigues' },
|
||||
{ name: 'Geraldinho', fullName: 'Geraldo Júnior' },
|
||||
{ name: 'Wagner', fullName: 'Jaques Wagner' },
|
||||
{ name: 'Júlio Pinheiro' },
|
||||
])
|
||||
|
||||
/**
|
||||
* The seed rows, deduped by slug: `{ name, slug, fullName? }`, named figures
|
||||
* first so a roster duplicate never overrides the curated spelling.
|
||||
*/
|
||||
export const faceFigureCatalogSeed = () => {
|
||||
const entries = []
|
||||
const seen = new Set()
|
||||
|
||||
const push = (name, fullName) => {
|
||||
const slug = slugify(name)
|
||||
if (!slug || seen.has(slug)) return
|
||||
seen.add(slug)
|
||||
entries.push(fullName ? { name, slug, fullName } : { name, slug })
|
||||
}
|
||||
|
||||
for (const named of FACE_FIGURE_NAMED_ENTRIES) push(named.name, named.fullName)
|
||||
for (const deputy of stateDeputyCatalog) push(deputy.name)
|
||||
|
||||
return entries
|
||||
}
|
||||
90
scripts/lib/faceFigurePlan.mjs
Normal file
90
scripts/lib/faceFigurePlan.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* C244 — pure planning/reporting of `pnpm faces:enroll-figure`: the argv
|
||||
* parser, the slug contract and the human lines/receipt stamp. No I/O of its
|
||||
* own, so the unit spec drives everything with fakes.
|
||||
*/
|
||||
import { SLUG_PATTERN } from '../../src/lib/slug.ts'
|
||||
|
||||
const USAGE =
|
||||
'uso: `pnpm faces:enroll-figure --figure <slug> [--name "Nome"] [--full-name "Nome completo"] [--source "Origem"] --image <arquivo> [--image <arquivo>]... [--replace] [--apply] [--out <dir>]`'
|
||||
|
||||
export const FACE_FIGURE_DEFAULT_OUT_DIR = 'data/face'
|
||||
|
||||
/**
|
||||
* `pnpm faces:enroll-figure` argv parser — pure, so the unit spec pins it (the
|
||||
* CLI owns the exit and the help text). The default mode is the plan/dry-run.
|
||||
*
|
||||
* @param {string[]} [argv]
|
||||
* @returns {{
|
||||
* figure: string | null,
|
||||
* name: string | null,
|
||||
* fullName: string | null,
|
||||
* source: string | null,
|
||||
* images: string[],
|
||||
* replace: boolean,
|
||||
* apply: boolean,
|
||||
* out: string,
|
||||
* help: boolean,
|
||||
* }}
|
||||
*/
|
||||
export const parseEnrollFigureArgs = (argv = process.argv.slice(2)) => {
|
||||
const options = {
|
||||
figure: null,
|
||||
name: null,
|
||||
fullName: null,
|
||||
source: null,
|
||||
images: [],
|
||||
replace: false,
|
||||
apply: false,
|
||||
out: FACE_FIGURE_DEFAULT_OUT_DIR,
|
||||
help: false,
|
||||
}
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const value = () => {
|
||||
const next = argv[++index]
|
||||
if (next === undefined || next.startsWith('--')) {
|
||||
throw new Error(`faltou valor para ${arg} — ${USAGE}.`)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
if (arg === '--help' || arg === '-h') options.help = true
|
||||
else if (arg === '--apply') options.apply = true
|
||||
else if (arg === '--replace') options.replace = true
|
||||
else if (arg === '--figure') options.figure = value()
|
||||
else if (arg === '--name') options.name = value()
|
||||
else if (arg === '--full-name') options.fullName = value()
|
||||
else if (arg === '--source') options.source = value()
|
||||
else if (arg === '--image') options.images.push(value())
|
||||
else if (arg === '--out') options.out = value()
|
||||
else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`)
|
||||
}
|
||||
|
||||
if (options.help) return options
|
||||
if (!options.figure || !SLUG_PATTERN.test(options.figure)) {
|
||||
throw new Error(`--figure deve ser um slug (ex.: lula) — ${USAGE}.`)
|
||||
}
|
||||
if (options.images.length === 0) {
|
||||
throw new Error(`pelo menos um --image é obrigatório — ${USAGE}.`)
|
||||
}
|
||||
if (options.out.split(/[\\/]/).includes('..')) {
|
||||
throw new Error('--out não pode escapar do diretório do repo (sem "..").')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
/** Receipt filename stamp (same contract as `faces:index`). */
|
||||
export const faceFigureReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-')
|
||||
|
||||
/** The human lines of one enrollment run (plan or apply). */
|
||||
export const formatEnrollFigureReport = (report) => {
|
||||
const lines = [
|
||||
`[faces:enroll-figure] modo: ${report.mode} | figura: ${report.figure} | modelo: ${report.model}`,
|
||||
`[faces:enroll-figure] figura ${report.action} | referências: ${report.referencesBefore} → ${report.referencesAfter}`,
|
||||
`[faces:enroll-figure] imagens: ${report.images.join(', ')}`,
|
||||
`[faces:enroll-figure] bust da tag: POST /api/revalidate?tag=archivePhotos`,
|
||||
]
|
||||
return lines
|
||||
}
|
||||
|
|
@ -80,6 +80,8 @@ export const SCRIPTS_SPEC_PINNED = [
|
|||
'scripts/lib/dossieUnit.mjs',
|
||||
'scripts/lib/e2e-affected-manifest.mjs',
|
||||
'scripts/lib/faceConsentTexts.mjs',
|
||||
'scripts/lib/faceFigureCatalogSeed.mjs',
|
||||
'scripts/lib/faceFigurePlan.mjs',
|
||||
'scripts/lib/faceIndexPlan.mjs',
|
||||
'scripts/lib/flickrApi.mjs',
|
||||
'scripts/lib/flickrPlan.mjs',
|
||||
|
|
|
|||
133
scripts/seed-face-figures.mjs
Normal file
133
scripts/seed-face-figures.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* C244 — seeds the initial curated catalog of public figures (`faceFigure`)
|
||||
* declared in `scripts/lib/faceFigureCatalogSeed.mjs`: the six named figures of
|
||||
* the owner's decision plus the state-deputy roster, WITHOUT reference
|
||||
* descriptors. A seeded figure never appears in the album facet until
|
||||
* `pnpm faces:enroll-figure` enrolls its references and the face index
|
||||
* recognizes it.
|
||||
*
|
||||
* Idempotent by slug: creates only the missing rows and never updates an
|
||||
* existing one (curation edits stay untouched).
|
||||
*
|
||||
* Modes:
|
||||
* pnpm seed:face-figures plan/dry-run (default) — shows the state
|
||||
* pnpm seed:face-figures --apply creates the missing rows; requires
|
||||
* FACE_FIGURE_SEED_CONFIRM=1 outside local
|
||||
* dev and the declared TEQO_ENV matching
|
||||
* the exact database
|
||||
*/
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import {
|
||||
TEQO_ENV_DATABASE_BY_ENV,
|
||||
assertEnvironmentDatabaseTarget,
|
||||
assertWriteConfirm,
|
||||
databaseTarget,
|
||||
dieWithLabel,
|
||||
loadCliEnv,
|
||||
} from './lib/cli.mjs'
|
||||
import { faceFigureCatalogSeed } from './lib/faceFigureCatalogSeed.mjs'
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const die = dieWithLabel('seed:face-figures')
|
||||
const WRITE_CONFIRM_FLAG = 'FACE_FIGURE_SEED_CONFIRM'
|
||||
|
||||
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
|
||||
.map(([environment, database]) => `${environment} (${database})`)
|
||||
.join(' | ')
|
||||
|
||||
const HELP = `
|
||||
Uso: pnpm seed:face-figures [opções]
|
||||
|
||||
Cria as figuras públicas iniciais do filtro "Pessoa pública" do álbum (C244),
|
||||
sem descritores de referência. Nome exibido e slug vêm do catálogo curado; o
|
||||
enrollment das referências é feito pelo comando faces:enroll-figure.
|
||||
|
||||
Modos:
|
||||
(sem flag) plano/dry-run — estado atual; sem escrita
|
||||
--apply cria as figuras ausentes (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local)
|
||||
|
||||
Ambiente:
|
||||
DATABASE_URL alvo (obrigatório em todos os modos)
|
||||
|
||||
--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines}
|
||||
casando o banco exato. O plano é read-only.
|
||||
`
|
||||
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2)
|
||||
if (argv.includes('--help') || argv.includes('-h')) {
|
||||
console.log(HELP)
|
||||
process.exit(0)
|
||||
}
|
||||
const unknown = argv.find((arg) => arg !== '--apply')
|
||||
if (unknown) die(`argumento desconhecido: ${unknown} — uso: pnpm seed:face-figures [--apply].`)
|
||||
|
||||
const apply = argv.includes('--apply')
|
||||
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
|
||||
|
||||
console.log(`[seed:face-figures] alvo: ${databaseTarget()} | modo: ${apply ? 'apply' : 'plan'}`)
|
||||
|
||||
if (apply) {
|
||||
assertWriteConfirm({
|
||||
label: 'seed:face-figures',
|
||||
flag: WRITE_CONFIRM_FLAG,
|
||||
command: 'pnpm seed:face-figures --apply',
|
||||
})
|
||||
assertEnvironmentDatabaseTarget()
|
||||
}
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
const entries = faceFigureCatalogSeed()
|
||||
let created = 0
|
||||
let existingCount = 0
|
||||
|
||||
for (const entry of entries) {
|
||||
const found = await payload.find({
|
||||
collection: 'faceFigure',
|
||||
where: { slug: { equals: entry.slug } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
if (found.docs[0]) {
|
||||
existingCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (!apply) continue
|
||||
await payload.create({
|
||||
collection: 'faceFigure',
|
||||
data: {
|
||||
name: entry.name,
|
||||
slug: entry.slug,
|
||||
...(entry.fullName ? { fullName: entry.fullName } : {}),
|
||||
active: true,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
created += 1
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[seed:face-figures] catálogo: ${entries.length} figura(s) | existentes: ${existingCount} | ${apply ? `criadas: ${created}` : `ausentes: ${entries.length - existingCount}`}`,
|
||||
)
|
||||
|
||||
if (!apply) {
|
||||
console.log('\n[seed:face-figures] dry-run — use --apply para criar as ausentes.')
|
||||
process.exit(0)
|
||||
}
|
||||
console.log(
|
||||
'\n[seed:face-figures] OK — catálogo inicial garantido; enrole as referências com pnpm faces:enroll-figure.',
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
die(error?.message || String(error))
|
||||
})
|
||||
|
|
@ -57,4 +57,12 @@ describe('face consent texts', () => {
|
|||
expect(text).toMatch(/Minha presença/)
|
||||
expect(text).toMatch(/remoção de uma foto/i)
|
||||
})
|
||||
|
||||
it('declares the curated public-figure filter and its limits (C244)', () => {
|
||||
const text = flatten(FACE_INDEX_CONSENT_TEXT)
|
||||
expect(text).toMatch(/catálogo curado/i)
|
||||
expect(text).toMatch(/aval jurídico/i)
|
||||
expect(text).toMatch(/fora desse catálogo continua anônimo/i)
|
||||
expect(text).toMatch(/nunca é usado para identificar, nomear ou listar terceiros/i)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
43
tests/unit/faceFigureCatalogSeed.unit.spec.ts
Normal file
43
tests/unit/faceFigureCatalogSeed.unit.spec.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { stateDeputyCatalog } from '@/lib/stateDeputyCatalog'
|
||||
import {
|
||||
FACE_FIGURE_NAMED_ENTRIES,
|
||||
faceFigureCatalogSeed,
|
||||
} from '../../scripts/lib/faceFigureCatalogSeed.mjs'
|
||||
|
||||
// C244 — the initial catalog of the "Pessoa pública" facet: the six named
|
||||
// figures plus the S30 roster, deduped by the public slug that the `?pessoa`
|
||||
// URL uses.
|
||||
|
||||
describe('faceFigureCatalogSeed', () => {
|
||||
const entries = faceFigureCatalogSeed()
|
||||
|
||||
it('contains the named figures of the owner decision with their display names', () => {
|
||||
const bySlug = new Map(entries.map((entry) => [entry.slug, entry]))
|
||||
|
||||
expect(bySlug.get('jorge-solla')?.name).toBe('Jorge Solla')
|
||||
expect(bySlug.get('lula')).toMatchObject({
|
||||
name: 'Lula',
|
||||
fullName: 'Luiz Inácio Lula da Silva',
|
||||
})
|
||||
expect(bySlug.get('jeronimo')).toMatchObject({ name: 'Jerônimo' })
|
||||
expect(bySlug.get('geraldinho')).toMatchObject({ name: 'Geraldinho' })
|
||||
expect(bySlug.get('wagner')).toMatchObject({ name: 'Wagner' })
|
||||
expect(bySlug.get('julio-pinheiro')).toMatchObject({ name: 'Júlio Pinheiro' })
|
||||
|
||||
// Júlio Pinheiro is both a named figure and a roster entry: one dedupe.
|
||||
expect(entries.length).toBe(FACE_FIGURE_NAMED_ENTRIES.length + stateDeputyCatalog.length - 1)
|
||||
})
|
||||
|
||||
it('has unique, URL-safe slugs and non-empty names', () => {
|
||||
const slugs = entries.map((entry) => entry.slug)
|
||||
expect(new Set(slugs).size).toBe(slugs.length)
|
||||
for (const entry of entries) {
|
||||
expect(entry.slug).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
expect(entry.name.trim().length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
99
tests/unit/faceFigurePlan.unit.spec.ts
Normal file
99
tests/unit/faceFigurePlan.unit.spec.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
FACE_FIGURE_DEFAULT_OUT_DIR,
|
||||
faceFigureReportStamp,
|
||||
formatEnrollFigureReport,
|
||||
parseEnrollFigureArgs,
|
||||
} from '../../scripts/lib/faceFigurePlan.mjs'
|
||||
|
||||
// C244 — the pure argv contract of `faces:enroll-figure`: the slug-shaped
|
||||
// figure, at least one image, no path escape and the mode flags.
|
||||
|
||||
describe('parseEnrollFigureArgs', () => {
|
||||
it('parses the full enrollment with repeated images and the optional provenance', () => {
|
||||
const parsed = parseEnrollFigureArgs([
|
||||
'--figure',
|
||||
'lula',
|
||||
'--name',
|
||||
'Lula',
|
||||
'--full-name',
|
||||
'Luiz Inácio Lula da Silva',
|
||||
'--source',
|
||||
'retrato oficial 2023',
|
||||
'--image',
|
||||
'a.jpg',
|
||||
'--image',
|
||||
'b.jpg',
|
||||
'--replace',
|
||||
'--apply',
|
||||
'--out',
|
||||
'data/face',
|
||||
])
|
||||
|
||||
expect(parsed).toEqual({
|
||||
figure: 'lula',
|
||||
name: 'Lula',
|
||||
fullName: 'Luiz Inácio Lula da Silva',
|
||||
source: 'retrato oficial 2023',
|
||||
images: ['a.jpg', 'b.jpg'],
|
||||
replace: true,
|
||||
apply: true,
|
||||
out: 'data/face',
|
||||
help: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults to plan mode and to the receipt dir', () => {
|
||||
const parsed = parseEnrollFigureArgs(['--figure', 'lula', '--image', 'a.jpg'])
|
||||
expect(parsed.apply).toBe(false)
|
||||
expect(parsed.replace).toBe(false)
|
||||
expect(parsed.out).toBe(FACE_FIGURE_DEFAULT_OUT_DIR)
|
||||
})
|
||||
|
||||
it('requires a slug figure and at least one image', () => {
|
||||
expect(() => parseEnrollFigureArgs(['--image', 'a.jpg'])).toThrow(/--figure/)
|
||||
expect(() => parseEnrollFigureArgs(['--figure', 'Lula', '--image', 'a.jpg'])).toThrow(
|
||||
/--figure/,
|
||||
)
|
||||
expect(() => parseEnrollFigureArgs(['--figure', 'lula'])).toThrow(/--image/)
|
||||
})
|
||||
|
||||
it('refuses an unknown argument and a missing value', () => {
|
||||
expect(() => parseEnrollFigureArgs(['--figure', 'lula', '--image', 'a.jpg', '--nope'])).toThrow(
|
||||
/argumento desconhecido/,
|
||||
)
|
||||
expect(() => parseEnrollFigureArgs(['--figure', 'lula', '--image'])).toThrow(/faltou valor/)
|
||||
})
|
||||
|
||||
it('refuses an --out escaping the repo and lets --help skip the validations', () => {
|
||||
expect(() =>
|
||||
parseEnrollFigureArgs(['--figure', 'lula', '--image', 'a.jpg', '--out', '../etc']),
|
||||
).toThrow(/não pode escapar/)
|
||||
expect(parseEnrollFigureArgs(['--help']).help).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('faceFigureReportStamp / formatEnrollFigureReport', () => {
|
||||
it('makes a filename-safe stamp', () => {
|
||||
expect(faceFigureReportStamp('2026-10-01T23:56:32.123Z')).toBe('2026-10-01T23-56-32-123Z')
|
||||
})
|
||||
|
||||
it('reports the mode, the figure action, the references and the bust', () => {
|
||||
const lines = formatEnrollFigureReport({
|
||||
mode: 'apply',
|
||||
figure: 'lula',
|
||||
model: 'face-api@1.7.15/faceRecognitionNet',
|
||||
action: 'criar',
|
||||
referencesBefore: 0,
|
||||
referencesAfter: 2,
|
||||
images: ['lula.jpg'],
|
||||
})
|
||||
|
||||
expect(lines.join('\n')).toContain('modo: apply | figura: lula')
|
||||
expect(lines.join('\n')).toContain('referências: 0 → 2')
|
||||
expect(lines.join('\n')).toContain('/api/revalidate?tag=archivePhotos')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue