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
This commit is contained in:
Francisco Solla 2026-10-02 09:36:17 -03:00
parent 539138db0a
commit 930db3eb02
16 changed files with 21825 additions and 228 deletions

View file

@ -0,0 +1 @@
**C245 — catalogação do acervo em produção: camada de metadados sem IA (2026-10-02):** a catalogação do C232 nunca rodou em produção (`catalog.*` zerado nas 6.492 fotos publicadas), e a faceta Município/atividade e a legenda do card dependiam do 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 baixar mídia — usando o mesmo núcleo transacional/fresh-read e os mesmos builders puros, com o texto base extraído num helper compartilhado (`archivePhotoCatalogText`). A camada grava `catalog.metadataCheckedAt` (novo marker com migration aditiva `add_archive_photo_metadata_checked`) e **não** marca `catalogedAt`, então a fila da IA segue aberta; nunca escreve `catalog.people` (a faceta de pessoa é da C244, por facial). A passada de IA passa a carimbar os dois markers, as duas filas convergem (a de metadados exclui linha já catalogada pela IA para não rebaixar o `source`), o `--verify` relata as **duas camadas** (exit 1 pela selecionada) e o `--apply` de metadados dispensa `S3_*` e engine — só `ARCHIVE_CATALOG_CONFIRM` e `TEQO_ENV` casando o banco. O runbook §C245 documenta a ordem de produção: metadados → `POST /api/revalidate?tag=archivePhotos` (o write do container de manutenção não busta o ISR) → IA quando o engine subir. Nenhuma UI nova; curadoria vence; nada é publicado.

View file

@ -1452,6 +1452,59 @@ Rollback: a migration `add_archive_photo_catalog` é aditiva; o rollback funcion
é limpar o grupo `catalog`/`curatedFields` no admin. Reexecutar nunca duplica nem
sobrescreve curadoria.
## C245 — catalogação em produção: camada de metadados primeiro
O mesmo comando roda em **duas camadas independentes** (migration aditiva
`add_archive_photo_metadata_checked`, coluna `catalog.metadataCheckedAt`):
- **metadados** (`--metadata-only`): sem engine e sem mídia — lê o texto já
ingerido (título, descrição, álbuns, tags) e deriva **município** (gazetteer)
e **temas**. Grava `catalog.metadataCheckedAt` e **não** preenche
`catalogedAt`, então a foto continua na fila da IA. Nunca escreve
`catalog.people` (a faceta de pessoa é da C244, por facial).
- **IA** (padrão, seção acima): legenda/descrição/atividade/texto visível;
quando roda, marca `catalogedAt` **e** `metadataCheckedAt` (a derivação de
texto roda nessa passada).
Ordem em produção (staging primeiro):
```bash
# 1) canário de metadados no staging (sem engine, sem S3):
ssh homeserver && cd ~/stack
docker compose --profile maintenance run --rm \
-e TEQO_ENV=staging -e ARCHIVE_CATALOG_CONFIRM=1 \
teqo-staging-migrate pnpm archive:catalog --metadata-only --apply --limit 5
# 2) lote de metadados no staging e, validado, em produção (serviço/banco do alvo):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e ARCHIVE_CATALOG_CONFIRM=1 \
teqo-1313-migrate pnpm archive:catalog --metadata-only --apply
# 3) depois de cada lote, buste o cache do álbum: o write roda no container de
# manutenção e o ISR do app não é revalidado por ele.
curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
# 4) inventário das DUAS camadas (read-only; sai 1 se a camada selecionada
# tiver pendentes):
docker compose --profile maintenance run --rm teqo-1313-migrate pnpm archive:catalog --verify
docker compose --profile maintenance run --rm teqo-1313-migrate pnpm archive:catalog --metadata-only --verify
```
A camada de IA não muda os comandos da seção C232: com o engine no ar, o
`--apply` padrão processa toda foto sem `catalogedAt` (as já marcadas pelos
metadados inclusive) e preserva o município/temas gravados; depois do lote,
repita o passo 3.
Guardas: `--metadata-only --apply` exige `ARCHIVE_CATALOG_CONFIRM=1` fora de
dev local e `TEQO_ENV` casando o nome exato do banco (`teqo_staging`/
`teqo_1313`) — **não** exige `S3_*` nem engine (não há mídia no caminho). O
recibo JSON registra `layer` e o `--verify` sempre imprime as duas camadas; o
exit 1 segue a camada selecionada.
Rollback: a migration `add_archive_photo_metadata_checked` é aditiva
(`catalog.metadataCheckedAt` + índice); o rollback funcional é anular o marker
via API/DB (o campo é `readOnly` no admin) e reexecutar. Reexecutar converge:
foto com `metadataCheckedAt` não é reprocessada pela camada pura, a IA continua
pegando quem não tem `catalogedAt` e a curadoria nunca é sobrescrita.
## C242 — busca por selfie aberta (índice anônimo do acervo)
A busca por selfie (`/fotos/encontre` + `POST /api/fotos/selfie`) cobre **qualquer

View file

