teqo-backup/scripts/lib/archiveCatalogPlan.mjs
Francisco Solla 930db3eb02 feat(C245): catalogar o acervo em produção por camadas — metadados primeiro
A catalogação do C232 nunca rodou em produção: catalog.* zerado nas 6.492
fotos publicadas e o engine de visão fora do ar. O pnpm archive:catalog
ganha --metadata-only: deriva município (gazetteer) e temas do texto já
ingerido, sem engine e sem mídia, grava catalog.metadataCheckedAt
(migration aditiva) e não marca catalogedAt — a fila da IA segue aberta.
Nunca escreve catalog.people (C244 assume a faceta por facial).

A passada de IA passa a carimbar os dois markers; a fila de metadados exclui
linha já catalogada pela IA (não rebaixa o source); o --verify relata as duas
camadas com exit pela selecionada; o --apply de metadados dispensa S3_* e
engine. Runbook §C245: metadados → revalidate archivePhotos → IA.

Closes #1417
2026-10-02 10:17:49 -03:00

237 lines
8.6 KiB
JavaScript

/**
* C232 — pure planning/reporting of the photo-archive cataloguing: the argv
* parser, the run summaries (by outcome and by source), the catalogue
* `--verify` inventory and the human lines + JSON receipt. No I/O of its own:
* the queue, the engine and the Payload writes arrive as parameters, so the
* unit spec drives everything with fakes.
*
* C245 adds the two-layer reading: `--metadata-only` selects the deterministic
* layer (municipality/themes from text, no engine) in plan/apply/verify; the
* inventory always reports BOTH layers and the verify exit follows the
* selected one.
*/
const USAGE =
'uso: `pnpm archive:catalog [--apply|--verify] [--metadata-only] [--limit <n>] [--out <dir>]`'
export const ARCHIVE_CATALOG_DEFAULT_OUT_DIR = 'data/archive'
/** Layer labels for the human lines and the receipt (`ai` is the default). */
export const archiveCatalogLayerLabel = (layer) => (layer === 'metadata' ? 'metadados' : 'IA')
/**
* The pending count the `--verify` exit follows: the selected layer only —
* metadata pending hides in the AI inventory and vice versa.
*
* @param {{ ai: { pending: number }, metadata: { pending: number } }} inventory
* @param {string} layer
* @returns {number}
*/
export const archiveCatalogPendingForLayer = (inventory, layer) =>
layer === 'metadata' ? inventory.metadata.pending : inventory.ai.pending
export const archiveCatalogReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-')
/**
* `pnpm archive:catalog` 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;
* `--metadata-only` selects the layer without the vision engine; errors carry
* the usage line.
*
* @param {string[]} [argv]
* @returns {{ apply: boolean, verify: boolean, metadataOnly: boolean, limit: number | null, out: string, help: boolean }}
*/
export const parseArchiveCatalogCliArgs = (argv = process.argv.slice(2)) => {
const options = {
apply: false,
verify: false,
metadataOnly: false,
limit: null,
out: ARCHIVE_CATALOG_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 === '--verify') options.verify = true
else if (arg === '--metadata-only') options.metadataOnly = true
else if (arg === '--limit') options.limit = Number(value())
else if (arg === '--out') options.out = value()
else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`)
}
if (options.help) return options
if (options.apply && options.verify) {
throw new Error(`--apply e --verify são mutuamente exclusivos — ${USAGE}.`)
}
if (options.limit !== null && (!Number.isInteger(options.limit) || options.limit < 1)) {
throw new Error('--limit deve ser >= 1.')
}
if (options.out.split(/[\\/]/).includes('..')) {
throw new Error('--out não pode escapar do diretório do repo (sem "..").')
}
return options
}
const ARCHIVE_CATALOG_SOURCE_KEYS = ['ai', 'metadata', 'none']
const emptyBySource = () => ({ ai: 0, metadata: 0, none: 0 })
/**
* Aggregates one run: what was catalogued (and from where), what was skipped
* (catalogued before the run) and what failed with stage+reason.
*
* @param {Array<{ flickrId: string, status: string, source?: string | null, stage?: string | null, error?: string | null }>} results
*/
export const summarizeArchiveCatalogResults = (results) => {
const bySource = emptyBySource()
const failures = []
let cataloged = 0
let skipped = 0
let failed = 0
for (const result of results) {
if (result.status === 'cataloged') {
cataloged += 1
if (ARCHIVE_CATALOG_SOURCE_KEYS.includes(result.source)) bySource[result.source] += 1
} else if (result.status === 'skipped') {
skipped += 1
} else {
failed += 1
failures.push({
flickrId: result.flickrId,
stage: result.stage ?? null,
error: result.error ?? null,
})
}
}
return { cataloged, skipped, failed, bySource, failures }
}
/**
* The `--verify` inventory over the stored rows (read-only): BOTH layers — the
* AI catalogue (`catalogedAt`) and the metadata pass (`metadataCheckedAt`; a
* row catalogued by the AI counts as metadata-checked because that pass runs
* the same deterministic derivation). `pending` is the honest gap per layer,
* `bySource` counts the last writer's provenance and each coverage counts the
* layer that owns the field. Rows without a stored filename are the one broken
* state C231 already checks.
*
* @param {any[]} rows
*/
export const summarizeArchiveCatalogInventory = (rows) => {
const bySource = emptyBySource()
const missingFilename = []
let aiCataloged = 0
let aiPending = 0
let metadataChecked = 0
let metadataPending = 0
let withoutScene = 0
let withoutMunicipality = 0
for (const row of rows) {
if (!row?.filename) missingFilename.push(String(row?.flickrId ?? ''))
const catalog = row?.catalog ?? {}
const aiDone = Boolean(catalog.catalogedAt)
if (aiDone) {
aiCataloged += 1
if (!catalog.scene) withoutScene += 1
} else {
aiPending += 1
}
if (aiDone || catalog.metadataCheckedAt) {
metadataChecked += 1
if (catalog.municipality === undefined || catalog.municipality === null)
withoutMunicipality += 1
} else {
metadataPending += 1
}
if (ARCHIVE_CATALOG_SOURCE_KEYS.includes(catalog.source)) bySource[catalog.source] += 1
}
return {
total: rows.length,
ai: { cataloged: aiCataloged, pending: aiPending },
metadata: { checked: metadataChecked, pending: metadataPending },
bySource,
withoutScene,
withoutMunicipality,
missingFilename,
}
}
const REPORT_LABEL = '[archive:catalog]'
const engineLine = (engine) =>
engine ? `${REPORT_LABEL} engine: ${engine.host} (${engine.model}, ${engine.scope})` : null
/**
* Human lines for stdout; the JSON file keeps the full report (the CLI already
* announced target/mode before booting Payload). Failures are never hidden —
* each one names its stage and reason.
*
* @param {Record<string, any>} report
* @returns {string[]}
*/
export const formatArchiveCatalogReport = (report) => {
const label = REPORT_LABEL
const lines = []
const engine = engineLine(report.engine)
if (engine) lines.push(engine)
if (report.layer) lines.push(`${label} camada: ${archiveCatalogLayerLabel(report.layer)}`)
if (report.mode === 'plan') {
lines.push(`${label} pendentes na fila: ${report.pending}`)
}
if (report.summary) {
const { cataloged, skipped, failed, bySource } = report.summary
const skippedLabel =
report.layer === 'metadata' ? 'puladas (já na camada)' : 'puladas (já catalogadas)'
lines.push(
`${label} resultado: processadas: ${cataloged} · ${skippedLabel}: ${skipped} · falharam: ${failed}`,
)
lines.push(
`${label} origem: IA: ${bySource.ai} · metadados: ${bySource.metadata} · nada a propor: ${bySource.none}`,
)
}
lines.push(`${label} tempo: ${(report.durationMs / 1000).toFixed(1)}s`)
for (const failure of report.summary?.failures ?? []) {
lines.push(
` - ${failure.flickrId} (${failure.stage ?? 'desconhecido'}): ${failure.error ?? 'erro'}`,
)
}
return lines
}
/** `--verify` human lines (both layers, read-only). */
export const formatArchiveCatalogInventory = (inventory) => {
const label = REPORT_LABEL
const lines = [
`${label} inventário: ${inventory.total} foto(s)`,
`${label} camada IA: catalogadas ${inventory.ai.cataloged} · pendentes ${inventory.ai.pending}`,
`${label} camada metadados: verificadas ${inventory.metadata.checked} · pendentes ${inventory.metadata.pending}`,
`${label} origem: IA: ${inventory.bySource.ai} · metadados: ${inventory.bySource.metadata} · nada a propor: ${inventory.bySource.none}`,
`${label} cobertura: sem cena (IA): ${inventory.withoutScene} · sem município (metadados): ${inventory.withoutMunicipality}`,
]
if (inventory.missingFilename.length > 0) {
lines.push(`${label} sem arquivo armazenado: ${inventory.missingFilename.join(', ')}`)
}
if (inventory.metadata.pending > 0) {
lines.push(`${label} pendentes de metadados: ${inventory.metadata.pending}`)
}
if (inventory.ai.pending > 0) {
lines.push(`${label} pendentes de IA: ${inventory.ai.pending}`)
}
return lines
}