teqo-backup/scripts/catalog-archive-photos.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

338 lines
13 KiB
JavaScript

/**
* C232 — catalogues the private photo archive (C231) with the local vision
* engine: each pending `archivePhoto` is downloaded from the private storage,
* downscaled, analysed by an OpenAI-compatible VLM on the private network and
* written back as a revisable ficha — the assessoria's curation always wins and
* re-running converges ("processadas / puladas / falharam com motivo"). Nothing
* is published and no photo leaves the private network without the explicit
* remote escape; the model never names people.
*
* C245 adds the metadata-only layer (`--metadata-only`): municipality by the
* gazetteer and themes from the text already stored on the row — no media, no
* engine. It stamps its own `catalog.metadataCheckedAt` and never touches
* `catalogedAt`, so the AI queue stays open for the engine pass.
*
* Modes:
* pnpm archive:catalog plan/dry-run (default) — lists the
* pending queue; no engine call, no write
* pnpm archive:catalog --apply catalogues the pending photos; requires
* ARCHIVE_CATALOG_CONFIRM=1 outside local
* dev and TEQO_ENV matching the exact
* database; the AI layer also requires
* the complete S3_* envs and the vision
* engine envs
* pnpm archive:catalog --verify read-only inventory of both layers
*
* Options: --metadata-only (layer), --limit <n> (canary), --out <dir>
* (receipts, default `data/archive`), --help.
*
* Guards: --apply refuses a non-local/production target without the intent flag
* (C155) and demands the declared TEQO_ENV matching the exact database name
* (C195/C231); the AI layer on a remote target also demands the complete S3_*
* set and a vision endpoint on the private network unless
* ARCHIVE_VISION_ALLOW_REMOTE=1 is declared (PII guardrail). The engine config
* lives in ARCHIVE_VISION_* (env only, never the repo).
*
* Runbook: on the homeserver, inside the compose maintenance service with the
* stack env file — see docs/ops/teqo-1313-deploy.md.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { getPayload } from 'payload'
import {
ARCHIVE_VISION_SYSTEM_PROMPT,
buildArchiveVisionPrompt,
parseArchiveVisionOutput,
} from '../src/lib/archivePhotoCatalog.ts'
import {
ARCHIVE_CATALOG_DEFAULT_OUT_DIR,
archiveCatalogLayerLabel,
archiveCatalogPendingForLayer,
archiveCatalogReportStamp,
formatArchiveCatalogInventory,
formatArchiveCatalogReport,
parseArchiveCatalogCliArgs,
summarizeArchiveCatalogInventory,
summarizeArchiveCatalogResults,
} from './lib/archiveCatalogPlan.mjs'
import { analyzeArchivePhotoVision } from './lib/archiveVisionApi.mjs'
import {
TEQO_ENV_DATABASE_BY_ENV,
assertEnvironmentDatabaseTarget,
assertLocalVisionEndpoint,
assertWriteConfirm,
databaseTarget,
dieWithLabel,
isPrivateVisionHost,
loadCliEnv,
mirroredMediaRequired,
} from './lib/cli.mjs'
loadCliEnv()
const die = dieWithLabel('archive:catalog')
const WRITE_CONFIRM_FLAG = 'ARCHIVE_CATALOG_CONFIRM'
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
.map(([environment, database]) => `${environment} (${database})`)
.join(' | ')
const HELP = `
Uso: pnpm archive:catalog [opções]
Pré-cataloga as fotos do acervo privado (C231) em duas camadas:
• metadados (--metadata-only): sem engine — município pelo gazetteer e temas
do texto já ingerido; grava catalog.metadataCheckedAt e NÃO marca a IA
• IA (padrão): com o engine de visão local — legenda/descrição, atividade/
cena, texto visível, temas, município e pessoas públicas (só do catálogo
curado, lidas do TEXTO — nunca de rosto)
A curadoria da assessoria vence: campo curado nunca é sobrescrito. Reexecutar
converge ("processadas / puladas / falharam com motivo") e nada é publicado.
Modos (mutuamente exclusivos; o default é o plano):
(sem flag) plano/dry-run — lista a fila pendente da camada; sem engine nem escrita
--apply cataloga as fotos pendentes (escrita transacional)
--verify inventário read-only das DUAS camadas (sem engine)
Opções:
--metadata-only seleciona a camada sem IA (no plano, no --apply e no --verify)
--limit <n> --apply: processa no máximo n fotos (canário); no plano, só limita a prévia
--out <dir> diretório dos recibos (default ${ARCHIVE_CATALOG_DEFAULT_OUT_DIR})
--help esta ajuda
Ambiente:
ARCHIVE_VISION_BASE_URL endpoint OpenAI-compatible do engine (ex.: http://100.94.122.26:11434/v1)
ARCHIVE_VISION_MODEL modelo de visão (ex.: qwen2.5vl:7b)
ARCHIVE_VISION_API_KEY opcional, quando o servidor local exige chave
DATABASE_URL alvo (obrigatório em todos os modos)
--apply na camada IA em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1,
TEQO_ENV=${envLines} casando o banco exato, as quatro envs S3_* e o engine na
rede privada (host público exige ARCHIVE_VISION_ALLOW_REMOTE=1). O --apply da
camada metadados exige ${WRITE_CONFIRM_FLAG}=1 e o TEQO_ENV casando o banco —
não usa S3 nem engine. O plano e o --verify são read-only.
`
/** Renders the configured engine without calling it (plan/verify banner). */
const configuredEngine = () => {
const raw = String(process.env.ARCHIVE_VISION_BASE_URL ?? '').trim()
if (raw === '') return null
try {
const host = new URL(raw).hostname
return {
host,
scope: isPrivateVisionHost(host) ? 'local' : 'remote',
model: String(process.env.ARCHIVE_VISION_MODEL ?? '').trim() || '(não definido)',
}
} catch {
return null
}
}
const writeReport = async (options, report, suffix = '') => {
const stamp = archiveCatalogReportStamp(report.runAt)
const reportPath = join(options.out, 'reports', `archive-catalog-${stamp}${suffix}.json`)
await mkdir(dirname(reportPath), { recursive: true })
await writeFile(reportPath, JSON.stringify(report, null, 2))
return reportPath
}
/**
* The `--apply` guards, per layer: both layers require the intent flag and the
* declared database; only the AI layer touches media and the engine, so only
* it demands the complete S3_* set and a private `ARCHIVE_VISION_*` endpoint.
* Returns the engine banner data (null on the metadata layer).
*/
const assertApplyGuards = async ({ metadataOnly }) => {
assertWriteConfirm({
label: 'archive:catalog',
flag: WRITE_CONFIRM_FLAG,
command: metadataOnly
? 'pnpm archive:catalog --metadata-only --apply'
: 'pnpm archive:catalog --apply',
})
assertEnvironmentDatabaseTarget()
if (metadataOnly) return null
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 { scope, host } = assertLocalVisionEndpoint()
const model = String(process.env.ARCHIVE_VISION_MODEL ?? '').trim()
if (model === '') {
die('ARCHIVE_VISION_MODEL ausente — informe o modelo de visão (ex.: qwen2.5vl:7b).')
}
return { host, scope, model }
}
async function main() {
const options = parseArchiveCatalogCliArgs(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 layer = options.metadataOnly ? 'metadata' : 'ai'
const layerLabel = archiveCatalogLayerLabel(layer)
const runAt = new Date().toISOString()
console.log(`[archive:catalog] alvo: ${databaseTarget()} | modo: ${mode} | camada: ${layerLabel}`)
// Only the AI layer talks to the engine; the metadata-only plan/apply/verify
// neither needs nor announces the ARCHIVE_VISION_* envs.
let engine = layer === 'ai' ? configuredEngine() : null
if (mode === 'apply') {
engine = await assertApplyGuards({ metadataOnly: options.metadataOnly })
}
const config = (await import('../src/payload.config.ts')).default
const { listArchivePhotos } = await import('../src/utilities/flickr/archivePhotoIngest.ts')
const {
catalogArchivePhoto,
catalogArchivePhotoMetadata,
listArchivePhotoCatalogQueue,
listArchivePhotoMetadataCatalogQueue,
} = await import('../src/utilities/flickr/archivePhotoCatalog.ts')
const payload = await getPayload({ config })
const startedAt = Date.now()
const listQueue = ({ limit } = {}) =>
options.metadataOnly
? listArchivePhotoMetadataCatalogQueue({ payload, limit })
: listArchivePhotoCatalogQueue({ payload, limit })
if (mode === 'verify') {
const rows = await listArchivePhotos(payload)
const inventory = summarizeArchiveCatalogInventory(rows)
const report = {
runAt,
mode,
layer,
target: databaseTarget(),
engine,
...inventory,
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report, '-verify')
for (const line of formatArchiveCatalogInventory(inventory)) console.log(line)
console.log(`\n[archive:catalog] recibo JSON: ${reportPath}`)
if (inventory.missingFilename.length > 0) {
die(
`${inventory.missingFilename.length} foto(s) sem arquivo armazenado — acervo inconsistente.`,
)
}
const pending = archiveCatalogPendingForLayer(inventory, layer)
if (pending > 0) {
die(
`${pending} foto(s) ainda pendentes na camada ${layerLabel} — rode o ${options.metadataOnly ? '--metadata-only --apply' : '--apply'}.`,
)
}
process.exit(0)
}
if (mode === 'plan') {
// The plan always reports the TRUE pending count; `--limit` only caps the
// preview (the apply mode is where it caps the work).
const pending = await listQueue()
const report = {
runAt,
mode,
layer,
target: databaseTarget(),
engine,
pending: pending.length,
photos: pending.slice(0, options.limit ?? 50).map((item) => ({
flickrId: item.flickrId,
title: item.title,
takenAt: item.takenAt,
})),
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatArchiveCatalogReport(report)) console.log(line)
console.log(`\n[archive:catalog] recibo JSON: ${reportPath}`)
process.exit(0)
}
const queue = await listQueue({ limit: options.limit })
console.log(`[archive:catalog] fila (${layerLabel}): ${queue.length} foto(s) pendente(s)`)
/**
* The engine seam: one HTTP call + the fail-closed parse against the closed
* vocabularies. A transport error or an off-contract answer throws and the
* pipeline turns it into a named failure — never a write. Built only for the
* AI layer (the metadata path never calls it).
*/
const analyze =
layer === 'ai'
? async ({ imageDataUrl }) => {
const content = await analyzeArchivePhotoVision({
baseUrl: process.env.ARCHIVE_VISION_BASE_URL,
model: process.env.ARCHIVE_VISION_MODEL,
apiKey: process.env.ARCHIVE_VISION_API_KEY,
imageDataUrl,
prompt: buildArchiveVisionPrompt(),
systemPrompt: ARCHIVE_VISION_SYSTEM_PROMPT,
})
const suggestion = parseArchiveVisionOutput(content)
if (!suggestion) {
throw new Error('resposta do engine fora do contrato JSON da catalogação')
}
return suggestion
}
: null
const results = []
for (const item of queue) {
const result = options.metadataOnly
? await catalogArchivePhotoMetadata({ payload, item })
: await catalogArchivePhoto({ payload, item, analyze })
results.push({ flickrId: item.flickrId, ...result })
const detail =
result.status === 'cataloged'
? ` (${result.source})`
: result.status === 'failed'
? ` [${result.stage}]: ${result.error}`
: ''
console.log(`[archive:catalog] ${result.status}${detail} ${item.flickrId}`)
}
const summary = summarizeArchiveCatalogResults(results)
const report = {
runAt,
mode,
layer,
target: databaseTarget(),
engine,
summary,
results: results.map((result) => ({
flickrId: result.flickrId,
status: result.status,
source: result.source ?? null,
stage: result.stage ?? null,
error: result.error ?? null,
})),
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(options, report)
for (const line of formatArchiveCatalogReport(report)) console.log(line)
console.log(`\n[archive:catalog] recibo JSON: ${reportPath}`)
if (summary.cataloged === 0 && results.length > 0) {
die('nenhuma foto foi catalogada — falhas listadas no recibo.')
}
process.exit(0)
}
main().catch((error) => {
die(error?.message || String(error))
})