@ -0,0 +1,180 @@
# Impl: C245 — Catalogação do acervo em produção (município, atividade, legenda)
Status: aprovado
Atualizado em: 2026-10-01
Issue: #1417
Intenção: docs/plans/album-catalogacao-producao.md
Appetite restante: herdado — ~1 dia eng + tempo de máquina
## Leitura da intenção
- **Outcome:** o acervo publicado (6.492 fotos) deixa de ter só a faceta **Data**: (1) a camada **metadata** grava `catalog.municipality` e `catalog.themes` a partir do texto já ingerido, e `/fotos` passa a renderizar a faceta **Município** e o card com `data · município`; (2) a camada **IA**, quando o engine local subir, grava legenda/descrição/atividade/texto visível, e o card ganha legenda real e a faceta **Atividade**. Tudo com recibo, reexecução idempotente, curadoria vencendo e nada publicado automaticamente.
- **O que NÃO negociar:** metadata-only **nunca** escreve `catalog.people` (o C244 assume a faceta por facial); nenhuma proposta sobrescreve `curatedFields`; nada muda `publicationStatus`; a IA segue chamada só por endereço privado (guardrail PII); o marker da camada metadata não pode bloquear a fila da IA; reexecutar não duplica nem repaga; sem segunda curadoria e sem superfície nova.
- **O que reavaliar:** a hipótese da intenção sugeria reaproveitar `buildArchivePhotoCatalogWrite` para a camada metadata (`docs/plans/album-catalogacao-producao.md:45`). A engenharia reavalia: o builder da IA carrega semântica de `people`/`alt`/`catalogedAt` que o puro não pode ter — nasce um builder dedicado **no mesmo dono**, com o texto base e o mini-merge extraídos em helpers compartilhados para as duas camadas não divergirem. O marker `catalog.metadataCheckedAt` da hipótese é adotado como proposto.
## Abordagem recomendada
```mermaid
flowchart LR
A[archivePhoto<br/>catalog vazio] --> C{fila metadata:<br/>metadataCheckedAt e catalogedAt ausentes}
C --> D[texto base puro<br/>alt · título · descrição · álbuns · tags]
D --> E[resolveMentionedMunicipalityId<br/>+ classifySpeechByGazetteer]
E --> F[buildArchivePhotoMetadataCatalogWrite<br/>município + temas · source · metadataCheckedAt]
F --> G[(transação: fresh-read curatedFields<br/>update context archivePhotoCatalog)]
G --> H[hook deriva municipalitySlug<br/>e searchText]
H --> I[POST /api/revalidate?tag=archivePhotos]
A -.-> J[fila IA intacta:<br/>catalogedAt ausente]
J --> K[archive:catalog --apply<br/>S3_* + ARCHIVE_VISION_*]
K --> L[legenda · descrição · atividade<br/>texto visível · pessoas do texto]
L --> G
I --> M[/fotos: facetas Município e Atividade<br/>card data · município · atividade + legenda]
```
**Opções consideradas:** A) duas camadas no mesmo dono, marker próprio e builder puro dedicado (recomendada) | B) só IA quando o engine subir, reusando `catalogedAt`/`source` para adiantar o texto | C) script/SQL ad-hoc paralelo.
**Recomendação:** A — o produtor de `/fotos` já existe (C233) e o que falta é dado; a camada pura é derivável do texto já ingerido (sem modelo, sem mídia) e a IA entra depois pela mesma transação/guardas do C232, sem reabrir o que o puro marcou.
**Rejeitadas:** B porque reusar `catalogedAt` para metadata torna a fila da IA ambígua — ou a IA nunca pega uma linha `metadata` (o marker bloqueia o processamento futuro), ou passa a reabri-las e o `source` deixa de ser testemunho da passada que gravou (podendo virar `none` sem nenhuma mudança de conteúdo), além de um "nada a propor" do puro bloquear a IA para sempre; C porque duplicaria transação, curadoria, guardas e recibo — exatamente o "twin" que a doutrina proíbe.
### Componentes / mudanças
**Contrato puro (no dono atual, sem I/O e sem `server-only`)**
- **`archivePhotoCatalogText(source, extras?)`** (`src/lib/archivePhotoCatalog.ts`): junta/filtra o texto base da ficha (alt, título, descrição, álbuns, tags) com extras opcionais; a IA passa `suggestion.caption`/`visibleText` (hoje inline em `src/utilities/flickr/archivePhotoCatalog.ts:199-209`) e a metadata passa vazio. Um dono do join impede as duas camadas de divergirem.
- **`buildArchivePhotoMetadataCatalogWrite({ municipalityId, gazetteerThemes, curatedFields, currentCatalog, metadataCheckedAt })`** (`src/lib/archivePhotoCatalog.ts`): merge puro que só propõe `municipality` e `themes` — **nunca `people`**; respeita `curatedFields`; devolve `{ data: { catalog: { municipality?, themes?, source: 'metadata' | 'none', metadataCheckedAt } }, source }`; nunca toca `catalogedAt`. Reusa `archivePhotoThemesFrom` (`:176-183`) e o vocabulário `ArchivePhotoCuratedField` (`:62-74`).
- **`buildArchivePhotoCatalogWrite` (edit)** (`src/lib/archivePhotoCatalog.ts:406-520`): ganha `metadataCheckedAt` na entrada e o grava no `catalog` ao lado de `source`/`catalogedAt` — a passada de IA também marca a camada metadata, pois é nela que a derivação determinística de texto roda. Os helpers privados de "o que pode ser escrito" e `pick` (`:438-469`) passam a ser compartilhados pelos dois builders, sem mudar comportamento.
- **Testes do puro** (`tests/unit/archivePhotoCatalog.unit.spec.ts:26-50,195+`): factories `suggestion()`/`writeInput()` ganham `metadataCheckedAt`; novo describe do builder metadata (campos propostos, curated vence, `source`, markers, nunca `people`).
**Server-side Payload (mesmo arquivo dono do C232)**
- **`listArchivePhotoMetadataCatalogQueue` / `catalogArchivePhotoMetadata`** (`src/utilities/flickr/archivePhotoCatalog.ts`, `import 'server-only'` já no topo): fila `where { 'catalog.metadataCheckedAt': { exists: false }, 'catalog.catalogedAt': { exists: false } }` (o segundo termo protege legado/linha já catalogada), sem download/sharp/engine — só DB. Reusa `toItem`/`catalogSelect` (`:68-105`), `resolveMentionedMunicipalityId` (`src/utilities/municipality/municipalityMentionResolver.ts:15-38`), `classifySpeechByGazetteer` (`src/lib/speechGazetteer.ts:447`) e `archivePhotoCatalogText`; devolve o mesmo `ArchivePhotoCatalogResult`. Estágios de falha: `resolve` e `write`.
- **Núcleo transacional extraído (privado)** no mesmo arquivo: fresh-read `{ alt, curatedFields, catalog }` + `skip(doc)` + `payload.update` com `context: { archivePhotoCatalog: true }` + `withPayloadTransaction` (`src/utilities/payloadTransaction.ts`), parametrizado por um `buildWrite(doc)`; a derivação de texto (município/temas) compartilhada fica em `deriveArchivePhotoCatalogText`. `catalogArchivePhoto` (IA) e `catalogArchivePhotoMetadata` passam a chamá-lo; o comportamento externo da IA é inalterado (download → image → analyze → resolve continuam com os mesmos estágios nomeados).
- **Fila da IA** (`listArchivePhotoCatalogQueue`, `:113-143`): inalterada — continua sendo o único dono do `catalogedAt`; a IA passa a gravar `metadataCheckedAt` no write (via builder).
**Schema**
- **Campo `metadataCheckedAt`** no `catalogGroup` (`src/collections/ArchivePhoto.ts:53-153`), depois de `catalogedAt`: `date`, `index: true`, `admin.readOnly: true`, descrição de idempotência da camada sem IA ("preenchida não reprocessa o puro"). Precedente de marker só-de-batch: `faces.checkedAt/checkedKey` do C234 (`src/collections/ArchivePhoto.ts:311-337`; gravação em `src/utilities/faceIndex/faceDescriptorIndex.ts:243-252`).
- **Migration `add_archive_photo_metadata_checked`**: `pnpm migrate:create add_archive_photo_metadata_checked` gera `.ts`/`.json` + registro em `src/migrations/index.ts` (não editar à mão; última entrada hoje `20261001_040904_add_archive_photo_face`, `:563-565`). Aditiva: coluna `catalog_metadata_checked_at timestamp(3) with time zone` + índice `archive_photo_catalog_catalog_metadata_checked_at_idx`; `down` derruba índice e coluna — mesmo padrão de `src/migrations/20260926_132106_add_archive_photo_catalog.ts:39,54,72,81`. `push:false` (`src/payload.config.ts:173`); `pnpm generate:types` em seguida.
**CLI/ops**
- **`--metadata-only`** (`scripts/catalog-archive-photos.mjs` + `scripts/lib/archiveCatalogPlan.mjs:23-60`): seletor de camada em plan/`--apply`/`--verify`. No `--apply` metadata-only as guardas caem para `assertWriteConfirm` (`ARCHIVE_CATALOG_CONFIRM`) + `assertEnvironmentDatabaseTarget` (`scripts/lib/cli.mjs:312-320,238-301`), **sem** `mirroredMediaRequired` (`:197-202`) e **sem** `assertLocalVisionEndpoint`/`ARCHIVE_VISION_MODEL` (`:373-403`) — não há mídia nem engine no caminho. No modo IA, tudo como hoje.
- **Recibo/inventário layer-aware** (`scripts/lib/archiveCatalogPlan.mjs:72-195`): parser com `metadataOnly`; resumo ganha `layer: 'metadata' | 'ai'`; `summarizeArchiveCatalogInventory` passa a expor `metadata: { checked, pending }`, `ai: { cataloged, pending }`, `bySource`, cobertura (`withoutScene` sobre as linhas da IA, `withoutMunicipality` sobre as linhas verificadas pelo metadata) e `missingFilename`; as linhas humanas mostram **as duas camadas** e o exit do `--verify` é pela camada selecionada (`--metadata-only` → pendência metadata; sem flag → pendência IA).
- **Runbook §C245** em `docs/ops/teqo-1313-deploy.md`, antes de `## C242` (`:1455`): passo 1 metadata-only em produção (sem engine/S3); passo 2 `POST /api/revalidate?tag=archivePhotos` (o write no container de manutenção não busta o ISR do app — `revalidateTagSafely` no-op fora de request, `src/utilities/documents.ts:27-34`; tag allowlistada em `src/utilities/revalidateRequest.ts:15,38`; rota `src/app/(frontend)/api/revalidate/route.ts:84`); passo 3 IA quando o engine subir (subida já documentada em §C232, `:1411-1433`); verificação e rollback funcional (grupo aditivo; limpar no admin como em `:1451-1453`).
- **Changelog** `docs/changelog/<data>-c245.md` (parágrafo único iniciando `**C245 — ... (data):**`, molde `docs/changelog/2026-09-26-c232.md:1`; guard additions-only `scripts/check-changelog-append-only.mjs:105-115`).
- **Migration:** a acima (aditiva, 1 coluna + 1 índice; stop humano antes de executar no fluxo `--auto`).
- **Access / Consent:** intocados — `canReadArchivePhoto`/`payloadAdminOnly` e `publicationStatus` não mudam; o CLI continua o ator confiável com `overrideAccess: true` documentado (`src/utilities/flickr/archivePhotoCatalog.ts:132-133,232-233,265-266`). Sem Consent novo: não há coleta nova, biometria ou publicação.
- **UI:** nenhuma (Impeccable A) — a ficha usa o grupo `catalog` existente (campo novo readOnly) e as facetas/card do C233 (`src/lib/archivePhotoPublicCatalog.ts:264-300,308-317,337-369`) passam a renderizar quando houver dado; a superfície pública só reflete depois da revalidação da tag. Sem dispatch de designer.
### Dados → forma (se aplicável)
Não aplicável — a intenção declara que a catalogação não apresenta números; cobertura por camada é recibo operacional do `--verify`, não leitura pública.
## Decisões de engenharia
### D1 — Marker próprio da camada metadata
- **Opções:** A) coluna nova `catalog.metadataCheckedAt` (date, index, readOnly), com migration aditiva | B) reusar `catalog.catalogedAt` com `source=metadata`.
- **Recomendação:** A — a fila da IA é "`catalogedAt` ausente" (`src/utilities/flickr/archivePhotoCatalog.ts:126`) e precisa continuar sendo; o marker separado espelha o precedente C234 (`faces.checkedAt/checkedKey`, `src/collections/ArchivePhoto.ts:311-337`; int `tests/int/faceDescriptorIndex.int.spec.ts:138-143`).
- **Rejeitadas:** B porque faria a IA nunca pegar a linha `metadata` (ou exigiria reabri-la), tornando o `source` um recálculo — podendo virar `none` numa linha já gravada sem mudança de conteúdo (procedência desonesta) — e porque um "nada a propor" do puro bloquearia a IA permanentemente; `catalogedAt` segue chave exclusiva da IA e o `source` segue contando o que de fato entrou.
### D2 — Duas filas, duas semânticas
- **Opções:** A) fila metadata = `metadataCheckedAt` ausente **e** `catalogedAt` ausente; fila IA = `catalogedAt` ausente (inalterada); a passada de IA grava `metadataCheckedAt` junto | B) fila metadata = só `metadataCheckedAt` ausente | C) um único `--refresh`.
- **Recomendação:** A — a condição dupla evita reprocessar linha já catalogada pela IA (incluindo legado com `catalogedAt`, hoje zero em produção) e evita rebaixar `source`; a IA marca a camada metadata porque a derivação de texto roda dentro dela (linha que passou pela IA já passou pelo puro). `--verify` relata as duas camadas; `--metadata-only --verify` falha por pendência de metadata, sem a flag por pendência de IA, sempre listando as duas.
- **Rejeitadas:** B porque uma linha legada com `catalogedAt` entraria na fila de metadata e poderia recomputar `source` (`ai` → `metadata`/`none`); C porque não há necessidade de reprocessar e o refresh reabriria a porta para repagar tudo.
### D3 — Builder puro dedicado + texto base compartilhado
- **Opções:** A) `buildArchivePhotoMetadataCatalogWrite` novo no mesmo lib, com helpers de merge/texto compartilhados com o builder da IA | B) reusar `buildArchivePhotoCatalogWrite` com `suggestion` vazio | C) SQL direto.
- **Recomendação:** A — o builder novo só propõe `municipality`/`themes`, nunca `people`, devolve `source` honesto (`metadata|none`), grava `metadataCheckedAt` e nunca `catalogedAt`; `archivePhotoCatalogText` elimina a divergência do texto base (hoje inline em `src/utilities/flickr/archivePhotoCatalog.ts:199-209`). Os helpers privados de `pick`/`writes` são compartilhados (depth check: nenhum módulo novo nasce para copiar o que já existe).
- **Rejeitadas:** B porque reusar o builder da IA com sugestão vazia gravaria `catalogedAt` (bloqueando a fila da IA para sempre) e misturaria a semântica de `people`/`alt` da IA na camada que não pode tê-las; C porque joga fora transação, curadoria e os hooks que derivam `municipalitySlug`/`searchText`.
### D4 — Pipeline metadata no dono server-side, núcleo transacional único
- **Opções:** A) `listArchivePhotoMetadataCatalogQueue` + `catalogArchivePhotoMetadata` em `src/utilities/flickr/archivePhotoCatalog.ts`, com o núcleo fresh-read/skip/update/context extraído e compartilhado com a IA | B) pipeline em arquivo novo | C) duplicar o bloco transacional dentro do CLI.
- **Recomendação:** A — o dono server-side já é esse arquivo (C232) e o padrão "fresh-read dentro da transação + `context.archivePhotoCatalog` + bypass documentado" não pode existir em duas versões; a extração é privada (API pública inalterada) e os pipelines diferem só no que antecede a transação (download/sharp/analyze vs. nada) e no `shouldSkip` (`catalogedAt` para IA; `metadataCheckedAt || catalogedAt` para metadata). O item reusa o mesmo `ArchivePhotoCatalogItem`/`toItem`/`catalogSelect`.
- **Rejeitadas:** B cria o twin do pipeline no mesmo domínio; C tira transação/curadoria do dono e faz o CLI conhecer o shape da ficha.
### D5 — CLI `--metadata-only` com guardas por camada e recibo honesto
- **Opções:** A) `--metadata-only` como seletor de camada; apply metadata-only não exige `S3_*` nem engine; verify biplex com exit pela camada | B) comando novo (`archive:catalog:metadata`) | C) exigir as guardas completas mesmo no metadata-only.
- **Recomendação:** A — a camada pura não baixa mídia nem chama engine, então exigir S3/`ARCHIVE_VISION_*` só criaria bloqueio artificial em produção (o engine está fora do ar, que é o motivo do item); as guardas que importam (`ARCHIVE_CATALOG_CONFIRM`, `TEQO_ENV` com o nome exato do banco) continuam. O recibo carrega `layer` e o `--verify` mantém a leitura das duas camadas — metadata pendente não esconde IA pendente.
- **Rejeitadas:** B multiplica scripts e recibo; C atrasa a entrega de valor (município) atrás de um engine indisponível.
### D6 — Entrega e execução em produção
- **Opções:** A) ferramenta + runbook §C245 na mesma entrega; execução em produção como passo operacional pós-merge com revalidação da tag | B) executar o lote de produção nesta sessão | C) publicar direto pela UI.
- **Recomendação:** A — a sessão de implementação não escreve em produção (proibição da skill; DB de prod é stop): o runbook documenta metadata-only primeiro (sem engine), `revalidate` da tag `archivePhotos`, verify, e a IA quando o engine subir. Nenhuma UI nova (Impeccable A; sem dispatch de designer).
- **Rejeitadas:** B viola o guardrail de escrita em produção e o stop de banco; C não existe — a publicação é a C233 e continua ato humano.
### D7 — Testes
- **Opções:** A) unit do builder/texto; unit do parser/inventário/linhas layer-aware; guarda por subprocesso; int metadata→IA sobre `teqo_wt245_test`; sem e2e novo (OPS72) | B) e2e novo | C) só manual.
- **Recomendação:** A — cobre o que não pode falhar em silêncio: (i) unit `tests/unit/archivePhotoCatalog.unit.spec.ts` (builder metadata: município/temas, curated vence, `source`, `metadataCheckedAt`, nunca `people`; builder IA grava o marker); (ii) unit `tests/unit/archiveCatalogPlan.unit.spec.ts:16-141` (parser com `metadataOnly`, resumo com `layer`, inventário de duas camadas, linhas); (iii) `tests/unit/archiveCatalogCli.unit.spec.ts` (harness spawnSync `:12-36`): com `--metadata-only --apply`, `ARCHIVE_VISION_BASE_URL` pública setada e sem S3, o processo **não** morre nas guardas de engine/S3 — sem tocar banco real (porta local fechada; asserção nas mensagens ausentes `host público`/`S3_BUCKET`/`ARCHIVE_VISION_BASE_URL ausente`); (iv) int `tests/int/archivePhotoCatalog.int.spec.ts` (fixtures `:67-121`): metadata grava município/temas/marker e não grava `people`/`catalogedAt`; a linha sai da fila metadata e **continua** na fila IA; a IA depois grava caption/scene, preserva o município e reescreve o marker; linha legada (com `catalogedAt` e sem marker, escrita com `context.archivePhotoCatalog`) é excluída das duas filas; curadoria vence; falha da resolução (payload de teste que delega ao real mas lança no `find` da collection `municipality`) não escreve e a linha permanece na fila. Sem e2e novo: a superfície admin é stock e o álbum já é coberto (`frontendFotos`, OPS72).
- **Rejeitadas:** B porque o admin é stock e o álbum não muda (OPS72); C porque idempotência/curadoria/guardas são exatamente o risco do lote.
## Fases verificáveis
1. **Fase 1 — Contrato puro + schema** (quota ~0,25 dia): `archivePhotoCatalogText`, `buildArchivePhotoMetadataCatalogWrite`, `metadataCheckedAt` no builder da IA (e factories do unit), campo no `catalogGroup`; `pnpm migrate:create add_archive_photo_metadata_checked` (revisar diff à mão), `pnpm migrate` local, `pnpm generate:types`; unit do puro. Gate: `pnpm gate:fast`.
2. **Fase 2 — Pipeline metadata** (quota ~0,25 dia): fila/pipeline + núcleo transacional extraído em `src/utilities/flickr/archivePhotoCatalog.ts`; int metadata→IA no `teqo_wt245_test`. Gate: `pnpm gate:fast` + int local.
3. **Fase 3 — CLI layer-aware** (quota ~0,25 dia): parser/resumo/inventário/linhas, `--metadata-only`, guardas por camada; unit de `archiveCatalogPlan` e do subprocesso; plan/`--verify` contra o banco do worktree (read-only). Gate: `pnpm gate:fast`.
4. **Fase 4 — Ops e entrega** (quota ~0,25 dia): runbook §C245 antes de `## C242`, changelog `docs/changelog/<data>-c245.md`, plano no commit/PR. A execução em produção (metadata-only → revalidate → verify; IA quando o engine subir) fica documentada no runbook e **fora do run desta sessão**.
## Rabbit holes / Não escopo (engenharia)
- Engine de visão de terceiro, OCR dedicado, recorte/edição de imagem — produto já cortou (PII) e a intenção manda fora.
- `catalog.people` derivado de texto na camada metadata — o C244 assume a faceta por facial; escrever aqui seria inventar a segunda curadoria de pessoa.
- Reabrir linhas com `catalogedAt` (legado) ou criar `--refresh` — nenhuma linha legada existe hoje e a fila dupla de D2 já resolve o futuro.
- Backfill de `metadataCheckedAt` em linhas já catalogadas — sem demanda; a IA grava o marker quando roda.
- Superfície nova de revisão/fila de curadoria — Impeccable A; só nasce no gate.
- Env nova (`ARCHIVE_METADATA_*`) — a camada pura não precisa de engine/mídia.
- Segundo script/comando ou segunda fila para a metadata — o seletor `--metadata-only` é a camada.
- Otimizar o lote (paralelismo, bulk update) — 6,5k linhas DB-only cabem no tempo de máquina; medir antes.
## Adiado com gatilho (pós-simplify)
- **Predicado único de "metadata pendente"** (fila no `where`, `skip` transacional e inventário):
hoje a mesma semântica mora em 3 mecânicas diferentes. **Gatilho:** um quarto
consumidor em JS ou uma mudança da regra (novo marker/refresh) — aí extrair o
predicado no dono. (cheap_polish, score 2)
- **Corrida metadata×IA no write** (o skip lê `catalogedAt`, mas um commit de IA entre o
read e o update da metadata poderia rebaixar o `source`): as camadas rodam
serializadas por ops (runbook) e o `source` da IA não é sobrescrito por mutação de
campos; **gatilho:** se as camadas passarem a rodar concorrentes, trocar por update
condicional por marcador. (cheap_polish, score 2)
- **Teste de falha de resolução** espião no primeiro `payload.find`: **gatilho:** próxima
edição do pré-transação de `catalogArchivePhotoMetadata` — estreitar o stub por
collection. (cheap_polish, score 2)
## Riscos e mitigação
1. **Migration no fluxo `--auto`** — é aditiva e pequena (1 coluna + 1 índice), mas o fluxo para para confirmação humana antes de executá-la; o diff gerado é revisado à mão contra o snapshot e aplicado primeiro no banco do worktree; nenhuma migration entregue é reescrita.
2. **Escrita em produção fora de hora** — o run desta sessão não escreve em produção; os guardas `ARCHIVE_CATALOG_CONFIRM` + `TEQO_ENV=production`/`teqo_1313` continuam, e o passo 1 do runbook roda no serviço de manutenção com o env file do stack.
3. **Cache ISR não bustado pelo CLI** — o write no container não tem request scope (`src/utilities/documents.ts:27-34`); sem o passo 2 (`POST /api/revalidate?tag=archivePhotos`) as facetas não aparecem. O runbook fixa a ordem: apply → revalidate → verify.
4. **`undefined` vs `null` e curadoria** — Payload ignora `undefined` e nunca limpa; os builders nunca devolvem `null` para "não escrever" (só `undefined`/omitido), e o fresh-read dentro da transação garante que uma edição humana concorrente vence (precedente C234 `faces`, `tests/int/faceDescriptorIndex.int.spec.ts:138-143`).
5. **Marker bloqueando a IA** — a fila da IA continua sendo só `catalogedAt`; o int metadata→IA prova que a linha marcada pelo metadata volta à fila da IA e que a IA mantém município/temas.
6. **CI high-risk** — a migration e a edição de `src/collections/ArchivePhoto.ts` puxam unit/int full + e2e curado, e `src/utilities/flickr` mapeia para `frontendFotos`/`frontendFotosSelfie` (`scripts/lib/e2e-affected-manifest.mjs:57,224-239`); manter o PR focado. `scripts/lib/archiveCatalogPlan.mjs` é pinado (`scripts/lib/test-affected-core.mjs:46`) e o invariante de closure segue fechado (`tests/unit/ciSkipInvariants.unit.spec.ts:192-216`).
7. **Cobertura parcial do texto** — município/atividade só entram quando o texto (ou a visão) sustenta; o `--verify` reporta `withoutMunicipality`/`withoutScene` por camada e nada é inventado.
8. **Spec de subprocesso tocando banco** — o caso metadata-only usa porta local fechada e asserção nas mensagens ausentes; nunca aponta para um banco real.
9. **Contrato do `--verify` antigo** — a saída muda (duas camadas) e o exit passa a depender da flag; o runbook e o changelog citam a mudança para o operador.
## Aceite de engenharia
- [ ] Builder metadata puro cobre o aceite: só `municipality`/`themes`, nunca `people`; `curatedFields` vence; `source` ∈ `metadata|none`; grava `metadataCheckedAt`, nunca `catalogedAt`; o builder da IA grava `metadataCheckedAt` junto do `catalogedAt`.
- [ ] Fila metadata = `metadataCheckedAt` ausente e `catalogedAt` ausente; fila IA inalterada; int prova o ciclo metadata→IA (município/temas preservados, caption/scene depois, legado excluído, curadoria vence, falha não escreve).
- [ ] `/fotos` exibe **Município** após a camada metadata (via `municipalitySlug` derivado no hook + revalidação da tag) e **Atividade**/legenda após a camada IA; `curatedFields` sempre vence; nada é publicado.
- [ ] Migration `add_archive_photo_metadata_checked` gerada, revisada, aplicada local, index registrado, tipos regenerados; `push:false` mantido.
- [ ] `--metadata-only` em plan/apply/verify; apply metadata-only exige só `ARCHIVE_CATALOG_CONFIRM` + `TEQO_ENV`; verify sempre lista as duas camadas e o exit é pela selecionada.
- [ ] Runbook §C245 antes de `## C242` (metadata-only → revalidate `archivePhotos` → verify → IA quando o engine subir; rollback) e changelog `docs/changelog/<data>-c245.md` entregues; nenhuma UI nova.
- [ ] Access/Consent intocados; bypass `overrideAccess` documentado; sem env nova; sem segundo pipeline/script.
- [ ] Gates: `pnpm gate:fast`, int local, e2e curado do CI verdes; deploy staging-primeiro; execução em produção documentada e fora da sessão.
## Self-score (decision-quality)
1. Decisões caras com rejeitadas? **Sim** — D1–D7 têm opções, recomendação e rejeitadas explícitas (marker vs `catalogedAt`, builder dedicado vs reuso, guardas por camada, etc.).
2. Abordagem cabe no appetite? **Sim** — ~1 dia de engenharia em 4 fases de ~0,25 dia; o tempo de máquina (6,5k linhas DB-only, depois IA quando o engine subir) fica fora do código.
3. Rabbit holes nomeados? **Sim** — terceiro/OCR, pessoas por texto, refresh/legado, backfill, superfície nova, env nova, segundo script, otimização prematura do lote.
4. Depth check: reusa shells/helpers? **Sim** — fila/item/select, `resolveMentionedMunicipalityId`, `classifySpeechByGazetteer`, `withPayloadTransaction`, contexto/hook do `archivePhotoCatalog`, guardas de `cli.mjs`, precedente C234 de marker, CLI/recibo C232, revalidação da tag existente.
5. Intenção permanece satisfeita? **Sim** — o outcome (facetas e card com dados reais, duas camadas, idempotente, curadoria vence, nada publicado) não muda; a engenharia só escolhe como.
**Score: 5/5.**

