From 9c0b44a2c0674eb7d4e7ec7a729c47324a9ba9b2 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 24 Sep 2026 15:02:48 -0300 Subject: [PATCH] =?UTF-8?q?feat(C218):=20skill=20catalogo-falas-web=20?= =?UTF-8?q?=E2=80=94=20atualiza=C3=A7=C3=A3o=20incremental=20do=20cat?= =?UTF-8?q?=C3=A1logo=20(Closes=20#1294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/catalogo-falas-web/SKILL.md | 303 ++++++++++++++++++ .opencode/agent/catalogo-falas-web.md | 63 ++++ .opencode/commands/catalogo-falas-web.md | 9 + docs/changelog/2026-09-24-c218.md | 1 + docs/plans/skill-catalogo-falas-web-impl.md | 268 ++++++++++++++++ tests/unit/catalogoFalasWebSkill.unit.spec.ts | 137 ++++++++ tests/unit/opencodeCommands.unit.spec.ts | 1 + 7 files changed, 782 insertions(+) create mode 100644 .agents/skills/catalogo-falas-web/SKILL.md create mode 100644 .opencode/agent/catalogo-falas-web.md create mode 100644 .opencode/commands/catalogo-falas-web.md create mode 100644 docs/changelog/2026-09-24-c218.md create mode 100644 docs/plans/skill-catalogo-falas-web-impl.md create mode 100644 tests/unit/catalogoFalasWebSkill.unit.spec.ts diff --git a/.agents/skills/catalogo-falas-web/SKILL.md b/.agents/skills/catalogo-falas-web/SKILL.md new file mode 100644 index 00000000..3ca6a399 --- /dev/null +++ b/.agents/skills/catalogo-falas-web/SKILL.md @@ -0,0 +1,303 @@ +--- +name: catalogo-falas-web +description: 'Atualiza o catálogo de falas do acervo a partir da web: lê a data da última execução, descobre o que apareceu desde então nas plataformas do C215 (YouTube, Instagram, rádio/áudio), cura falsos positivos (“revisar” não ingere até confirmação), entrega o lote à esteira de ingestão (pnpm falas-web:import) e devolve o recibo (período, achados, novos, ignorados, falhas, custo/tempo); rodada manual, incremental e sem duplicar.' +--- + +# Catálogo de falas web — atualização incremental (C218) + +`/catalogo-falas-web` atualiza o catálogo de falas do acervo desde a **última +execução bem-sucedida**: descobre o que apareceu na web no período (descoberta +por busca, plataformas do C215), cura o que a descoberta achou errado, entrega o +lote à esteira de ingestão do C215 (`pnpm falas-web:import`) e imprime o recibo. +Rodada **manual** (sem agendador, sem UI). + +A skill **só orquestra**: descobrir é dela; baixar, transcrever, classificar, +espelhar e deduplicar é do C215 (`scripts/import-web-speeches.mjs`, +`src/lib/webSpeech.ts`, `src/utilities/speech/*`). Nada de segunda esteira. + +## Quando usar + +- A comunicação precisa do acervo atualizado com o que saiu na web desde a + última rodada; o operador roda a skill quando decidir. +- Quem executa: o **orquestrador** (agente principal) + um sub-agente de + **descoberta** por rodada (`.opencode/agent/catalogo-falas-web.md`) + o CLI do + C215. Precedente das skills de conteúdo: `.agents/skills/dossie-solla-cidade/SKILL.md`. +- Primeira execução (sem `data/falas-web/last-run.json`) = **varredura inicial + completa**; as seguintes são incrementais. + +## Argumentos (`$ARGUMENTS`) + +| Invocação | O que faz | +| --- | --- | +| `/catalogo-falas-web` | rodada incremental (ou varredura inicial, sem estado) | +| `/catalogo-falas-web --limit ` | rodada processando no máximo `n` achados; a cauda continua no lote e não é ingerida (o recibo diz como terminar) | +| `/catalogo-falas-web revisar` | lista numerada de `review` + `pending` do estado; não descobre, não ingere, não muda o estado | +| `/catalogo-falas-web aprovar <índice\|sourceKey\|url\|todos>` | promove de `review` (mais os `pending`) para um lote e ingere na mesma invocação; o watermark **não** muda | +| `/catalogo-falas-web descartar <índice\|sourceKey\|url\|todos>` | remove de `review`/`pending` com o motivo no recibo; nada é ingerido | + +Uma rodada por vez: não rode duas invocações simultâneas (o estado não tem +lock). Sem argumento desconhecido: pare e mostre as opções — nunca trate um +argumento não reconhecido como rodada. + +## Pipeline (etapas) + +1. **Ler o estado.** `data/falas-web/last-run.json` (gitignored; contrato + abaixo). Sem o arquivo: `from = null`, `initial = true`. Com o arquivo: + `from = lastRunAt`. Estado ilegível/corrompido → **pare** (fail-closed) com o + caminho e a saída ("remova/renomeie o arquivo para forçar a varredura + inicial") — nunca trate corrompido como ausente em silêncio. +2. **Descobrir (sub-agente).** Dispare a Task com + `.opencode/agent/catalogo-falas-web.md` informando a janela já resolvida + (`from`/`initial` e `to = agora`, o instante do despacho) e receba **só o + recibo curto** (seção "Recibo da descoberta"). O sub-agente escreve o + artefato datado `data/falas-web/discovery-.json` (contrato abaixo) com + `window.to` = o `to` recebido. O orquestrador lê o artefato do disco para + auditar (etapa 3) — nunca peça o corpo ao subagente. +3. **Curar.** Leia o artefato do disco (é o produto auditável — não confie só no + recibo) e audite cada `findings[]`: URL da plataforma declarada, data dentro + da janela, indício de que é fala do titular. Qualquer dúvida → **não inclua + no lote** e registre em `review` do estado (etapa 8) com o motivo (critérios + abaixo). A curadoria decide **relevância e autenticidade**; nunca faceta + (tema/alcance/menções são do classificador do C215). +4. **Montar o lote.** `pending` do estado vai **primeiro** (retry), depois os + `findings` aprovados; sem duplicar URL. Se o lote ficar vazio (sem achados e + sem pendências), **pule o CLI** e emita o recibo a partir do artefato + ("nada novo no período"), ainda avançando o estado. Senão, grave + `data/falas-web/batch-.json` no contrato **exato** do C215 + (`{ generatedAt, findings }`) — não reimplemente o parser. +5. **Planejar (obrigatório).** `pnpm falas-web:import --findings + --dry-run` (com o mesmo `--limit ` da etapa 6) e leia o relatório JSON + (`data/falas-web/reports/falas-web-.json`). O `plannedEntries` dá o + `sourceKey` de cada entrada **na ordem do lote** e o `invalid`/`duplicates` + valida a curadoria. Se `invalid > 0` ou `duplicates > 0`: **não ingira**, + **não avance o watermark** e relate no recibo os ofensores com o motivo + (índice + erro) — o conserto é da descoberta/curadoria; isso **não** vira + `review` (item de schema não se resolve por `aprovar`) e o estado fica como + está. +6. **Ingerir.** `pnpm falas-web:import --findings ` (com `--limit ` se + a rodada pediu). Guarde o código de saída e o caminho do relatório impresso. + Exit 0 = sucesso total/parcial; exit 1 = nenhum achado entrou (falha + sistêmica ou todos os achados falharam). +7. **Recibo.** Imprima o bloco literal abaixo com as regras de preenchimento por + modo, sempre derivado do **JSON do relatório** (nunca do stdout nem de + memória). Sem relatório — CLI morreu antes de gravar —, declare a falha da + rodada e não invente números (a exceção é o lote vazio da etapa 4). +8. **Gravar o estado.** Por **último**, sempre que houver o que atualizar + (watermark, `pending` ou `review`), com escrita atômica (arquivo temporário + + `mv` para `data/falas-web/last-run.json`): `lastRunAt` só avança quando a + rodada importou com exit 0 (ou pulou o CLI por lote vazio); `pending` acumula + as falhas do lote considerado (com `attempts`); `review` acumula o que + aguarda confirmação. Falha de planejamento (etapa 5) e falha sistêmica **não** + avançam o watermark. Nada de PII (URLs/títulos públicos e motivos + operacionais apenas). + +## Contrato do estado (`data/falas-web/last-run.json`, gitignored) + +```jsonc +{ + "version": 1, + "lastRunAt": "2026-09-24T18:00:00.000Z", // janela superior (window.to) da última rodada importada com exit 0 + "lastStateAt": "2026-09-24T18:07:12.000Z", // quando este estado foi gravado (inclusive em rodada com falhas) + "lastDiscoveryPath": "data/falas-web/discovery-2026-09-24T18-00-00-000Z.json", + "lastBatchPath": "data/falas-web/batch-2026-09-24T18-03-00-000Z.json", + "lastReportPath": "data/falas-web/reports/falas-web-2026-09-24T18-03-05-000Z.json", + "pending": [ + { + "sourceKey": "web:youtube:abc123", // quando conhecido (vem do plannedEntries do dry-run) + "finding": { "platform": "youtube", "url": "https://www.youtube.com/watch?v=abc123", "publishedAt": "2026-09-12" }, + "reason": "download: HTTP 403", + "attempts": 1, + "addedAt": "2026-09-24T18:07:12.000Z" + } + ], + "review": [ + { + "sourceKey": "web:instagram:xyz", // opcional (item que não passou por um dry-run pode não ter) + "finding": { "platform": "instagram", "url": "https://www.instagram.com/reel/xyz/", "publishedAt": "2026-09-10" }, + "reason": "não confirmado como fala do titular (compilado de terceiro)", + "flaggedAt": "2026-09-24T18:07:12.000Z" + } + ] +} +``` + +- `lastRunAt` é o `window.to` da descoberta — nunca o fim do import: o que for + publicado durante a esteira cai na próxima janela, não no vão. +- `lastStateAt` é só o carimbo da gravação: "sucesso" do incremental é o + `lastRunAt` ter avançado. +- `lastReportPath` é `null` quando a rodada pulou o import (lote vazio). +- `pending`/`review` carregam o finding integral (reentrega sem depender de + artefatos antigos) e o `sourceKey` quando conhecido — sem ele, + `revisar`/`aprovar`/`descartar` operam por índice ou URL. +- `pending` = falhas do relatório (`failures[].sourceKey` mapeado pela ordem do + `plannedEntries`) com `reason` (e o estágio quando houver); `attempts` + incrementa a cada reentrega. Ciclo: a cada rodada/`aprovar`, o que entrar + (criado/atualizado/pulado) sai de `pending`/`review`; o que falhar fica (ou + entra) em `pending`; a cauda de `--limit` **não** vira pending (fica no lote); + `duplicates`/`invalid` **não** viram pending (não melhoram por repetição — o + conserto é na curadoria/descoberta). +- Retry: os `pending` vão primeiro no próximo lote; o skip/atualização é + decidido pelo C215 (`findSpeechImportState`). Nada é descartado + automaticamente — `descartar` é o caminho explícito. + +## Contrato do artefato de descoberta (`data/falas-web/discovery-.json`) + +`stamp` = `generatedAt` ISO com `:`/`.` trocados por `-` (mesmo shape do report +do C215). Um artefato = um escritor: só o sub-agente escreve (o orquestrador +lê, audita e cura; não reescreve). + +```jsonc +{ + "generatedAt": "2026-09-24T18:00:00.000Z", + "window": { "from": "2026-09-01T00:00:00.000Z", "to": "2026-09-24T18:00:00.000Z", "initial": false }, + "queries": ["\"Jorge Solla\" after:2026-09-01", "site:youtube.com \"Jorge Solla\"", "..."], + "findings": [ + { "platform": "youtube", "url": "https://www.youtube.com/watch?v=abc123", "externalId": "abc123", "publishedAt": "2026-09-12", "title": "…", "channel": "…" } + ], + "review": [ + { "finding": { "platform": "instagram", "url": "https://www.instagram.com/p/xyz/", "publishedAt": "2026-09-10" }, "reason": "…" } + ], + "notes": ["limites declarados: rádio sem arquivo direto não virou finding; Instagram fora da janela descartado"] +} +``` + +- Plataformas v1 = as do C215 (`youtube|instagram|radio|audio`), com queries + pelo nome do deputado e variações e âmbito de data na janela. +- Rádio/áudio só viram `finding` com `mediaUrl` direta (o schema do C215 a + exige); sem ela, entram em `notes` (limite declarado) — nunca em `review` + (aprovar não pode gerar `invalid`). +- `findings` não tem URL duplicada (duplicata no lote faz a skill parar na + etapa 5 — a regra é dela; o C215 apenas reporta `duplicates` e segue). + +## Curadoria e "revisar" + +Critérios para mandar a `review` (relevância/autenticidade, **nunca schema** — +item inválido para o C215 não se resolve por `aprovar`): + +- não é fala do titular (homônimo, menção, compilado de terceiro); +- data não confirmável dentro da janela; +- provável duplicata semântica por outra URL (sinalizada; dedupe cross-URL está + fora do escopo do C215); +- metadados que o agente não conseguiu confirmar. + +O que está em `review` não é ingerido até confirmação (`aprovar`). + +Modes (numeração única da listagem de `revisar` — `pending` primeiro, depois +`review`, em sequência; numa sessão nova, rode `revisar` antes de +`aprovar`/`descartar`): + +- `revisar` — leia o estado e liste, numerado: `pending` primeiro, depois + `review`, cada um com `sourceKey` (quando houver), plataforma, título/URL e + motivo. Não escreva nada. +- `aprovar ` — resolva o alvo (índice da listagem, `sourceKey`, URL ou + `todos`), **imprima o mapeamento antes de agir**, monte o lote com os + aprovados + os `pending` e rode as etapas 4–8 da rodada (sem descoberta). O + `lastRunAt` **não** muda; o que entrar (criado/atualizado/pulado) sai de + `pending`/`review`; o que falhar vira `pending`. +- `descartar ` — remova da lista; o recibo lista o que saiu com o motivo + registrado no item; nada é ingerido; nada além das listas muda. + +## Recibo (literal) + +```text +[catalogo-falas-web] período: → +achados: N · revisar: N +novos: N · atualizados: N · ignorados: N · inválidos: N · duplicados: N +falhas: N (pendentes para a próxima rodada) + - (): +custo/tempo: · ASR (~US$ ) · LLM US$ +estado: data/falas-web/last-run.json (lastRunAt → ) +``` + +Regras de preenchimento por modo: + +- **Rodada com descoberta:** `período` = `lastRunAt` anterior (ou "varredura + inicial") → `window.to`; `achados` = `findings` do artefato; `revisar` = + tamanho de `review` no estado **depois** da rodada; o resto vem do relatório do + C215 (`created/updated/skipped/invalid/duplicates/failures/asrSeconds/ + asrCostUsd/llmCostUsd/durationMs`). +- **`aprovar`/`descartar`:** `período: — (sem descoberta)`; `achados` = entradas + do lote montado; `revisar` = tamanho de `review` no estado depois; `estado` sem + a seta do watermark (não muda). +- **Lote vazio (etapa 4):** zeros no import; `estado` com a seta (watermark + avança). +- **Exit 1/sem relatório (fora do lote vazio):** `falhas: N` com o motivo da + rodada; `estado: inalterado`. + +## Recibo da descoberta + +O sub-agente devolve **só** este recibo curto (≤ ~15 linhas) — **nunca** o +corpo do artefato (`findings`/`review`): + +```jsonc +{ + "status": "ok", // ou "failed" + "artifactPath": "data/falas-web/discovery-2026-09-24T18-00-00-000Z.json", + "generatedAt": "2026-09-24T18:00:00.000Z", + "window": { "from": "2026-09-01T00:00:00.000Z", "to": "2026-09-24T18:00:00.000Z", "initial": false }, + "findingsCount": 7, + "reviewCount": 2, + "byPlatform": { "youtube": 5, "instagram": 1, "radio": 1, "audio": 0 }, + "notes": ["…"], + "failureReason": "…" // opcional (só quando status = failed) +} +``` + +`status: "failed"` não escreve artefato parcial; a rodada reporta a falha da +descoberta e mantém o estado (watermark e listas) intacto — nada de recobrir a +janela com números falsos. + +## Falhas, watermark e retry + +- **Exit 0** (criou/atualizou/pulou ≥1): sucesso total ou parcial. `lastRunAt` + avança para `window.to`; falhas viram `pending` para a próxima rodada. +- **Exit 1** (nenhum achado entrou): falha sistêmica. `lastRunAt` **não** avança; + o lote considerado vira `pending` inteiro (a rodada seguinte recobre a mesma + janela); o recibo explica o motivo. +- **CLI não roda / sem relatório**: mesma regra do exit 1; declare a falha e não + invente números. +- **`--limit `**: M = `findings` do relatório (o lote) e N = `planned` + (considerados); o que ficou fora do corte continua no `batch-.json`; o + recibo diz "restam M−N achados no lote ; rode `pnpm falas-web:import + --findings ` para terminar" (o CLI pula o que já está completo). + +## Fronteira (o que a skill NÃO faz) + +- Não baixa mídia, não transcreve, não classifica facetas, não espelha, não faz + dedupe própria — tudo isso é do C215. +- Não lê o banco para "pular o que já está no catálogo": a garantia é janela + + skip por estado do C215 (`sourceKey` unique). +- Não agendada, sem fila e sem monitoramento contínuo; uma rodada por vez. +- Nunca exporta `FALAS_WEB_IMPORT_CONFIRM` nem contorna as guardas do CLI + (`assertLocalDatabase`); alvo não-local é recusado pelo próprio CLI. +- Nada commitado: tudo em `data/falas-web/` é gitignored. + +## Troubleshooting + +- **Estado corrompido**: fail-closed. Remova/renomeie + `data/falas-web/last-run.json` para forçar a varredura inicial (perde o + watermark e as listas). +- **yt-dlp ausente/desatualizado**: o CLI orienta (`pipx install yt-dlp`); a + skill reporta a falha e mantém `pending`. Não instale nada pela skill. +- **Banco local fora do ar**: `pnpm db:start` no worktree; o CLI recusa alvo + não-local sem a flag de confirmação. +- **Plataforma bloqueada (ex.: Instagram sem login)**: falha do achado vira + `pending`; se for crônica, use `descartar`. +- **Varredura inicial grande**: use `--limit` e termine o lote com o caminho + impresso; o recibo declara o que ficou de fora. +- **Cauda de `--limit` órfã**: pegue o `batch-*.json` mais recente em + `data/falas-web/` e rode `pnpm falas-web:import --findings ` — o CLI + pula o que já está completo. +- **Achado válido mas duvidoso**: não force a ingestão — registre em `review` + (estado) e confirme depois com `aprovar`. + +## Referências + +- Ingestão (dono da esteira): `docs/plans/acervo-falas-web-ingestao.md` e + `docs/plans/acervo-falas-web-ingestao-impl.md` (C215); + `scripts/import-web-speeches.mjs`; contrato do lote em `src/lib/webSpeech.ts`. +- Sub-agente de descoberta: `.opencode/agent/catalogo-falas-web.md`. +- Precedentes de skill: `.agents/skills/dossie-solla-cidade/SKILL.md` (research + por subagente + recibo curto) e `.agents/skills/reels-tutoriais/SKILL.md`. +- Intenção/impl desta skill: `docs/plans/skill-catalogo-falas-web.md` e + `docs/plans/skill-catalogo-falas-web-impl.md`. diff --git a/.opencode/agent/catalogo-falas-web.md b/.opencode/agent/catalogo-falas-web.md new file mode 100644 index 00000000..6655c28f --- /dev/null +++ b/.opencode/agent/catalogo-falas-web.md @@ -0,0 +1,63 @@ +--- +description: Descoberta web do catálogo de falas — pesquisa as plataformas do C215 na janela pedida e escreve o discovery-.json; devolve só o recibo curto (etapa da skill /catalogo-falas-web) +mode: subagent +--- + +# Catálogo de falas web — descoberta + +Você é a etapa **descoberta** da skill +`.agents/skills/catalogo-falas-web/SKILL.md` (fonte canônica: contratos do +artefato e do estado, critérios de curadoria, recibo). Leia-a antes de rodar. +O orquestrador já leu o estado e te diz a **janela** (`from`/`initial` e +`to` = o instante do despacho; use-o como `window.to` do artefato; `from` nulo = +varredura inicial completa). O orquestrador lê o artefato do disco para +auditar/cuidar — ele não pede o corpo a você. + +## Papel + +1. Pesquisar na web o que apareceu de **fala do deputado Jorge Solla** dentro da + janela, nas plataformas do C215: `youtube`, `instagram`, `radio`, `audio`. + Queries por nome e variações (`"Jorge Solla"`, `Jorge Solla deputado`, + `Solla PT`), com âmbito de data na janela e `site:` por plataforma + (`site:youtube.com`, `site:instagram.com`, `site:soundcloud.com`, sites de + rádio/podcast). Buscas em português. +2. Escrever `data/falas-web/discovery-.json` no contrato da skill + (`stamp` = `generatedAt` ISO com `:`/`.` trocados por `-`), com `generatedAt`, + `window`, `queries`, `findings`, `review` e `notes`. Crie a pasta se faltar. +3. Devolver **apenas o recibo curto** (seção "Recibo da descoberta" da skill): + `status`, `artifactPath`, `generatedAt`, `window`, `findingsCount`, + `reviewCount`, `byPlatform`, `notes`, `failureReason?`. **Nunca** devolva o + corpo do artefato (`findings`/`review`). + +## O que é finding e o que é review + +- **`findings[]`** (entra na ingestão): mídia com fala/entrevista/pronunciamento + do titular, com `platform` (`youtube|instagram|radio|audio`), `url` e + `publishedAt` confirmados dentro da janela; `externalId`, `title`, `channel`, + `durationSeconds` e `thumbnailUrl` quando a plataforma expõe (o yt-dlp + completa lacunas no download). Sem URL duplicada no arquivo. + - `radio`/`audio` **só** entram como finding com `mediaUrl` direta (arquivo de + áudio); sem ela, registre em `notes` como limite declarado — nunca em + `review` (aprovar não pode gerar item inválido). +- **`review[]`** (não ingere até confirmação), cada um com `finding` + `reason`: + - não é fala do titular (homônimo, menção, compilado de terceiro); + - data não confirmável dentro da janela; + - provável duplicata semântica por outra URL (sinalize, não bloqueie); + - metadados que você não conseguiu confirmar. +- **`notes[]`**: limites declarados da rodada (o que ficou de fora e por quê) — + nunca esconda lacuna; o recibo não promete cobertura exaustiva. + +## Limites + +- **Não** baixe mídia (nada de yt-dlp), **não** transcreva, **não** classifique + temas/alcance/menções, **não** rode `pnpm falas-web:import`, **não** escreva o + lote (`batch-.json`) nem o estado (`last-run.json`) — são do C215 e do + orquestrador. +- **Não** leia o banco nem o catálogo; a garantia de não reprocessar é janela + + skip do C215. +- **Não** invente URL, `mediaUrl`, data ou metadado; sem confirmação, vire + `review` (ou `notes`, no caso de rádio/áudio sem arquivo). +- **Não** varra perfis que não são do deputado nem plataformas fora da v1 + (TikTok/Facebook/X) — fora de escopo. +- **Não** commite nada: `data/falas-web/` é gitignored. +- Uma rodada = um artefato; não escreva artefato parcial com `status: failed`. diff --git a/.opencode/commands/catalogo-falas-web.md b/.opencode/commands/catalogo-falas-web.md new file mode 100644 index 00000000..4e4f46da --- /dev/null +++ b/.opencode/commands/catalogo-falas-web.md @@ -0,0 +1,9 @@ +--- +description: Atualiza o catálogo de falas do acervo a partir da web — descobre o que apareceu desde a última execução, cura falsos positivos e entrega o lote à ingestão (C215), com recibo +--- + +Carregue a skill `catalogo-falas-web` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: leia o estado da última execução (`data/falas-web/last-run.json`; sem ele, varredura inicial completa), dispare o sub-agente de descoberta com a janela, cure os achados (falso positivo vira "revisar" e não ingere), monte o lote no contrato do C215 e rode `pnpm falas-web:import --findings`, leia o relatório e imprima o recibo (período, achados, novos, ignorados, falhas, custo/tempo), gravando o estado por último. A fonte canônica é `.agents/skills/catalogo-falas-web/SKILL.md` — não a transcreva nem recrie o fluxo. + +Uso: `/catalogo-falas-web` (incremental) · `/catalogo-falas-web --limit ` (rodada limitada) · `/catalogo-falas-web revisar` · `/catalogo-falas-web aprovar <índice|sourceKey|url|todos>` · `/catalogo-falas-web descartar <índice|sourceKey|url|todos>`. + +$ARGUMENTS diff --git a/docs/changelog/2026-09-24-c218.md b/docs/changelog/2026-09-24-c218.md new file mode 100644 index 00000000..2fc8427f --- /dev/null +++ b/docs/changelog/2026-09-24-c218.md @@ -0,0 +1 @@ +**C218 — Skill catalogo-falas-web: atualização incremental do catálogo (2026-09-24):** a atualização do catálogo de falas vira operação repetível e manual: `/catalogo-falas-web` lê a data da última execução em `data/falas-web/last-run.json` (gitignored — o único estado novo da entrega: watermark `lastRunAt`, `pending` de retry e `review` de curadoria; sem ele, varredura inicial completa), dispara a descoberta por subagente (`.opencode/agent/catalogo-falas-web.md`), que pesquisa as plataformas do C215 na janela, escreve o artefato datado `data/falas-web/discovery-.json` (`generatedAt`/`window`/`queries`/`findings`/`review`/`notes`) e devolve só o recibo curto. O orquestrador audita, cura (falso positivo vira `review` e não ingere até confirmação; `revisar`/`aprovar`/`descartar` no `$ARGUMENTS`, com `aprovar` congelando o watermark), monta o lote no contrato exato do C215 (`{ generatedAt, findings }`, `pending` primeiro), planeja com `--dry-run` (exige `invalid`/`duplicates` = 0 e dá o `sourceKey` por ordem do lote), ingere com `pnpm falas-web:import --findings`, lê o JSON do report e imprime o recibo literal (período, achados, revisar, novos, atualizados, ignorados, inválidos, duplicados, falhas com motivo, custo/tempo), gravando o estado por último com escrita atômica. Exit 0 (total/parcial) avança `lastRunAt` para o `window.to` da descoberta; exit 1/sem report não avança e devolve o lote a `pending` (a rodada seguinte recobre a mesma janela); lote vazio (sem achados e sem pendências) pula o CLI e ainda avança; `--limit` deixa a cauda no próprio lote com o recibo dizendo como terminá-la. Fronteira explícita: a skill não baixa, não transcreve, não classifica facetas, não espelha nem deduplica (donos do C215), não lê o banco e nunca exporta `FALAS_WEB_IMPORT_CONFIRM`; sem schema/migration/collection/access, sem UI. Entrega: `.agents/skills/catalogo-falas-web/SKILL.md` (fonte canônica), `.opencode/commands/catalogo-falas-web.md` (wrapper fino) e `.opencode/agent/catalogo-falas-web.md` (descoberta, `mode: subagent` sem pin de modelo), com os contratos pinados em `tests/unit/catalogoFalasWebSkill.unit.spec.ts` e o coupling comando↔skill em `tests/unit/opencodeCommands.unit.spec.ts`. Verificação: o tracer (`--dry-run` de um lote fake contra o banco local do worktree) validou envelope, CLI e leitura do report; o smoke de rede (janela 01–24/09, queries pelo nome, Instagram sem login e rádio sem `mediaUrl` declarados em `notes`) escreveu o artefato real com 18 findings/8 review e o dry-run do lote deu 18 considerados, 0 inválidos e 0 duplicados. A rodada real com download/ASR é do operador (P2). Desvios registrados no `*-impl.md` (cauda do `--limit` fica no lote em vez de `pending`; dry-run é passo obrigatório de planejamento; `sourceKey` opcional no estado e `lastStateAt` no carimbo). No `/simplify` (2 revisores em paralelo), 17 achados corrigidos na sessão — regras de preenchimento do recibo por modo, ciclo de `pending`/`review`, falha de planejamento sem avanço de watermark, pinos do envelope do lote e do watermark — e 0 débitos registrados (6 descartes de prosa/spec na triage do `*-impl.md`). diff --git a/docs/plans/skill-catalogo-falas-web-impl.md b/docs/plans/skill-catalogo-falas-web-impl.md new file mode 100644 index 00000000..1d49cdbd --- /dev/null +++ b/docs/plans/skill-catalogo-falas-web-impl.md @@ -0,0 +1,268 @@ +# Impl: C218 — Skill catalogo-falas-web: atualização incremental do catálogo + +Status: executado +Atualizado em: 2026-09-24 +Issue: #1294 +Intenção: docs/plans/skill-catalogo-falas-web.md +Appetite restante: herdado (~1–2 dias eng; um outcome verificável — rodar a skill atualiza o catálogo desde a última execução bem-sucedida e devolve o recibo, sem duplicar) + +## Leitura da intenção + +- **Outcome:** `/catalogo-falas-web` lê a data da última execução (sem estado anterior = varredura inicial completa), descobre o que apareceu desde então nas plataformas do C215, cura falsos positivos (“revisar” não ingere até confirmação), entrega o lote à esteira de ingestão do C215 e imprime o recibo (período, achados, novos, ignorados, falhas com motivo, custo/tempo) — reexecutar não duplica nem reprocessa o que já está no catálogo. +- **O que NÃO negociar:** + - A skill não é uma segunda esteira: download/ASR/classificação/upsert/idempotência são do C215 (`scripts/import-web-speeches.mjs`, `src/lib/webSpeech.ts`, `src/utilities/speech/*`); a skill não decide faceta manualmente. + - Nenhum schema/collection/migration novo, nenhuma UI (Impeccable A), nenhum access novo; o estado da última execução é o **único** estado novo — arquivo gitignored em `data/falas-web/` (gate 2026-09-24). + - Achado duvidoso marca “revisar” e **não** é ingerido até confirmação; falha de um achado não derruba o lote (sucesso parcial com motivo no recibo). + - A descoberta não promete cobertura exaustiva: o recibo declara período, achados e limites; a decisão de mecanismo (download/descoberta) é a do C215. +- **O que reavaliar (hipóteses da intenção vs. exploração):** + - “Janela explícita como `pnpm camara:import --date`”: o CLI do C215 **não tem filtro de data** (`scripts/import-web-speeches.mjs:56-87` só tem `--findings/--out/--limit/--dry-run/--reprocess`). A janela é responsabilidade da **descoberta**; o CLI recebe o lote pronto no envelope `{ generatedAt?, findings[] }` (`src/lib/webSpeech.ts:250-280`). + - “Precedente `sourceHash` do `reels-tutoriais`”: não precisa — identidade/idempotência já têm dono no C215 (`webSpeechSourceKey` + `findSpeechImportState` + índice unique de `Speech.sourceKey`); a skill não mantém hash próprio. + - “Watermark de última execução no repo”: confirmado que não existe nada hoje (só o report por execução em `data/falas-web/reports/`); o estado é artefato novo desta entrega, no formato que a implementação decidir (D1). + - “C215 pode exigir `FALAS_WEB_IMPORT_CONFIRM`”: a flag só é exigida para alvo não-local/override (`scripts/import-web-speeches.mjs:144-156`); no banco local do worktree a rodada não precisa dela e a skill **nunca** a exporta por conta própria. + +## Abordagem recomendada + +```mermaid +flowchart LR + CMD["/catalogo-falas-web [revisar|aprovar|descartar]"] --> SKILL["skill catalogo-falas-web
orquestra a rodada"] + ST[("data/falas-web/last-run.json
lastRunAt · pending · review")] -->|"from = lastRunAt (ou varredura inicial)"| SKILL + SKILL -->|"janela + queries"| AG["subagente catalogo-falas-web
pesquisa web nas plataformas do C215"] + AG --> DISC[("data/falas-web/discovery-.json
window · findings · review · notes")] + DISC --> CUR{"curadoria
falso positivo?"} + CUR -->|"sim"| REV["review[]
não ingere até confirmação"] + CUR -->|"não"| BATCH[("data/falas-web/batch-.json
{ generatedAt, findings[] }")] + BATCH --> CLI["pnpm falas-web:import --findings
C215: baixa · transcreve · classifica · upsert"] + CLI --> REPORT[("data/falas-web/reports/
falas-web-.json")] + REPORT --> REC["recibo: período · achados · novos · ignorados · falhas · custo/tempo"] + REC --> ST + REV -.->|"aprovar "| BATCH +``` + +**Opções consideradas:** A) skill fina orquestrando: estado → subagente de descoberta com artefato datado → curadoria → lote no contrato do C215 → `pnpm falas-web:import --findings` → leitura do report → recibo + estado | B) estender o CLI do C215 para também descobrir (crawler/SERP dentro do script) | C) a sessão principal descobre e chama o CLI sem subagente nem artefato. +**Recomendação:** A — porque mantém a fronteira que a intenção fixou (a skill orquestra; o C215 ingere), isola o contexto pesado da pesquisa web num subagente com artefato datado/auditável (padrão do researcher dos dossiês) e não cria módulo/serviço novo num repo que não tem SERP nem crawler. +**Rejeitadas:** B porque não existe descoberta web no app (o `src/utilities/socialFeed/*` é o feed oficial do board — C211/C212, não descoberta) e transformar a esteira de mídia/ASR em cliente de rede/descoberta seria a “segunda esteira” que a intenção proíbe; C porque o volume/contexto da pesquisa web contaminaria a sessão do orquestrador e não deixaria artefato com janela declarada para auditar, revisar e reentregar. + +### Decisões de engenharia + +**D1 — Estado da última execução: `data/falas-web/last-run.json` (gitignored), dono único do watermark + pendências + revisão.** +`Opções: A) arquivo gitignored em data/ (gate); B) derivar do report mais recente em data/falas-web/reports/; C) registro em banco (collection/global).` +`Recomendação: A — porque é a decisão confirmada no gate (“o formato é decidido na implementação”), segue o padrão das famílias de skill (nada novo em data/ fica versionado) e é o único lugar que pode carregar pendências e revisão sem schema novo.` +`Rejeitadas: B porque o report do import não carrega a lista de revisão nem as pendências de retry (o recorte da descoberta e o que aguarda confirmação ficariam sem dono) e o diretório de reports pode ser podado; C porque exigiria migration/collection/access e viola “o estado da última execução é o único estado novo” (sem banco).` + +Formato (pinado pelo spec): + +```jsonc +{ + "version": 1, + "lastRunAt": "2026-09-24T18:00:00.000Z", + "lastStateAt": "2026-09-24T18:07:12.000Z", + "lastDiscoveryPath": "data/falas-web/discovery-2026-09-24T18-00-00-000Z.json", + "lastBatchPath": "data/falas-web/batch-2026-09-24T18-03-00-000Z.json", + "lastReportPath": "data/falas-web/reports/falas-web-2026-09-24T18-03-05-000Z.json", + "pending": [ + { + "sourceKey": "web:youtube:abc123", + "finding": { "platform": "youtube", "url": "https://www.youtube.com/watch?v=abc123", "publishedAt": "2026-09-12" }, + "reason": "download: HTTP 403", + "attempts": 1, + "addedAt": "2026-09-24T18:07:12.000Z" + } + ], + "review": [ + { + "sourceKey": "web:instagram:xyz", + "finding": { "platform": "instagram", "url": "https://www.instagram.com/reel/xyz/", "publishedAt": "2026-09-10" }, + "reason": "não confirmado como fala do titular (compilado de terceiro)", + "flaggedAt": "2026-09-24T18:07:12.000Z" + } + ] +} +``` + +- `lastRunAt` = janela superior da última descoberta importada com sucesso (total/parcial): o `window.to` do artefato, **nunca** o fim do import — assim o que for publicado durante a esteira cai na próxima janela em vez de ficar no vão. +- `lastReportPath` é `null` quando a rodada pula o import (D6); `pending`/`review` carregam o finding integral (reentrega sem depender de artefatos antigos); só URLs/títulos públicos — zero PII. +- Escrita atômica (arquivo temporário + `mv`); estado ilegível/corrompido → **fail-closed**: a rodada não começa e a mensagem diz o caminho e como forçar varredura inicial (remover/renomear). Nunca tratar corrompido como ausente em silêncio. + +**D2 — Descoberta: subagente dedicado escreve artefato datado; a skill separa descoberta de lote.** +`Opções: A) subagente de pesquisa web + artefato datado + recibo curto (padrão do researcher dos dossiês); B) a própria sessão principal pesquisa; C) serviço/crawler no app (ou fan-out de subagentes por plataforma).` +`Recomendação: A — porque a pesquisa é o passo pesado de contexto, o artefato datado com window é auditável/reentregável e o recibo curto é o mesmo contrato já testado em .opencode/agent/dossie-solla-cidade.md; um artefato = um escritor.` +`Rejeitadas: B porque infla a sessão do orquestrador e não deixa rastro datado da janela/queries; C porque não há SERP/crawler no app e o fan-out por plataforma não tem volume que justifique (um deputado) — gatilho de revisitação: se a varredura inicial ficar lenta, dividir por plataforma, nunca por item.` + +Contrato do artefato `data/falas-web/discovery-.json` (stamp no mesmo shape do report do C215, `src/lib/webSpeeches.mjs:131`: ISO com `:`/`.` → `-`, para ordenar cronologicamente): + +```jsonc +{ + "generatedAt": "2026-09-24T18:00:00.000Z", + "window": { "from": "2026-09-01T00:00:00.000Z", "to": "2026-09-24T18:00:00.000Z", "initial": false }, + "queries": ["\"Jorge Solla\" after:2026-09-01", "site:youtube.com \"Jorge Solla\"", "..."], + "findings": [ + { "platform": "youtube", "url": "https://www.youtube.com/watch?v=abc123", "externalId": "abc123", "publishedAt": "2026-09-12", "title": "…", "channel": "…" } + ], + "review": [ + { "finding": { "platform": "instagram", "url": "https://www.instagram.com/p/xyz/", "publishedAt": "2026-09-10" }, "reason": "…" } + ], + "notes": ["limites declarados: rádio sem arquivo direto não virou finding; Instagram fora da janela descartado"] +} +``` + +- Plataformas v1 = as do C215 (`youtube|instagram|radio|audio`, `src/lib/webSpeech.ts:11-16`), com queries pelo nome do deputado e variações e âmbito de data na janela; `externalId`/título/canal/duração/thumb quando a plataforma expõe (o yt-dlp completa lacunas). +- Rádio/áudio só viram `finding` com `mediaUrl` direta (o schema do C215 a exige, `src/lib/webSpeech.ts:216-223`); sem ela, entram em `notes` (limite declarado) — não em `review` (aprovar não pode gerar `invalid`). +- O agente devolve **só o recibo curto** (seção “Recibo da descoberta” da skill): janela, contagens por plataforma, `findingsCount`, `reviewCount`, `artifactPath`, limites — nunca o corpo do artefato. +- A skill escreve o lote `data/falas-web/batch-.json` no contrato **exato** do C215 (`{ generatedAt, findings }`); a descoberta não escreve o lote nem roda o CLI, e a validação do lote é do C215 (`parseWebSpeechBatch`/`parseWebSpeechFinding`) — a skill não reimplementa o parser. + +**D3 — Curadoria: “revisar” vive no artefato e no estado; confirmação/descarte por argumento do comando.** +`Opções: A) marcar e não ingerir, com confirmação explícita (aprovar/descartar); B) ingerir tudo e curar depois; C) descartar automaticamente o duvidoso.` +`Recomendação: A — a intenção fixou “não ingere até confirmação”; o duvidoso é barato de listar e caro de desfazer (custo de ASR + lixo no catálogo), e o caminho de confirmação cabe em $ARGUMENTS sem UI.` +`Rejeitadas: B porque contraria o aceite (falso positivo entraria no catálogo); C porque descarte silencioso perde material real e esconde do recibo o que ficou de fora.` + +- Critérios de `review` (relevância/autenticidade, nunca schema): (a) não é fala do titular (homônimo/menção/compilado de terceiro); (b) data não confirmável na janela; (c) provável duplicata semântica por outra URL (sinalizada, não bloqueia — dedupe cross-URL está fora de escopo do C215); (d) metadados que o agente não consegue confirmar. +- Modes do `$ARGUMENTS` (contrato do comando/skill): + - vazio → rodada (incremental; sem estado = inicial completa); + - `revisar` → lista numerada de `review` + `pending` (sourceKey, plataforma, título/URL, motivo); não descobre, não ingere, não muda o estado; + - `aprovar <índice|sourceKey|url|todos>` → promove de `review` (+ `pending` existentes) para um lote e ingere na mesma invocação; o watermark **não** muda (não houve descoberta); sucesso/`skipped` remove da lista, falha vira `pending`; + - `descartar <índice|sourceKey|url|todos>` → remove de `review`/`pending` com o motivo no recibo; nada é ingerido. +- Índice resolvido contra a listagem corrente, com o mapeamento impresso antes de agir (nada de “aprovar 3” ambíguo). + +**D4 — Falhas, sucesso parcial e watermark: exit 0 avança; exit 1 não avança e vira `pending`; retry reentrega.** +`Opções: A) exit 0 (criou/atualizou/pulou ≥1) avança lastRunAt para window.to; falhas e não-considerados viram pending e são reentregues no lote seguinte; exit 1 (nenhum entrou) não avança e os considerados viram pending; B) avança sempre; C) não avança enquanto houver qualquer falha.` +`Recomendação: A — porque “falha de um achado não derruba o lote” é o aceite (sucesso parcial avança) e uma falha sistêmica (yt-dlp ausente, banco fora, envelope inválido) exige recobrir a mesma janela, não escondê-la.` +`Rejeitadas: B porque exit 1 é sinal de falha sistêmica e pode acontecer antes do plano (nenhum considered para virar pending) — avançar perderia a janela sem retry; C porque um link morto congelaria a janela para sempre e toda rodada recomeçaria do mesmo ponto.` + +- O recibo lê o **JSON do report** (`data/falas-web/reports/falas-web-.json`, o CLI imprime o caminho; escrito antes do exit em `scripts/import-web-speeches.mjs:226-233`) — nunca do stdout nem de memória; em exit 1 o report existe e é lido do mesmo jeito. +- `pending` = `failures[].sourceKey` mapeados de volta ao lote + cauda não considerada por `--limit` (a skill roda o `--dry-run` equivalente e usa `plannedEntries` para saber o que sobrou; sem `--limit`, o summary cobre tudo). +- Retry: os `pending` vão **primeiro** no próximo lote (prioridade); o skip/atualização é decidido pelo C215 (`findSpeechImportState` + `isCompleteWebSpeechState`); `attempts` incrementa por reentrega; nada é descartado automaticamente — `descartar` é o caminho explícito (ex.: plataforma que exige login). +- `duplicates`/`invalid` do C215 não viram pending (duplicata já foi tratada; inválido não melhora por repetição — o conserto é na curadoria/descoberta) e aparecem contados no recibo. + +**D5 — Fronteira: a skill só orquestra — nada de download/ASR/faceta/dedupe própria.** +`Opções: A) a skill para no lote e no post-mortem do recibo; aquisição/ASR/classificação/upsert/idempotência ficam no C215; B) a skill baixa mídia/transcreve para “chegar pronta”; C) a skill valida o lote e mantém dedupe própria.` +`Recomendação: A — porque os donos já existem e são testados (yt-dlp/downloadToFile, deepInfraTranscribeSegments, classifySpeech, upsertSpeechBundle, webSpeechSourceKey) e a skill é prosa: qualquer lógica duplicada em prompt divergiria em silêncio.` +`Rejeitadas: B porque é a segunda esteira que a intenção proíbe (custo/credenciais/guardas duplicados); C porque src/lib/webSpeech.ts (schema/canonicalização/sourceKey) e findSpeechImportState são os donos da validação e da idempotência.` + +- A descoberta também **não lê o banco**: a garantia de não reprocessar é janela + skip por estado do C215. +- A curadoria é só relevância/autenticidade/janela — temas/alcance/menções são do classificador do C215. + +**D6 — Rodada sem achados (findings 0 e pending 0): pula o import e ainda avança o watermark.** +`Opções: A) rodar o CLI com lote vazio só para sair um report; B) pular o import, emitir o recibo a partir do artefato e avançar o estado para window.to; C) não avançar sem import.` +`Recomendação: B — porque “nada novo” é sucesso da rodada; avançar evita recobrir o mesmo período para sempre e o boot do Payload não produz nada.` +`Rejeitadas: A porque faz conexão de banco para um report todo zero; C porque toda rodada vazia recomeçaria do mesmo ponto indefinidamente.` + +- Se `pending` não for vazio, há lote: o import roda normalmente (retry). +- Em B, `lastReportPath: null` e o recibo declara “nada novo no período”; o restante do estado é gravado normalmente. + +### Componentes / mudanças + +- **`.agents/skills/catalogo-falas-web/SKILL.md`** (novo; fonte canônica): Quando usar; argumentos (`revisar`/`aprovar`/`descartar`); fluxo ordenado (estado → descoberta → curadoria → lote → CLI → report → recibo → estado, com o estado gravado **por último**); contrato do estado (D1) e do artefato de descoberta (D2); política de revisão (D3); falhas/watermark/retry (D4); fronteira (D5); pulo do import (D6); recibo literal; troubleshooting (estado corrompido, yt-dlp ausente, `--limit`, plataforma bloqueada, exit 1, primeira rodada grande, banco local fora do ar); guardrails (uma rodada por vez; sem PII; nada commitado — `data/falas-web/` é gitignored, `.gitignore:82`). +- **`.opencode/commands/catalogo-falas-web.md`** (novo; fino): frontmatter só com `description`; corpo cita `` `catalogo-falas-web` ``, aponta `.agents/skills/catalogo-falas-web/SKILL.md` e passa `$ARGUMENTS`; não transcreve o fluxo. +- **`.opencode/agent/catalogo-falas-web.md`** (novo; `mode: subagent`, sem `model:`): papel de descoberta (D2) — recebe a janela, pesquisa as plataformas do C215, escreve `data/falas-web/discovery-.json`, devolve só o recibo curto; limites explícitos: não baixa mídia, não transcreve/classifica, não roda `pnpm falas-web:import`, não escreve o lote/estado, não lê o banco, não inventa URL/`mediaUrl`. +- **`tests/unit/opencodeCommands.unit.spec.ts`** (editar): `'catalogo-falas-web'` na lista hardcoded (`:13-25`) — guarda o coupling comando↔skill (nome exato, `$ARGUMENTS`, path do SKILL, `description`, sem `model:`). +- **`tests/unit/catalogoFalasWebSkill.unit.spec.ts`** (novo; pinos de texto, padrão `reelsTutoriaisSkill`/`graficosDadosSkill`): `/catalogo-falas-web`; `data/falas-web/last-run.json` com `lastRunAt`/`pending`/`review` e “gitignored”; artefato `discovery-.json` com `window`/`findings`/`review`; `pnpm falas-web:import --findings` e `data/falas-web/reports/`; campos do recibo (período/achados/novos/ignorados/falhas/custo); modes `revisar`/`aprovar`/`descartar`; proibições (não baixa/não transcreve/não classifica/não deduplica); agente `mode: subagent` sem `model:` e receita curta. +- **`docs/changelog/2026-09-24-c218.md`** (novo): entrada curta no padrão do repositório. +- **`docs/plans/skill-catalogo-falas-web-impl.md`** (este arquivo): parte do PR. +- **Migration:** sem migration — nenhum schema/collection/global muda; `data/falas-web/` já é gitignored (`.gitignore:82`) e `pnpm falas-web:import` já existe (`package.json:92`). Sem migration também é a pista de que o PR **não** é plans-only: skill/agent/command + spec entram no diff, então `Closes #1294` é permitido. +- **Access / Consent:** sem mudança — a skill não toca banco; o CLI mantém as guardas (`assertLocalDatabase` + `FALAS_WEB_IMPORT_CONFIRM=1` para alvo não-local) e a skill não exporta a flag; sem PII nova (URLs/títulos públicos; telefone/e-mail nunca). +- **UI:** N/A — Impeccable A; sem superfície de usuário. +- **Nenhum módulo novo de código:** não criar helper em `scripts/lib/*.mjs` (evita entrada em `SCRIPTS_SPEC_PINNED`, `scripts/lib/test-affected-core.mjs:31`) nem módulo top-level em `src/utilities/` (o `codebaseConventions.unit.spec.ts` pina) — a entrega é skill/agent/command + spec de pin. + +### Recibo (contrato literal da skill) + +```text +[catalogo-falas-web] período: → +achados: N · revisar: N +novos: N · atualizados: N · ignorados: N · inválidos: N · duplicados: N +falhas: N (pendentes para a próxima rodada) + - (): +custo/tempo: · ASR (~US$ ) · LLM US$ +estado: data/falas-web/last-run.json (lastRunAt → ) +``` + +Mapa: período/achados/revisar vêm do artefato de descoberta; novos/atualizados/ignorados/inválidos/duplicados/falhas/custo/tempo vêm do report do C215 (`created/updated/skipped/invalid/duplicates/failures/asrSeconds/asrCostUsd/llmCostUsd/durationMs`, `scripts/lib/webSpeeches.mjs:95-129`); falha sem report (envelope inválido/CLI morreu antes) vira falha da rodada com o motivo, sem avançar o estado. + +### Dados → forma (se aplicável) + +- **N/A por decisão da intenção:** o recibo é insumo operacional de quem roda a skill, não superfície de usuário; nada a apresentar, nenhuma forma nova. + +## Fases verificáveis + +1. **Tracer (skill + command + lista de pinos)** — ~0,3 dia. + - `.agents/skills/catalogo-falas-web/SKILL.md` com o fluxo ponta a ponta (estado → descoberta → curadoria → lote → CLI → report → recibo → estado) e `.opencode/commands/catalogo-falas-web.md`; `'catalogo-falas-web'` em `tests/unit/opencodeCommands.unit.spec.ts`. + - Prova barata do contrato (sem rede/mídia): criar `data/falas-web/last-run.json` + um `batch-smoke.json` de 1 finding e rodar `pnpm falas-web:import --findings data/falas-web/batch-smoke.json --dry-run` contra o banco local do worktree (lê o plano, imprime o report e o caminho) — valida estado, envelope, CLI e leitura do report. +2. **Subagente de descoberta + contratos** — ~0,5 dia. + - `.opencode/agent/catalogo-falas-web.md`; contrato do artefato (D2), critérios de revisão (D3), recibo da descoberta, templates de query por plataforma. + - Prova manual (rede): rodada de janela estreita no ambiente local, inspeção do `discovery-.json` e confirmação de que o agente devolve só o recibo. +3. **Curadoria/estado/retry + spec de pin** — ~0,5 dia. + - Modes `revisar`/`aprovar`/`descartar`, `pending`/`attempts`/`descartar`, regras de watermark (D4/D6), bloco do recibo, troubleshooting; `tests/unit/catalogoFalasWebSkill.unit.spec.ts`. +4. **Gates** — ~0,25 dia. + - `pnpm gate:fast` (lint + typecheck + unit); `pnpm push`. A primeira rodada real com download/ASR é do operador (P2, custo de máquina) — a entrega verifica o contrato com `--dry-run` e a descoberta real em janela estreita. + +Quota: ~1,5–1,6 dia eng, dentro do appetite herdado. Se apertar, o corte é a conveniência do fan-out/afinação de queries — nunca as garantias: estado/watermark, revisão sem ingestão, retry de pending, recibo honesto e fronteira com o C215. + +## Rabbit holes / Não escopo (engenharia) + +- Agendador/cron/monitoramento contínuo/fila/alerta — rodada manual sob demanda (intenção). +- Crawler/SERP próprio; plataformas fora da v1 (TikTok/Facebook/X); login/cookies/conteúdo privado; varredura de perfis que não são do deputado. +- Download/ASR/classificação/facetas/serving/dedupe/idempotência na skill (donos do C215); `--reprocess` do CLI não é usado pela skill. +- Dedupe semântico cross-URL/cross-plataforma (mesma fala em dois links) — item próprio (C215 já declarou fora de escopo). +- UI/tela (C216), cortes (C217), paridade das gravações (C219), publicação externa/redes. +- Schema/migration/collection/global/Consent/access; módulo novo em `src/utilities/` ou helper em `scripts/lib/`; alterar o CLI do C215. +- Fan-out de subagentes por plataforma (gatilho de revisitação: varredura inicial lenta). +- Prometer cobertura exaustiva/dashboard de vaidade. + +## Riscos e mitigação + +- **Descoberta ruidosa / falso positivo.** Critérios de curadoria (D3) + `review` que nunca ingere + contagem “revisar” no recibo; o duvidoso é visível, não silencioso. +- **Link morto/plataforma bloqueada (Instagram sem login).** Falha por achado no report do C215 (`stage`/`error`), vira `pending`; `descartar` explícito quando for crônico; o lote nunca cai por um item. +- **Estado corrompido/ausente.** Fail-closed com mensagem acionável; remover/renomear força varredura inicial; escrita atômica (tmp + `mv`); uma rodada por vez documentada. +- **C215/yt-dlp ausente ou desatualizado.** O CLI falha com hint (`pipx install yt-dlp`, `scripts/import-web-speeches.mjs:108-111`); a skill não instala nem mascara — reporta a falha e mantém `pending`. +- **Janela perdida por falha sistêmica.** Exit 1/envelope inválido não avançam o watermark e os considerados viram `pending`; a rodada seguinte recobre a mesma janela. +- **Varredura inicial cara (horas de vídeo).** `--limit` + dry-run para identificar a cauda → `pending`; recibo com tempo/custo; cobertura declarada em `notes`. +- **Duplicata semântica por outra URL.** Fora de escopo (C215); `review` pode sinalizar “provável duplicata”, sem bloquear; o recibo não promete dedupe cross-URL. +- **Duas rodadas simultâneas sobrescrevendo o estado.** “Uma rodada por vez” documentado; o índice unique do C215 impede duplicata no catálogo mesmo assim. +- **Coupling silencioso com o CLI do C215 (flags/report mudarem).** O spec pina `pnpm falas-web:import --findings`, o caminho do report e os campos usados no recibo; mudança no C215 força atualizar skill + spec no mesmo PR. +- **Recibo mentir.** Sempre derivado do JSON do report (D4), nunca de estimativa/memória. +- **PII no estado.** Só URLs/títulos públicos e motivos operacionais; telefone/e-mail nunca entram. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: rodar a skill atualiza desde a última execução (sem estado = varredura inicial completa), cura “revisar” sem ingerir, entrega o lote ao C215, imprime o recibo (período/achados/novos/ignorados/falhas com motivo/custo-tempo) e reexecutar não duplica nem reprocessa. +- [ ] Invariantes AGENTS/engineering-standards: sem schema/migration/collection/access/Consent; sem UI; sem segundo pipeline de download/ASR/classificação; C215 é o dono da validação, aquisição, idempotência e guardas de execução; copy pt-BR / identificadores em inglês; artefatos gitignored; nenhum módulo novo em `src/utilities/` ou `scripts/lib/`. +- [ ] Estado é o único estado novo (`data/falas-web/last-run.json`), regenerável por remoção, sem PII; a skill nunca exporta `FALAS_WEB_IMPORT_CONFIRM`. +- [ ] Testes: `'catalogo-falas-web'` na lista de `opencodeCommands`; `catalogoFalasWebSkill.unit.spec.ts` pinando os literais do contrato (estado, artefato, CLI, recibo, modes, fronteira, agente subagent sem `model:`); tracer com `pnpm falas-web:import --dry-run`; `pnpm gate:fast` verde. +- [ ] PR não plans-only (skill/agent/command/spec no diff) → `Closes #1294` permitido; changelog `docs/changelog/2026-09-24-c218.md` commitado. + +## Self-score de decision-quality + +**4,5/5.** + +- **Decisões caras com rejeitadas (1):** D1 (dono do estado/watermark), D2 (descoberta por subagente + contrato do artefato), D3 (revisar/confirmar/descartar), D4 (watermark/retry/degradê) e D5 (fronteira com o C215) têm opções e rejeições ancoradas em contrato (`scripts/import-web-speeches.mjs:144-156/226-234`, `src/lib/webSpeech.ts:216-231`, `findSpeechImportState`, `.gitignore:82`, gate 2026-09-24). +- **Cabe no appetite (2):** fases ≈1,5–1,6 dia (tracer → agente/contratos → curadoria/estado/spec → gates), dentro dos ~1–2 dias herdados; tracer cedo (`--dry-run`) antes da descoberta real. +- **Rabbit holes nomeados (3):** agendador/monitoramento, crawler/SERP, plataformas fora da v1, segunda esteira, dedupe cross-URL, UI/cortes, schema/módulo novo, fan-out prematuro. +- **Depth check (4):** reusa o CLI/report/contrato do C215, o padrão de researcher com recibo curto dos dossiês e o pin de comandos; não cria shell, serviço, helper nem collection — nada de twin. +- **Outcome preservado (5):** a engenharia não reescreveu o produto — a rodada continua manual, o duvidoso não entra, o C215 ingere e o recibo é o insumo; o que ficou de fora (tela, cortes, monitoramento) é exatamente o que a intenção cortou. + +Perde 0,5 porque duas pontas são declaradamente probabilísticas/externas em vez de garantidas: a qualidade da descoberta web (mitigada por curadoria + `review`, não provada) e a duplicata semântica cross-URL (herdada como fora de escopo do C215) — o plano declara as duas em vez de fingir cobertura. + +## Execução — desvios do plano (2026-09-24) + +- **`--limit`: a cauda fica no lote, não vira `pending`.** O plano (D4) dizia que a cauda não considerada por `--limit` entraria em `pending`; a implementação deixa os itens no próprio `batch-.json` e o recibo diz como terminá-los (`pnpm falas-web:import --findings `, idempotente por skip de estado). Motivo: com `pending` primeiro e um limite fixo, a cauda antiga poderia consumir o limite para sempre e travar descobertas novas; o aceite só exige reportar o que ficou de fora. +- **`--dry-run` virou passo obrigatório de planejamento.** Além de validar o envelope e exigir `invalid`/`duplicates` = 0 (curadoria), é ele que dá o `sourceKey` de cada entrada **na ordem do lote** (`plannedEntries`) — é esse mapeamento por ordem que permite transformar `failures[].sourceKey` do import em `pending` sem a skill recalcular a chave. +- **`aprovar` sempre reentrega os `pending`.** O mode promove os itens escolhidos de `review` e reentrega todos os `pending` no mesmo lote (D3), com o watermark congelado; o que entrar (criado/atualizado/pulado) sai das listas e o que falhar vira `pending`. +- **`sourceKey` opcional no estado + `lastStateAt` (não `lastSuccessfulAt`).** O item de `pending`/`review` grava o `sourceKey` quando conhecido (vem do `plannedEntries` do dry-run) para `revisar`/`aprovar`/`descartar` operarem por chave sem a skill recalcular `webSpeechSourceKey`; e o carimbo da gravação virou `lastStateAt` porque ele avança também em rodada com falhas — "sucesso" do incremental é o `lastRunAt` ter avançado. +- **Falha de planejamento não vira `review`.** `invalid`/`duplicates` no dry-run **não** são "devolvidos a `review`" (item de schema não se resolve por `aprovar` — loop sem saída): a skill para, não avança o watermark e relata os ofensores (índice + erro) no recibo; o estado fica como está. O mesmo vale para a leitura do estado: falha de planejamento/sistêmica não reescreve o watermark. +- **Smoke da descoberta com listener `general`.** O arquivo `.opencode/agent/catalogo-falas-web.md` é carregado pelo opencode no início da sessão; como ele nasceu nesta sessão, o smoke de rede (janela 01–24/09) foi despachado com o contrato do arquivo passado a um listener `general` — numa sessão nova o tipo `catalogo-falas-web` estará disponível. O contrato do arquivo (papel, artefato, recibo, limites) fica pinado pelo spec, como nos subagentes das famílias de dossiê. +- **Smoke local sem ingestão real.** O tracer rodou `--dry-run` de um lote fake e o dry-run do lote descoberto (18 achados, 0 inválidos, 0 duplicados) contra o banco local do worktree; a rodada real com download/ASR fica com o operador (P2), como já registrado no C215. Os artefatos do smoke (`discovery`/`batch`/`reports`) foram removidos depois — `data/falas-web/` fica limpo para a primeira rodada real (que sem `last-run.json` faz varredura inicial completa). + +## Simplify — triage dos revisores (2026-09-24) + +Dois revisores em paralelo (estrutural + qualidade). **17 achados corrigidos na sessão** (não reabrir): `invalid`/`duplicates` não podem virar `review` (o C215 só reporta duplicata; a skill para, não avança o watermark e relata os ofensores), fronteira de escrita da curadoria (dúvida vai para `review` do estado; o artefato segue com um escritor só), ler o artefato do disco sem pedir o corpo ao subagente, ciclo explícito de `pending` (`sourceKey` opcional vindo do `plannedEntries`; cauda de `--limit` não vira pending), `--limit` igual no dry-run e no import, exceção do lote vazio no recibo, regras de preenchimento do recibo por modo (`aprovar`/`descartar`/exit 1), recuperação da cauda órfã no Troubleshooting, numeração de `aprovar`/`descartar`, `descartar` com o motivo do item, `to = agora` no despacho da descoberta, `lastStateAt` no lugar de `lastSuccessfulAt`, pinos do envelope do lote (`{ generatedAt, findings }`/`batch-.json`), do watermark (`lastRunAt` = `window.to`), do dry-run obrigatório e da **negação** de `FALAS_WEB_IMPORT_CONFIRM`. + +| ID | Resumo | Origem | Score | Tipo | Destino | +| --- | ----------------------------------------------------------------- | ----------------- | ----- | ------------ | -------------------------------------------------------------- | +| S1 | Pinso fracos de prosa (`gitignored`, `Exit 0/1`) | simplify estrutural | 1 | cheap_polish | descartar (contratos reais têm regex próprio) | +| S2 | `mediaUrl` de rádio/áudio repetido em 3 camadas | simplify estrutural | 1 | cheap_polish | descartar (redundância intencional de prompt; spec pina no agente) | +| S3 | Spec re-pina coupling comando↔skill | simplify estrutural | 1 | cheap_polish | descartar (precedente dos specs por skill) | +| Q1 | Pinos literais de markdown sensíveis a edição de prosa | simplify qualidade | 1 | cheap_polish | descartar (deliberado, como nos precedentes) | +| Q2 | "revisar" (modo) vs `review` (bucket) | simplify qualidade | 1 | cheap_polish | descartar (argumento pt-BR, chave JSON em inglês) | +| Q3 | Título do agente em minúscula | simplify qualidade | 1 | cheap_polish | corrigido na sessão | + +Veredito do modo autônomo (`--auto`): **0 Issues registradas** — nenhum resto alcança o piso (score ≥3 / expensive_lock ≥4); os descartes são polimento de prosa/spec deliberado. Fechamento: 23 achados → 17 corrigidos na sessão, 6 descartados. + diff --git a/tests/unit/catalogoFalasWebSkill.unit.spec.ts b/tests/unit/catalogoFalasWebSkill.unit.spec.ts new file mode 100644 index 00000000..1ff04683 --- /dev/null +++ b/tests/unit/catalogoFalasWebSkill.unit.spec.ts @@ -0,0 +1,137 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// C218 — pins the incremental catalog skill contract: the single new state +// file (watermark + pending + review), the dated discovery artifact written by +// the subagent, the batch handoff to the C215 ingestion CLI, the curation +// modes and the receipt. The whole flow is prompt text (SKILL.md + agent), so +// this guard fails the build if a contract literal is dropped or if the +// subagent regains the ingestion/download role it must not have. + +const repoRoot = process.cwd() + +const read = (path: string): string => readFileSync(resolve(repoRoot, path), 'utf8') + +const skill = read('.agents/skills/catalogo-falas-web/SKILL.md') +const agent = read('.opencode/agent/catalogo-falas-web.md') + +describe('command /catalogo-falas-web couples to the canonical skill', () => { + it('ships the thin wrapper pointing at the SKILL.md', () => { + const command = read('.opencode/commands/catalogo-falas-web.md') + expect(existsSync(resolve(repoRoot, '.agents/skills/catalogo-falas-web/SKILL.md'))).toBe(true) + expect(command).toContain('`catalogo-falas-web`') + expect(command).toContain('$ARGUMENTS') + expect(command).toContain('.agents/skills/catalogo-falas-web/SKILL.md') + expect(command).toMatch(/^description: .+$/m) + expect(command).not.toMatch(/^\s*["']?model["']?\s*:/m) + }) +}) + +describe('skill /catalogo-falas-web documents the incremental pipeline', () => { + it('pins the single new state file and its fail-closed read', () => { + expect(skill).toContain('## Contrato do estado') + expect(skill).toContain('data/falas-web/last-run.json') + for (const field of ['lastRunAt', 'lastStateAt', 'pending', 'review']) { + expect(skill, `state contract must carry ${field}`).toContain(field) + } + expect(skill, 'the watermark is the discovery window end').toMatch( + /`lastRunAt` é o `window\.to` da descoberta/, + ) + expect(skill, 'state lives in a gitignored path').toContain('gitignored') + expect(skill, 'state write must be atomic').toMatch(/escrita atômica/i) + expect(skill, 'corrupt state stops the run').toMatch(/fail-closed/) + }) + + it('pins the dated discovery artifact written by the one writer', () => { + expect(skill).toContain('## Contrato do artefato de descoberta') + expect(skill).toContain('data/falas-web/discovery-.json') + for (const field of ['generatedAt', 'window', 'queries', 'findings', 'review', 'notes']) { + expect(skill, `artifact contract must carry ${field}`).toContain(field) + } + expect(skill, 'initial scan runs without a watermark').toContain('varredura inicial') + expect(skill, 'failed discovery writes no partial artifact').toMatch( + /não escreve artefato parcial/, + ) + }) + + it('hands the batch to the C215 ingestion CLI and reads its report', () => { + expect(skill).toContain('pnpm falas-web:import --findings') + expect(skill, 'the batch carries the exact C215 envelope').toContain( + '{ generatedAt, findings }', + ) + expect(skill, 'the batch is the handoff artifact').toContain('batch-.json') + expect(skill, 'planning runs read-only first').toContain('Planejar (obrigatório)') + expect(skill, 'planning runs read-only first').toContain('--dry-run') + expect(skill, 'the report is the source of the receipt').toContain('data/falas-web/reports/') + expect(skill, 'invalid/duplicates stop the ingestion').toMatch(/invalid > 0/) + expect(skill, 'invalid/duplicates stop the ingestion').toMatch(/duplicates > 0/) + expect(skill, 'invalid/duplicates keep the watermark').toMatch(/não avance o watermark/) + expect(skill, 'empty batch skips the CLI').toMatch(/pule o CLI/) + }) +}) + +describe('curation, watermark and retry', () => { + it('documents the review modes without discovery or ingestion side effects', () => { + for (const mode of ['revisar', 'aprovar', 'descartar']) { + expect(skill, `mode ${mode} must be documented`).toContain(mode) + } + expect(skill, 'doubtful findings are not ingested before confirmation').toMatch( + /não é ingerido até confirmação/, + ) + expect(skill, 'curation demotes to the state review list').toMatch(/não inclua\s+no lote/) + expect(skill, 'approval does not move the watermark').toMatch( + /`lastRunAt` \*\*não\*\* muda/, + ) + }) + + it('pins the watermark semantics and the pending retry', () => { + expect(skill).toContain('Exit 0') + expect(skill).toContain('Exit 1') + expect(skill, 'exit 1 must not advance the window').toMatch( + /`lastRunAt` \*\*não\*\* avança/, + ) + expect(skill, 'retries carry the attempt count').toContain('attempts') + expect(skill, 'pending is retried first').toMatch(/vão primeiro no próximo lote/) + expect(skill, 'the --limit tail stays in the batch').toMatch(/restam M−N achados no lote/) + }) + + it('pins the literal receipt', () => { + expect(skill).toContain('## Recibo (literal)') + expect(skill).toContain('[catalogo-falas-web] período:') + for (const field of ['achados:', 'novos:', 'ignorados:', 'falhas:', 'custo/tempo:']) { + expect(skill, `receipt must declare ${field}`).toContain(field) + } + }) +}) + +describe('skill frontier and subagent role', () => { + it('forbids a second ingestion pipeline inside the skill', () => { + expect(skill, 'no media download').toMatch(/Não baixa mídia/) + expect(skill, 'no transcription').toContain('não transcreve') + expect(skill, 'no facet classification').toContain('não classifica facetas') + expect(skill, 'no own dedupe').toMatch(/não faz\s+dedupe própria/) + expect(skill, 'never exports the C215 write confirm flag').toMatch( + /Nunca exporta `FALAS_WEB_IMPORT_CONFIRM`/, + ) + expect(skill, 'never exports the C215 write confirm flag').not.toMatch( + /export\s+FALAS_WEB_IMPORT_CONFIRM/, + ) + }) + + it('keeps the subagent discovery-only', () => { + expect(agent).toMatch(/^---\n[\s\S]*mode: subagent[\s\S]*\n---/) + expect(agent).not.toMatch(/^\s*["']?model["']?\s*:/m) + expect(agent).toContain('discovery-.json') + expect(agent).toContain('Recibo da descoberta') + expect(agent, 'subagent must not run the ingestion CLI').not.toContain('--findings') + expect(agent, 'subagent must not write the lote/state').toContain( + 'nem o estado (`last-run.json`)', + ) + expect(agent, 'radio/audio requires the direct media URL').toContain('mediaUrl') + for (const field of ['artifactPath', 'findingsCount', 'reviewCount', 'byPlatform']) { + expect(agent, `receipt must carry ${field}`).toContain(field) + } + expect(agent, 'subagent never reads the catalog DB').toMatch(/Não\*\* leia o banco/) + }) +}) diff --git a/tests/unit/opencodeCommands.unit.spec.ts b/tests/unit/opencodeCommands.unit.spec.ts index d979e448..c867495e 100644 --- a/tests/unit/opencodeCommands.unit.spec.ts +++ b/tests/unit/opencodeCommands.unit.spec.ts @@ -22,6 +22,7 @@ const commands = [ 'briefing-capacitacao-solla', 'graficos-dados', 'reels-tutoriais', + 'catalogo-falas-web', ] as const describe('opencode commands couple to their skill by exact name', () => {