View file

@ -7,24 +7,31 @@
* 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, the complete S3_* envs and the
* vision engine envs
* pnpm archive:catalog --verify read-only inventory of the catalogue
* 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: --limit <n> (canary), --out <dir> (receipts, default `data/archive`),
* --help.
* 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); a remote target also demands the complete S3_* set; the vision
* endpoint must be 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).
* (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.
@ -42,6 +49,8 @@ import {
import {
ARCHIVE_CATALOG_DEFAULT_OUT_DIR,
archiveCatalogLayerLabel,
archiveCatalogPendingForLayer,
archiveCatalogReportStamp,
formatArchiveCatalogInventory,
formatArchiveCatalogReport,
@ -75,18 +84,22 @@ const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
const HELP = `
Uso: pnpm archive:catalog [opções]
Pré-cataloga as fotos do acervo privado (C231) com o engine de visão local: por
foto, legenda/descrição, atividade/cena, texto visível, temas do vocabulário
existente, município pelo gazetteer e pessoas públicas do catálogo curado.
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; sem engine nem escrita
--apply cataloga as fotos pendentes (engine local + escrita transacional)
--verify inventário read-only da catalogação (sem engine)
(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
@ -97,9 +110,11 @@ Ambiente:
ARCHIVE_VISION_API_KEY opcional, quando o servidor local exige chave
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, as quatro envs S3_* e o engine na rede privada (host
público exige ARCHIVE_VISION_ALLOW_REMOTE=1). O plano e o --verify são read-only.
--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). */
@ -126,6 +141,38 @@ const writeReport = async (options, report, suffix = '') => {
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) {
@ -135,45 +182,41 @@ async function main() {
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}`)
console.log(`[archive:catalog] alvo: ${databaseTarget()} | modo: ${mode} | camada: ${layerLabel}`)
let engine = configuredEngine()
// 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') {
assertWriteConfirm({
label: 'archive:catalog',
flag: WRITE_CONFIRM_FLAG,
command: 'pnpm archive:catalog --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 { 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).')
}
engine = { host, scope, model }
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, listArchivePhotoCatalogQueue } =
await import('../src/utilities/flickr/archivePhotoCatalog.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,
@ -187,8 +230,11 @@ async function main() {
`${inventory.missingFilename.length} foto(s) sem arquivo armazenado — acervo inconsistente.`,
)
}
if (inventory.pending > 0) {
die(`${inventory.pending} foto(s) ainda pendentes de catalogação — rode o --apply.`)
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)
}
@ -196,10 +242,11 @@ async function main() {
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 listArchivePhotoCatalogQueue({ payload })
const pending = await listQueue()
const report = {
runAt,
mode,
layer,
target: databaseTarget(),
engine,
pending: pending.length,
@ -216,33 +263,39 @@ async function main() {
process.exit(0)
}
const queue = await listArchivePhotoCatalogQueue({ payload, limit: options.limit })
console.log(`[archive:catalog] fila: ${queue.length} foto(s) pendente(s)`)
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.
* pipeline turns it into a named failure — never a write. Built only for the
* AI layer (the metadata path never calls it).
*/
const analyze = 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
}
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 = await catalogArchivePhoto({ payload, item, analyze })
const result = options.metadataOnly
? await catalogArchivePhotoMetadata({ payload, item })
: await catalogArchivePhoto({ payload, item, analyze })
results.push({ flickrId: item.flickrId, ...result })
const detail =
result.status === 'cataloged'
@ -257,6 +310,7 @@ async function main() {
const report = {
runAt,
mode,
layer,
target: databaseTarget(),
engine,
summary,

View file

@ -4,26 +4,48 @@
* `--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] [--limit <n>] [--out <dir>]`'
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;
* errors carry the usage line.
* `--metadata-only` selects the layer without the vision engine; errors carry
* the usage line.
*
* @param {string[]} [argv]
* @returns {{ apply: boolean, verify: boolean, limit: number | null, out: string, help: boolean }}
* @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,
@ -41,6 +63,7 @@ export const parseArchiveCatalogCliArgs = (argv = process.argv.slice(2)) => {
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}.`)
@ -96,39 +119,50 @@ export const summarizeArchiveCatalogResults = (results) => {
}
/**
* The `--verify` inventory over the stored rows (read-only): coverage of the
* catalogue by source, the pending count (the honest gap this check fails on,
* mirroring the `flickr:import --verify` missing-file rule) and the rows
* without a stored filename (the one broken state C231 already checks).
* 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 cataloged = 0
let pending = 0
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 ?? {}
if (!catalog.catalogedAt) {
pending += 1
continue
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
}
cataloged += 1
if (ARCHIVE_CATALOG_SOURCE_KEYS.includes(catalog.source)) bySource[catalog.source] += 1
if (!catalog.scene) withoutScene += 1
if (catalog.municipality === undefined || catalog.municipality === null)
withoutMunicipality += 1
}
return {
total: rows.length,
cataloged,
pending,
ai: { cataloged: aiCataloged, pending: aiPending },
metadata: { checked: metadataChecked, pending: metadataPending },
bySource,
withoutScene,
withoutMunicipality,
@ -155,13 +189,16 @@ export const formatArchiveCatalogReport = (report) => {
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} · puladas (já catalogadas): ${skipped} · falharam: ${failed}`,
`${label} resultado: processadas: ${cataloged} · ${skippedLabel}: ${skipped} · falharam: ${failed}`,
)
lines.push(
`${label} origem: IA: ${bySource.ai} · metadados: ${bySource.metadata} · nada a propor: ${bySource.none}`,
@ -177,19 +214,24 @@ export const formatArchiveCatalogReport = (report) => {
return lines
}
/** `--verify` human lines (catalogue inventory, read-only). */
/** `--verify` human lines (both layers, read-only). */
export const formatArchiveCatalogInventory = (inventory) => {
const label = REPORT_LABEL
const lines = [
`${label} inventário: ${inventory.total} foto(s) · catalogadas: ${inventory.cataloged} · pendentes: ${inventory.pending}`,
`${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: ${inventory.withoutScene} · sem município: ${inventory.withoutMunicipality}`,
`${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.pending > 0) {
lines.push(`${label} ainda pendentes de catalogação: ${inventory.pending}`)
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
}

View file

@ -149,6 +149,17 @@ const catalogGroup: Field = {
description: 'Chave de idempotência: foto com data preenchida não é reprocessada.',
},
},
{
name: 'metadataCheckedAt',
type: 'date',
label: 'Metadados verificados em',
index: true,
admin: {
readOnly: true,
description:
'Chave de idempotência da camada sem IA (município/temas do texto): preenchida não reprocessa o puro — e não bloqueia a IA.',
},
},
],
}

View file

@ -374,6 +374,148 @@ export const changedArchivePhotoCuratedFields = ({
return changed
}
/**
* C245 — the shared base text of the ficha: what the deterministic rules read
* (gazetteer municipality, gazetteer themes, curated-catalog name mentions).
* The AI layer appends its own caption/visibleText; the metadata layer passes
* nothing — both derive from the SAME join, so the two layers cannot drift.
*/
export const archivePhotoCatalogText = (
input: {
alt?: string | null
title?: string | null
description?: string | null
albumTitles?: readonly (string | null | undefined)[] | null
tagNames?: readonly (string | null | undefined)[] | null
},
extras: readonly (string | null | undefined)[] = [],
): string =>
[
input.alt,
input.title,
input.description,
...(input.albumTitles ?? []),
...(input.tagNames ?? []),
...extras,
]
.filter((value): value is string => typeof value === 'string' && value.trim() !== '')
.join(' ')
type ArchivePhotoCatalogCurrent = {
caption?: string | null
description?: string | null
scene?: ArchivePhotoScene | null
visibleText?: string | null
hasPeople?: boolean | null
themes?: SpeechTopic[] | null
people?: string[] | null
municipality?: number | null
}
/**
* The curated-wins merge both layers share: `writes` says whether a proposal
* is non-empty and unfrozen; `pick` keeps the curated value verbatim and
* normalizes the proposal otherwise (blank strings/empty arrays are "nothing
* to propose", never a written value). `undefined` never reaches the DB —
* Payload leaves the stored value untouched — so omitted keys are the "do not
* touch" signal.
*/
const createArchivePhotoCatalogMerge = ({
curatedFields,
currentCatalog,
}: {
curatedFields: readonly ArchivePhotoCuratedField[]
currentCatalog?: ArchivePhotoCatalogCurrent | null
}) => {
const curated = new Set<ArchivePhotoCuratedField>(curatedFields)
const current: ArchivePhotoCatalogCurrent = currentCatalog ?? {}
const writes = (field: ArchivePhotoCuratedField, value: unknown): boolean =>
!curated.has(field) &&
value !== null &&
value !== undefined &&
!(typeof value === 'string' && value.trim() === '') &&
!(Array.isArray(value) && value.length === 0)
const pick = <Key extends keyof ArchivePhotoCatalogCurrent>(
field: Key,
proposed: NonNullable<ArchivePhotoCatalogCurrent[Key]> | null | undefined,
): NonNullable<ArchivePhotoCatalogCurrent[Key]> | undefined => {
if (curated.has(field)) {
const kept = current[field]
return kept === null || kept === undefined
? undefined
: (kept as NonNullable<ArchivePhotoCatalogCurrent[Key]>)
}
if (proposed === null || proposed === undefined) return undefined
if (Array.isArray(proposed)) {
return proposed.length === 0
? undefined
: (proposed as NonNullable<ArchivePhotoCatalogCurrent[Key]>)
}
if (typeof proposed === 'string') {
const text = proposed.trim()
return text === '' ? undefined : (text as NonNullable<ArchivePhotoCatalogCurrent[Key]>)
}
return proposed as NonNullable<ArchivePhotoCatalogCurrent[Key]>
}
return { writes, pick, curated }
}
export type ArchivePhotoMetadataCatalogWrite = {
data: {
catalog: {
municipality?: number
themes?: SpeechTopic[]
source: Exclude<ArchivePhotoCatalogSource, 'ai'>
metadataCheckedAt: string
}
}
source: Exclude<ArchivePhotoCatalogSource, 'ai'>
}
/**
* C245 — the deterministic metadata-only write: municipality (gazetteer) and
* themes (gazetteer) derived from the text already ingested. People are NEVER
* proposed here (C244 owns the facial facet) and the AI layer is not marked as
* done — `catalogedAt` is untouched, so the photo stays in the AI queue;
* `metadataCheckedAt` is this layer's own idempotency key.
*/
export const buildArchivePhotoMetadataCatalogWrite = ({
municipalityId,
gazetteerThemes,
curatedFields,
currentCatalog,
metadataCheckedAt,
}: {
municipalityId: number | null
gazetteerThemes: readonly SpeechTopic[]
curatedFields: readonly ArchivePhotoCuratedField[]
currentCatalog?: ArchivePhotoCatalogCurrent | null
metadataCheckedAt: string
}): ArchivePhotoMetadataCatalogWrite => {
const { writes, pick } = createArchivePhotoCatalogMerge({ curatedFields, currentCatalog })
const themes = [...new Set<SpeechTopic>(gazetteerThemes)]
const written = {
municipality: pick('municipality', municipalityId),
themes: pick('themes', themes),
}
const source: Exclude<ArchivePhotoCatalogSource, 'ai'> =
writes('municipality', municipalityId) || writes('themes', themes) ? 'metadata' : 'none'
return {
data: {
catalog: {
...written,
source,
metadataCheckedAt,
},
},
source,
}
}
type ArchivePhotoCatalogValues = {
caption?: string
description?: string
@ -385,6 +527,7 @@ type ArchivePhotoCatalogValues = {
municipality?: number
source: ArchivePhotoCatalogSource
catalogedAt: string
metadataCheckedAt: string
}
export type ArchivePhotoCatalogWrite = {
@ -401,7 +544,8 @@ export type ArchivePhotoCatalogWrite = {
* states what actually landed — `ai` when a model field was written,
* `metadata` when only the text-derived ones were, `none` when there was
* nothing to propose. `catalogedAt` is set on every outcome, so "nada a
* propor" is a final honest state instead of a silent retry loop.
* propor" is a final honest state instead of a silent retry loop; the same
* pass derives the metadata layer, so `metadataCheckedAt` is stamped here too.
*/
export const buildArchivePhotoCatalogWrite = ({
suggestion,
@ -412,61 +556,22 @@ export const buildArchivePhotoCatalogWrite = ({
currentCatalog,
currentAlt,
catalogedAt,
metadataCheckedAt,
}: {
suggestion: ArchivePhotoSuggestion | null
municipalityId: number | null
mentionedPeople: readonly string[]
gazetteerThemes: readonly SpeechTopic[]
curatedFields: readonly string[]
currentCatalog?: {
caption?: string | null
description?: string | null
scene?: ArchivePhotoScene | null
visibleText?: string | null
hasPeople?: boolean | null
themes?: SpeechTopic[] | null
people?: string[] | null
municipality?: number | null
} | null
curatedFields: readonly ArchivePhotoCuratedField[]
currentCatalog?: ArchivePhotoCatalogCurrent | null
currentAlt?: string | null
catalogedAt: string
metadataCheckedAt: string
}): ArchivePhotoCatalogWrite => {
const curated = new Set<string>(curatedFields)
const current: NonNullable<typeof currentCatalog> = currentCatalog ?? {}
/** A proposed value that will be written: not curated, not blank/empty. */
const writes = (field: ArchivePhotoCuratedField, value: unknown): boolean =>
!curated.has(field) &&
value !== null &&
value !== undefined &&
!(typeof value === 'string' && value.trim() === '') &&
!(Array.isArray(value) && value.length === 0)
/**
* Kept curated value when the field is frozen (verbatim), otherwise the
* proposal normalized: blank strings and empty arrays are "nothing to
* propose", never a written value.
*/
const pick = <Key extends keyof typeof current>(
field: Key,
proposed: NonNullable<(typeof current)[Key]> | null | undefined,
): NonNullable<(typeof current)[Key]> | undefined => {
if (curated.has(field)) {
const kept = current[field]
return kept === null || kept === undefined
? undefined
: (kept as NonNullable<(typeof current)[Key]>)
}
if (proposed === null || proposed === undefined) return undefined
if (Array.isArray(proposed)) {
return proposed.length === 0 ? undefined : (proposed as NonNullable<(typeof current)[Key]>)
}
if (typeof proposed === 'string') {
const text = proposed.trim()
return text === '' ? undefined : (text as NonNullable<(typeof current)[Key]>)
}
return proposed as NonNullable<(typeof current)[Key]>
}
const { writes, pick, curated } = createArchivePhotoCatalogMerge({
curatedFields,
currentCatalog,
})
const aiThemes = suggestion?.themes ?? []
const themes = [...new Set<SpeechTopic>([...aiThemes, ...gazetteerThemes])]
@ -513,6 +618,7 @@ export const buildArchivePhotoCatalogWrite = ({
...written,
source,
catalogedAt,
metadataCheckedAt,
},
},
source,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "archive_photo" ADD COLUMN "catalog_metadata_checked_at" timestamp(3) with time zone;
CREATE INDEX "archive_photo_catalog_catalog_metadata_checked_at_idx" ON "archive_photo" USING btree ("catalog_metadata_checked_at");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP INDEX "archive_photo_catalog_catalog_metadata_checked_at_idx";
ALTER TABLE "archive_photo" DROP COLUMN "catalog_metadata_checked_at";`)
}

View file

@ -93,6 +93,7 @@ import * as migration_20260926_163248_add_archive_photo_publication from './2026
import * as migration_20260927_193954_add_face_subject from './20260927_193954_add_face_subject';
import * as migration_20261001_040904_add_archive_photo_face from './20261001_040904_add_archive_photo_face';
import * as migration_20261001_235632_add_face_figure from './20261001_235632_add_face_figure';
import * as migration_20261002_114110_add_archive_photo_metadata_checked from './20261002_114110_add_archive_photo_metadata_checked';
export const migrations = [
{
@ -568,6 +569,11 @@ export const migrations = [
{
up: migration_20261001_235632_add_face_figure.up,
down: migration_20261001_235632_add_face_figure.down,
name: '20261001_235632_add_face_figure'
name: '20261001_235632_add_face_figure',
},
{
up: migration_20261002_114110_add_archive_photo_metadata_checked.up,
down: migration_20261002_114110_add_archive_photo_metadata_checked.down,
name: '20261002_114110_add_archive_photo_metadata_checked'
},
];

View file

@ -1488,6 +1488,10 @@ export interface ArchivePhoto {
* Chave de idempotência: foto com data preenchida não é reprocessada.
*/
catalogedAt?: string | null;
/**
* Chave de idempotência da camada sem IA (município/temas do texto): preenchida não reprocessa o puro — e não bloqueia a IA.
*/
metadataCheckedAt?: string | null;
};
/**
* Só "Aprovada" aparece no álbum público (/fotos). "Removida" é o estado de remoção a pedido — a catalogação automática nunca o altera e a foto não volta ao público sem uma nova edição humana.
@ -3268,6 +3272,7 @@ export interface ArchivePhotoSelect<T extends boolean = true> {
municipality?: T;
source?: T;
catalogedAt?: T;
metadataCheckedAt?: T;
};
publicationStatus?: T;
curatedFields?: T;

View file

@ -3,17 +3,23 @@ import 'server-only'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import type { Payload, Where } from 'payload'
import sharp from 'sharp'
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
import {
archivePhotoCatalogText,
buildArchivePhotoCatalogWrite,
buildArchivePhotoMetadataCatalogWrite,
matchPublicFigureMentions,
type ArchivePhotoCatalogSource,
type ArchivePhotoCatalogWrite,
type ArchivePhotoCuratedField,
type ArchivePhotoMetadataCatalogWrite,
type ArchivePhotoSuggestion,
} from '@/lib/archivePhotoCatalog'
import { classifySpeechByGazetteer } from '@/lib/speechGazetteer'
import type { ArchivePhoto } from '@/payload-types'
import { messageOf } from '@/utilities/media/ffmpeg'
import { resolveMentionedMunicipalityId } from '@/utilities/municipality/municipalityMentionResolver'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
@ -116,6 +122,45 @@ export const listArchivePhotoCatalogQueue = async ({
}: {
payload: Payload
limit?: number | null
}): Promise<ArchivePhotoCatalogItem[]> =>
pagedCatalogItems({
payload,
where: { 'catalog.catalogedAt': { exists: false } },
limit,
})
/**
* C245 — the photos still waiting for the metadata-only layer: neither the
* deterministic pass (metadataCheckedAt) nor the AI pass (catalogedAt) ran on
* them. A row already catalogued by the AI never enters here — that pass
* derives the same text fields — so the metadata layer cannot re-stamp a
* `source` that the model earned. The AI queue above is untouched: the marker
* of this layer never blocks the model.
*/
export const listArchivePhotoMetadataCatalogQueue = async ({
payload,
limit,
}: {
payload: Payload
limit?: number | null
}): Promise<ArchivePhotoCatalogItem[]> =>
pagedCatalogItems({
payload,
where: {
'catalog.metadataCheckedAt': { exists: false },
'catalog.catalogedAt': { exists: false },
},
limit,
})
const pagedCatalogItems = async ({
payload,
where,
limit,
}: {
payload: Payload
where: Where
limit?: number | null
}): Promise<ArchivePhotoCatalogItem[]> => {
const items: ArchivePhotoCatalogItem[] = []
let page = 1
@ -123,7 +168,7 @@ export const listArchivePhotoCatalogQueue = async ({
do {
const found = await payload.find({
collection: ARCHIVE_PHOTO_SLUG,
where: { 'catalog.catalogedAt': { exists: false } },
where,
page,
limit: 100,
depth: 0,
@ -142,6 +187,114 @@ export const listArchivePhotoCatalogQueue = async ({
return items
}
/** The stored catalog with the relationship normalized to its id (depth 0). */
const catalogSnapshot = (doc: ArchivePhotoCatalogDoc) =>
doc.catalog
? {
...doc.catalog,
municipality:
typeof doc.catalog.municipality === 'number' ? doc.catalog.municipality : null,
}
: null
/** The fresh-read shape both layers' builders receive (partial select). */
type ArchivePhotoCatalogDoc = {
alt?: string | null
curatedFields?: ArchivePhotoCuratedField[] | null
catalog?: ArchivePhoto['catalog'] | null
}
/**
* The one transactional write both layers share: fresh-read inside the
* transaction, skip when the layer's marker already landed (another run
* committed before this transaction), merge via the layer's pure builder and
* write with the `archivePhotoCatalog` context so the admin hook never marks
* curation on a system write. `undefined` values are omitted by Payload — a
* layer only touches what it proposes. The layers are run serially by ops
* (metadata first, AI later), so there is no concurrent-write dance here.
*/
const writeArchivePhotoCatalog = async ({
payload,
item,
skip,
buildWrite,
}: {
payload: Payload
item: ArchivePhotoCatalogItem
skip: (doc: ArchivePhotoCatalogDoc) => boolean
buildWrite: (
doc: ArchivePhotoCatalogDoc,
) => ArchivePhotoCatalogWrite | ArchivePhotoMetadataCatalogWrite
}): Promise<ArchivePhotoCatalogResult> => {
try {
return await withPayloadTransaction(payload, async ({ req }) => {
// Fresh read inside the transaction: whatever the assessoria curated
// (or another layer wrote) while this photo was in flight wins.
const fresh = await payload.find({
collection: ARCHIVE_PHOTO_SLUG,
where: { id: { equals: item.id } },
depth: 0,
limit: 1,
pagination: false,
select: { alt: true, curatedFields: true, catalog: true },
req,
// Intentional bypass: the catalogue CLI is a trusted actor with no session.
overrideAccess: true,
})
const doc = fresh.docs[0]
if (!doc) return { status: 'failed', stage: 'write', error: 'Foto não encontrada.' }
if (skip(doc)) return { status: 'skipped' }
const write = buildWrite(doc)
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: item.id,
data: write.data,
depth: 0,
req,
context: { archivePhotoCatalog: true },
// Intentional bypass: the catalogue CLI is a trusted actor with no session.
overrideAccess: true,
})
return { status: 'cataloged', source: write.source }
})
} catch (error) {
return { status: 'failed', stage: 'write', error: messageOf(error, 'erro desconhecido') }
}
}
/**
* The deterministic text derivations both layers share: the base text (plus
* the AI extras, when present) feeds the gazetteer município and themes. A
* failed município catalogue read throws — the caller names the `resolve`
* stage on the receipt.
*/
const deriveArchivePhotoCatalogText = async ({
payload,
item,
extras = [],
}: {
payload: Payload
item: ArchivePhotoCatalogItem
extras?: readonly (string | null | undefined)[]
}) => {
const text = archivePhotoCatalogText(
{
alt: item.alt,
title: item.title,
description: item.description,
albumTitles: item.albums.map((album) => album.title),
tagNames: item.tags,
},
extras,
)
const municipalityId = await resolveMentionedMunicipalityId({ payload, text })
const gazetteerThemes = classifySpeechByGazetteer({ transcript: text }).topics
return { text, municipalityId, gazetteerThemes }
}
/**
* One photo through the whole pipeline. Phases fail with a NAMED stage so the
* receipt says why: download/image never reach the engine, analyze is the
@ -196,82 +349,80 @@ export const catalogArchivePhoto = async ({
return { status: 'failed', stage: 'analyze', error: messageOf(error, 'erro desconhecido') }
}
const text = [
item.alt,
item.title,
item.description,
...item.albums.map((album) => album.title),
...item.tags,
suggestion.caption,
suggestion.visibleText,
]
.filter((value): value is string => typeof value === 'string' && value.trim() !== '')
.join(' ')
let municipalityId: number | null
let derivations: Awaited<ReturnType<typeof deriveArchivePhotoCatalogText>>
try {
municipalityId = await resolveMentionedMunicipalityId({ payload, text })
derivations = await deriveArchivePhotoCatalogText({
payload,
item,
extras: [suggestion.caption, suggestion.visibleText],
})
} catch (error) {
return { status: 'failed', stage: 'resolve', error: messageOf(error, 'erro desconhecido') }
}
const mentionedPeople = matchPublicFigureMentions(text)
const gazetteerThemes = classifySpeechByGazetteer({ transcript: text }).topics
const mentionedPeople = matchPublicFigureMentions(derivations.text)
const writtenAt = new Date().toISOString()
try {
return await withPayloadTransaction(payload, async ({ req }) => {
// Fresh read inside the transaction: whatever the assessoria curated
// (or another run catalogued) while this photo was in flight wins.
const fresh = await payload.find({
collection: ARCHIVE_PHOTO_SLUG,
where: { id: { equals: item.id } },
depth: 0,
limit: 1,
pagination: false,
select: { alt: true, curatedFields: true, catalog: true },
req,
// Intentional bypass: the catalogue CLI is a trusted actor with no session.
overrideAccess: true,
})
const doc = fresh.docs[0]
if (!doc) return { status: 'failed', stage: 'write', error: 'Foto não encontrada.' }
if (doc.catalog?.catalogedAt) return { status: 'skipped' }
const write = buildArchivePhotoCatalogWrite({
const result = await writeArchivePhotoCatalog({
payload,
item,
skip: (doc) => Boolean(doc.catalog?.catalogedAt),
buildWrite: (doc) =>
buildArchivePhotoCatalogWrite({
suggestion,
municipalityId,
municipalityId: derivations.municipalityId,
mentionedPeople,
gazetteerThemes,
gazetteerThemes: derivations.gazetteerThemes,
curatedFields: doc.curatedFields ?? [],
// Depth 0: the relationship is the id even when the generated type
// still allows the populated shape.
currentCatalog: doc.catalog
? {
...doc.catalog,
municipality:
typeof doc.catalog.municipality === 'number' ? doc.catalog.municipality : null,
}
: null,
currentCatalog: catalogSnapshot(doc),
currentAlt: doc.alt,
catalogedAt: new Date().toISOString(),
})
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: item.id,
data: write.data,
depth: 0,
req,
context: { archivePhotoCatalog: true },
// Intentional bypass: the catalogue CLI is a trusted actor with no session.
overrideAccess: true,
})
return { status: 'cataloged', source: write.source }
})
} catch (error) {
return { status: 'failed', stage: 'write', error: messageOf(error, 'erro desconhecido') }
}
catalogedAt: writtenAt,
// The AI pass runs the same deterministic derivation, so it also
// stamps the metadata layer's own marker.
metadataCheckedAt: writtenAt,
}),
})
// A failed write is returned (never thrown); the temp dir cleanup happens
// in the `finally` below either way.
return result
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}
}
/**
* C245 — one photo through the metadata-only catalogue: no media download, no
* engine. The text already stored on the row (alt, title, Flickr description,
* albums, tags) feeds the gazetteer municipality and themes; people are never
* written here (C244 owns the facial facet). The write stamps
* `metadataCheckedAt` only — `catalogedAt` is left for the AI layer, so the
* photo stays in the model queue and a re-run converges.
*/
export const catalogArchivePhotoMetadata = async ({
payload,
item,
}: {
payload: Payload
item: ArchivePhotoCatalogItem
}): Promise<ArchivePhotoCatalogResult> => {
let derivations: Awaited<ReturnType<typeof deriveArchivePhotoCatalogText>>
try {
derivations = await deriveArchivePhotoCatalogText({ payload, item })
} catch (error) {
return { status: 'failed', stage: 'resolve', error: messageOf(error, 'erro desconhecido') }
}
const checkedAt = new Date().toISOString()
return writeArchivePhotoCatalog({
payload,
item,
skip: (doc) => Boolean(doc.catalog?.metadataCheckedAt || doc.catalog?.catalogedAt),
buildWrite: (doc) =>
buildArchivePhotoMetadataCatalogWrite({
municipalityId: derivations.municipalityId,
gazetteerThemes: derivations.gazetteerThemes,
curatedFields: doc.curatedFields ?? [],
currentCatalog: catalogSnapshot(doc),
metadataCheckedAt: checkedAt,
}),
})
}

View file

@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto'
import type { ArchivePhotoSuggestion } from '@/lib/archivePhotoCatalog'
@ -13,7 +13,9 @@ import { publicFigureCatalog } from '@/lib/publicFigureCatalog'
import config from '@/payload.config'
import {
catalogArchivePhoto,
catalogArchivePhotoMetadata,
listArchivePhotoCatalogQueue,
listArchivePhotoMetadataCatalogQueue,
type ArchivePhotoAnalyzer,
type ArchivePhotoCatalogItem,
} from '@/utilities/flickr/archivePhotoCatalog'
@ -335,3 +337,131 @@ describe('catalogArchivePhoto (C232)', () => {
expect((await findByFlickrId('53900000008'))?.curatedFields).toEqual(['scene'])
})
})
describe('catalogArchivePhotoMetadata (C245)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterAll(async () => {
for (const flickrId of createdFlickrIds) {
await payload.delete({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: flickrId } },
overrideAccess: true,
})
}
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
})
it('derives municipality and themes without the model, keeps the AI queue open and never writes people', async () => {
const feira = await municipalityId('feira-de-santana')
const { item } = await createPhoto('53900002001', {
title: 'Plenária em Feira de Santana',
tags: ['saude'],
})
const result = await catalogArchivePhotoMetadata({ payload, item })
expect(result).toEqual({ status: 'cataloged', source: 'metadata' })
const doc = await findByFlickrId('53900002001')
expect(doc?.catalog?.municipality).toBe(feira)
expect(doc?.catalog?.themes).toEqual(['saude'])
expect(doc?.catalog?.metadataCheckedAt).toBeTruthy()
expect(doc?.catalog?.catalogedAt ?? null).toBeNull()
expect(doc?.catalog?.people ?? []).toEqual([])
expect(doc?.catalog?.caption ?? null).toBeNull()
expect(
(await listArchivePhotoMetadataCatalogQueue({ payload })).map((entry) => entry.flickrId),
).not.toContain('53900002001')
expect(
(await listArchivePhotoCatalogQueue({ payload })).map((entry) => entry.flickrId),
).toContain('53900002001')
})
it('keeps both layers consistent: the AI after the metadata preserves the fields and stamps both markers', async () => {
const feira = await municipalityId('feira-de-santana')
const { item } = await createPhoto('53900002002', { title: 'Plenária em Feira de Santana' })
await catalogArchivePhotoMetadata({ payload, item })
const result = await catalogArchivePhoto({
payload,
item,
analyze: analyzeWith(suggestion({ caption: 'Plenária lotada', scene: 'plenaria' })),
})
expect(result).toEqual({ status: 'cataloged', source: 'ai' })
const doc = await findByFlickrId('53900002002')
expect(doc?.catalog?.caption).toBe('Plenária lotada')
expect(doc?.catalog?.scene).toBe('plenaria')
expect(doc?.catalog?.municipality).toBe(feira)
expect(doc?.catalog?.catalogedAt).toBeTruthy()
expect(doc?.catalog?.metadataCheckedAt).toBeTruthy()
})
it('never reopens a row already catalogued by the AI (legacy without the metadata marker)', async () => {
const { id, item } = await createPhoto('53900002003', { title: 'Plenária em Feira de Santana' })
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id,
data: {
catalog: { caption: 'Legado', source: 'ai', catalogedAt: new Date().toISOString() },
},
context: { archivePhotoCatalog: true },
overrideAccess: true,
})
expect((await findByFlickrId('53900002003'))?.catalog?.metadataCheckedAt ?? null).toBeNull()
expect(
(await listArchivePhotoMetadataCatalogQueue({ payload })).map((entry) => entry.flickrId),
).not.toContain('53900002003')
expect(await catalogArchivePhotoMetadata({ payload, item })).toEqual({ status: 'skipped' })
expect((await findByFlickrId('53900002003'))?.catalog?.source).toBe('ai')
})
it('never overwrites a curated municipality or themes', async () => {
const feira = await municipalityId('feira-de-santana')
const { id, item } = await createPhoto('53900002004', {
title: 'Plenária em Feira de Santana',
tags: ['saude'],
})
const admin = await campaignFixtures().createAdminUser()
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id,
data: { catalog: { municipality: feira, themes: ['esporte'] } },
user: admin,
overrideAccess: false,
})
const result = await catalogArchivePhotoMetadata({ payload, item })
expect(result).toEqual({ status: 'cataloged', source: 'none' })
const doc = await findByFlickrId('53900002004')
expect(doc?.catalog?.municipality).toBe(feira)
expect(doc?.catalog?.themes).toEqual(['esporte'])
expect(doc?.catalog?.metadataCheckedAt).toBeTruthy()
expect(doc?.curatedFields).toEqual(expect.arrayContaining(['municipality', 'themes']))
})
it('reports a failed municipality resolution, writes nothing and lets the next run retry', async () => {
const { item } = await createPhoto('53900002005', { title: 'Plenária em Feira de Santana' })
const findSpy = vi
.spyOn(payload, 'find')
.mockRejectedValueOnce(new Error('catálogo de municípios indisponível'))
const failed = await catalogArchivePhotoMetadata({ payload, item })
findSpy.mockRestore()
expect(failed).toEqual({
status: 'failed',
stage: 'resolve',
error: 'catálogo de municípios indisponível',
})
expect((await findByFlickrId('53900002005'))?.catalog?.metadataCheckedAt ?? null).toBeNull()
expect(
(await listArchivePhotoMetadataCatalogQueue({ payload })).map((entry) => entry.flickrId),
).toContain('53900002005')
})
})

View file

@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest'
// C232 — the cataloguing CLI's fail-closed write guards, exercised as a
// subprocess: each refusal must happen before any engine call or DB connection.
// C245 adds the metadata-only lane, whose guards stop at the intent flag and
// the declared target — no S3 and no vision engine on that path.
const repoRoot = process.cwd()
const scriptPath = join(repoRoot, 'scripts', 'catalog-archive-photos.mjs')
@ -122,4 +124,31 @@ describe('archive:catalog write guards (C232)', () => {
expect(result.status).toBe(1)
expect(output(result)).toContain('argumento desconhecido')
})
it('refuses the metadata-only --apply without the intent flag', () => {
const result = run(['--metadata-only', '--apply'])
expect(result.status).toBe(1)
expect(output(result)).toContain('ARCHIVE_CATALOG_CONFIRM=1')
})
it('metadata-only --apply skips the S3 and vision guards entirely', () => {
const result = run(['--metadata-only', '--apply'], {
ARCHIVE_CATALOG_CONFIRM: '1',
TEQO_ENV: 'production',
// A closed port: the guards pass and the boot fails on the DB, proving
// the run got past them without demanding S3 or the engine.
DATABASE_URL: 'postgresql://teqo:teqo@127.0.0.1:1/teqo_1313',
ARCHIVE_VISION_BASE_URL: 'https://api.example.com/v1',
})
expect(result.status).toBe(1)
const text = output(result)
// Positive proof the run got PAST the guards and died on the database:
expect(text).toMatch(/ECONNREFUSED|cannot connect to Postgres/)
expect(text).not.toContain('S3_BUCKET')
expect(text).not.toContain('host público')
expect(text).not.toContain('ARCHIVE_VISION_MODEL')
}, // the DB refusal; a busy full-suite run can push it past the default 5s. // This is the one case that gets past the guards and boots Payload before
60_000)
})

View file

@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
ARCHIVE_CATALOG_DEFAULT_OUT_DIR,
archiveCatalogLayerLabel,
archiveCatalogPendingForLayer,
formatArchiveCatalogInventory,
formatArchiveCatalogReport,
parseArchiveCatalogCliArgs,
@ -11,13 +13,15 @@ import {
// C232 — the pure CLI planning/reporting of the archive cataloguing: the argv
// contract, the run summaries and the `--verify` inventory the receipt and the
// stdout lines are built from.
// stdout lines are built from. C245 adds the layer selector and the two-layer
// inventory.
describe('parseArchiveCatalogCliArgs (C232)', () => {
it('defaults to the plan mode and the canonical receipt dir', () => {
it('defaults to the plan mode, the AI layer and the canonical receipt dir', () => {
expect(parseArchiveCatalogCliArgs([])).toEqual({
apply: false,
verify: false,
metadataOnly: false,
limit: null,
out: ARCHIVE_CATALOG_DEFAULT_OUT_DIR,
help: false,
@ -36,6 +40,22 @@ describe('parseArchiveCatalogCliArgs (C232)', () => {
expect(parseArchiveCatalogCliArgs(['--help'])).toMatchObject({ help: true })
})
it('parses the metadata-only layer selector in every mode', () => {
expect(parseArchiveCatalogCliArgs(['--metadata-only'])).toMatchObject({
metadataOnly: true,
apply: false,
verify: false,
})
expect(parseArchiveCatalogCliArgs(['--metadata-only', '--apply'])).toMatchObject({
metadataOnly: true,
apply: true,
})
expect(parseArchiveCatalogCliArgs(['--metadata-only', '--verify'])).toMatchObject({
metadataOnly: true,
verify: true,
})
})
it('refuses conflicting, unknown and unsafe input', () => {
expect(() => parseArchiveCatalogCliArgs(['--apply', '--verify'])).toThrow(/mutuamente/)
expect(() => parseArchiveCatalogCliArgs(['--limit', '0'])).toThrow(/--limit/)
@ -64,8 +84,8 @@ describe('summarizeArchiveCatalogResults (C232)', () => {
})
})
describe('summarizeArchiveCatalogInventory (C232)', () => {
it('separates catalogued from pending and flags the missing file', () => {
describe('summarizeArchiveCatalogInventory (C232/C245)', () => {
it('separates both layers, counts the provenance and flags the missing file', () => {
const inventory = summarizeArchiveCatalogInventory([
{
flickrId: '1',
@ -75,13 +95,18 @@ describe('summarizeArchiveCatalogInventory (C232)', () => {
{ flickrId: '2', filename: 'b.jpg', catalog: { catalogedAt: 'x', source: 'metadata' } },
{ flickrId: '3', filename: 'c.jpg', catalog: {} },
{ flickrId: '4', filename: null, catalog: { catalogedAt: 'x', source: 'none' } },
{
flickrId: '5',
filename: 'e.jpg',
catalog: { metadataCheckedAt: 'x', source: 'metadata', municipality: 9 },
},
])
expect(inventory).toMatchObject({
total: 4,
cataloged: 3,
pending: 1,
bySource: { ai: 1, metadata: 1, none: 1 },
expect(inventory).toEqual({
total: 5,
ai: { cataloged: 3, pending: 2 },
metadata: { checked: 4, pending: 1 },
bySource: { ai: 1, metadata: 2, none: 1 },
withoutScene: 2,
withoutMunicipality: 2,
missingFilename: ['4'],
@ -89,10 +114,27 @@ describe('summarizeArchiveCatalogInventory (C232)', () => {
})
})
describe('report lines (C232)', () => {
it('renders the plan line with the pending count', () => {
describe('archiveCatalogLayerLabel (C245)', () => {
it('labels the two layers in pt-BR', () => {
expect(archiveCatalogLayerLabel('ai')).toBe('IA')
expect(archiveCatalogLayerLabel('metadata')).toBe('metadados')
})
it('reads the pending gap of the selected layer only', () => {
const inventory = {
ai: { cataloged: 3, pending: 7 },
metadata: { checked: 6, pending: 4 },
}
expect(archiveCatalogPendingForLayer(inventory, 'metadata')).toBe(4)
expect(archiveCatalogPendingForLayer(inventory, 'ai')).toBe(7)
})
})
describe('report lines (C232/C245)', () => {
it('renders the plan line with the layer and the pending count', () => {
const lines = formatArchiveCatalogReport({
mode: 'plan',
layer: 'ai',
target: '127.0.0.1/teqo_staging',
engine: { host: '100.94.122.26', model: 'qwen2.5vl:7b', scope: 'local' },
pending: 12,
@ -100,12 +142,29 @@ describe('report lines (C232)', () => {
})
expect(lines[0]).toContain('100.94.122.26 (qwen2.5vl:7b, local)')
expect(lines.join('\n')).toContain('pendentes na fila: 12')
const text = lines.join('\n')
expect(text).toContain('camada: IA')
expect(text).toContain('pendentes na fila: 12')
})
it('renders the metadata-only plan without an engine line', () => {
const lines = formatArchiveCatalogReport({
mode: 'plan',
layer: 'metadata',
engine: null,
pending: 3,
durationMs: 10,
})
const text = lines.join('\n')
expect(text).toContain('camada: metadados')
expect(text).not.toContain('engine:')
})
it('renders the apply summary and every failure', () => {
const lines = formatArchiveCatalogReport({
mode: 'apply',
layer: 'metadata',
target: '127.0.0.1/teqo_staging',
durationMs: 2000,
summary: summarizeArchiveCatalogResults([
@ -115,27 +174,28 @@ describe('report lines (C232)', () => {
})
const text = lines.join('\n')
expect(text).toContain('camada: metadados')
expect(text).toContain('processadas: 1')
expect(text).toContain('falharam: 1')
expect(text).toContain('IA: 1')
expect(text).toContain('2 (analyze): timeout')
})
it('renders the verify inventory with the pending gap', () => {
it('renders the verify inventory with the pending gap of each layer', () => {
const lines = formatArchiveCatalogInventory({
total: 3,
cataloged: 2,
pending: 1,
bySource: { ai: 1, metadata: 1, none: 0 },
withoutScene: 1,
withoutMunicipality: 1,
total: 5,
ai: { cataloged: 3, pending: 2 },
metadata: { checked: 4, pending: 1 },
bySource: { ai: 1, metadata: 2, none: 1 },
withoutScene: 2,
withoutMunicipality: 2,
missingFilename: [],
})
const text = lines.join('\n')
expect(text).toContain('catalogadas: 2')
expect(text).toContain('pendentes: 1')
expect(text).toContain('IA: 1')
expect(text).toContain('ainda pendentes de catalogação: 1')
expect(text).toContain('camada IA: catalogadas 3 · pendentes 2')
expect(text).toContain('camada metadados: verificadas 4 · pendentes 1')
expect(text).toContain('pendentes de metadados: 1')
expect(text).toContain('pendentes de IA: 2')
})
})

View file

@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'
import {
ARCHIVE_PHOTO_CAPTION_MAX_LENGTH,
ARCHIVE_PHOTO_PUBLICATION_STATUSES,
archivePhotoCatalogText,
archivePhotoIsPublic,
archivePhotoPublicationStatusLabels,
archivePhotoSearchText,
archivePhotoTakenOn,
archivePhotoThemesFrom,
buildArchivePhotoCatalogWrite,
buildArchivePhotoMetadataCatalogWrite,
changedArchivePhotoCuratedFields,
isArchivePhotoPublicationStatus,
isArchivePhotoRemovalChannelUrl,
@ -46,6 +48,18 @@ const writeInput = (
currentCatalog: null,
currentAlt: null,
catalogedAt: '2026-09-26T12:00:00.000Z',
metadataCheckedAt: '2026-09-26T12:00:00.000Z',
...overrides,
})
const metadataWriteInput = (
overrides: Partial<Parameters<typeof buildArchivePhotoMetadataCatalogWrite>[0]> = {},
): Parameters<typeof buildArchivePhotoMetadataCatalogWrite>[0] => ({
municipalityId: null,
gazetteerThemes: [],
curatedFields: [],
currentCatalog: null,
metadataCheckedAt: '2026-10-01T12:00:00.000Z',
...overrides,
})
@ -218,6 +232,7 @@ describe('buildArchivePhotoCatalogWrite (C232)', () => {
themes: ['politica-instituicoes'],
source: 'ai',
catalogedAt: '2026-09-26T12:00:00.000Z',
metadataCheckedAt: '2026-09-26T12:00:00.000Z',
})
})
@ -238,6 +253,7 @@ describe('buildArchivePhotoCatalogWrite (C232)', () => {
municipality: 7,
source: 'metadata',
catalogedAt: '2026-09-26T12:00:00.000Z',
metadataCheckedAt: '2026-09-26T12:00:00.000Z',
})
})
@ -248,6 +264,7 @@ describe('buildArchivePhotoCatalogWrite (C232)', () => {
expect(data.catalog).toEqual({
source: 'none',
catalogedAt: '2026-09-26T12:00:00.000Z',
metadataCheckedAt: '2026-09-26T12:00:00.000Z',
})
})
@ -268,6 +285,7 @@ describe('buildArchivePhotoCatalogWrite (C232)', () => {
municipality: 3,
source: 'none',
catalogedAt: '2026-09-26T12:00:00.000Z',
metadataCheckedAt: '2026-09-26T12:00:00.000Z',
})
expect(data.alt).toBeUndefined()
})
@ -330,6 +348,98 @@ describe('buildArchivePhotoCatalogWrite (C232)', () => {
})
})
describe('archivePhotoCatalogText (C245)', () => {
it('joins the base text fields and drops blanks', () => {
expect(
archivePhotoCatalogText({
alt: 'Alt',
title: 'Título',
description: ' ',
albumTitles: ['Álbum', null, ''],
tagNames: ['saude'],
}),
).toBe('Alt Título Álbum saude')
})
it('appends the extras — the AI layer adds its own caption/visible text', () => {
expect(archivePhotoCatalogText({ title: 'Título' }, ['Legenda', null, 'Faixa'])).toBe(
'Título Legenda Faixa',
)
expect(archivePhotoCatalogText({})).toBe('')
})
})
describe('buildArchivePhotoMetadataCatalogWrite (C245)', () => {
it('writes municipality and themes from the gazetteer, never catalogedAt or people', () => {
const { data, source } = buildArchivePhotoMetadataCatalogWrite(
metadataWriteInput({ municipalityId: 7, gazetteerThemes: ['saude', 'saude', 'educacao'] }),
)
expect(source).toBe('metadata')
expect(data.catalog).toEqual({
municipality: 7,
themes: ['saude', 'educacao'],
source: 'metadata',
metadataCheckedAt: '2026-10-01T12:00:00.000Z',
})
expect(data.catalog).not.toHaveProperty('catalogedAt')
expect(data.catalog).not.toHaveProperty('people')
expect(data.catalog).not.toHaveProperty('caption')
})
it('states none when the text sustains nothing, still stamping the marker', () => {
const { data, source } = buildArchivePhotoMetadataCatalogWrite(metadataWriteInput())
expect(source).toBe('none')
expect(data.catalog).toEqual({
source: 'none',
metadataCheckedAt: '2026-10-01T12:00:00.000Z',
})
})
it('never overwrites a curated field and reports what actually landed', () => {
const curated = buildArchivePhotoMetadataCatalogWrite(
metadataWriteInput({
municipalityId: 9,
gazetteerThemes: ['saude'],
curatedFields: ['municipality', 'themes'],
currentCatalog: { municipality: 3, themes: ['esporte'] },
}),
)
expect(curated.source).toBe('none')
expect(curated.data.catalog).toEqual({
municipality: 3,
themes: ['esporte'],
source: 'none',
metadataCheckedAt: '2026-10-01T12:00:00.000Z',
})
const partial = buildArchivePhotoMetadataCatalogWrite(
metadataWriteInput({
municipalityId: 9,
gazetteerThemes: ['saude'],
curatedFields: ['municipality'],
currentCatalog: { municipality: 3 },
}),
)
expect(partial.source).toBe('metadata')
expect(partial.data.catalog.municipality).toBe(3)
expect(partial.data.catalog.themes).toEqual(['saude'])
})
it('omits a curated municipality that was never stored (nothing to keep)', () => {
const { data } = buildArchivePhotoMetadataCatalogWrite(
metadataWriteInput({
municipalityId: 9,
curatedFields: ['municipality'],
currentCatalog: { municipality: null },
}),
)
expect(data.catalog.municipality).toBeUndefined()
expect(data.catalog.source).toBe('none')
})
})
describe('archivePhotoSearchText (C232)', () => {
it('normalizes every facet into one haystack', () => {
const text = archivePhotoSearchText({