diff --git a/.agents/skills/briefing-capacitacao-solla/SKILL.md b/.agents/skills/briefing-capacitacao-solla/SKILL.md new file mode 100644 index 00000000..f499a8ce --- /dev/null +++ b/.agents/skills/briefing-capacitacao-solla/SKILL.md @@ -0,0 +1,258 @@ +--- +name: briefing-capacitacao-solla +description: 'Gera o Briefing de capacitação Solla 1313 por recorte (cidade · instituição · tema): PDF A4 de até 4 páginas + companion .md, derivado do dossiê já pesquisado (sem segunda pesquisa factual), com essencial com fonte, roteiro do pedido de voto (vote 1313 + plano de voto + compromisso nomeado), perguntas prováveis × melhores respostas dos dois lados e o que evitar; aceita um recorte ou um lote separado por vírgula.' +--- + +# Briefing de capacitação Solla 1313 (C210) + +Entrega, por recorte (**cidade · instituição · tema**), um **PDF A4 de até 4 +páginas + companion `.md`** para quem vai **pedir o voto 1313** no recorte: +essencial com fato-âncora e fonte, o que Solla defende, **roteiro do pedido** +("vote 1313" + plano de voto + compromisso nomeado), **perguntas prováveis × +melhores respostas** (ataques da direita **e** da esquerda) e **o que evitar**. +É **insumo interno de capacitação**: rótulo literal +`Insumo interno de capacitação — não publicar` em todas as folhas, sem CTA +público, sem marca publicável, artefato gitignored. + +Ele **deriva do dossiê** daquele recorte — nunca faz segunda pesquisa factual: +os fatos vêm do ledger `bulletinFacts` (só item com fonte) e a lacuna é +declarada, não preenchida. É o **3º entregável** da família +`dossie-solla-{cidade,instituicao,tema}` (dossiê + boletim + briefing na mesma +invocação) e também roda sozinho. O layout vem do design hi-fi aprovado +`docs/plans/briefing-capacitacao-solla-ui-design.html` — o port é +classe-a-classe, sem improviso visual. + +## Quando usar + +- A coordenação pede "o briefing de " para quem vai panfletar/pedir + voto — ou vários numa invocação. +- O fluxo do dossiê (`/dossie-solla-cidade`, `/dossie-solla-instituicao`, + `/dossie-solla-tema`) entrega o briefing junto do dossiê e do boletim, com a + mesma skill/build — nunca um segundo pipeline. +- Quem executa são o **orquestrador** (agente principal) + **um sub-agente + autor por recorte** + o script `build-dossie-solla-briefing.mjs` (local, + offline). O detalhe está em "Pipeline (etapas)". + +## Lote (recortes) + +A invocação aceita **um** recorte ou **vários**, separados por **vírgula**, cada +token prefixado pelo recorte: + +```text +/briefing-capacitacao-solla cidade:Miguel Calmon # N=1 +/briefing-capacitacao-solla cidade:Ilheus, instituicao:UFBA, tema:Saude # lote misto +``` + +Parsing (orquestrador, **antes** de qualquer redação): + +- separa por `,`; aplica `trim`; descarta vazio; o token é + `:` com recorte em `cidade | instituicao | tema`; +- **cidade** resolve como na skill irmã (`isMunicipalitySlug` / + `resolveMunicipalityName` → `municipalityCatalogEntriesForCity`; `Salvador` é + ambíguo = peça o slug da zona `salvador-ze-N`); +- **instituicao** resolve no catálogo (`isInstitutionSlug` / + `resolveInstitutionName` → `src/lib/institutionCatalog.ts`; ambíguo falha + fechado); +- **tema** resolve na taxonomia fechada (`resolveSpeechTopic` sobre + `SPEECH_TOPICS` de `src/lib/speechFacets.ts`; aceita valor `saude` ou label + `Saúde`); +- **dedupe após a resolução**, preservando a ordem; token inválido/ambíguo ou + sem prefixo vira **falha isolada** com o motivo no summary final — **nunca** + aborta o lote nem inventa slug. + +Um recorte = um briefing (PDF + MD); **sem** índice/PDF agregado. Falha de um +recorte **não** cancela os demais (**sucesso parcial** explícito). + +## Pipeline (etapas) + +1. **Orquestrador (agente principal).** Parseia a lista (seção "Lote") e resolve + os recortes nos catálogos das skills irmãs — sem pesquisa web aqui. Nunca + retém o corpo de um `research.json`: só os recibos. +2. **Autor (sub-agente, 1 por recorte).** + `.opencode/agent/briefing-capacitacao-solla.md` — lê os + `data/dossie-solla-/.{a,b,c}.research.json` e (quando houver) + o `.narrative.json`, redige o `.briefing.json` no contrato abaixo + **somente** a partir dos itens com fonte, e devolve só o recibo (seção + "Recibo do autor"). Não faz pesquisa nova, não edita research, não roda + build/ssh, não commita. +3. **Auditoria de citações (orquestrador).** Confere citação por citação (nome, + data, valor, órgão, alcance) contra os `research.json`; todo item ancorado + aponta um `factId` real e todo texto sem lastro é corrigido ou removido. + Fato novo não entra: vira `gapReason`. +4. **Build local (por recorte), offline:** + ```bash + NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-briefing.mjs \ + --unit=municipality \ + --snapshot=data/dossie-solla-cidade/.snapshot.json \ + --research-dir=data/dossie-solla-cidade \ + --out-dir=docs/research/dossie-solla-cidade + ``` + `--unit=institution|theme` para os outros recortes (default `municipality`). + O build valida o `briefing.json` **fechado** (âncora que não resolve em fato + com fonte = erro; chave de cenário/staff-only = erro), renderiza as **4 folhas + fixas**, mede cada folha e emite **1 PDF** com a guarda de teto de páginas + (`emitHtmlSinglePdf`, `maxPages=4`). Saídas: + `docs/research/dossie-solla-/--briefing.pdf` + + `-briefing.md`; intermediários gitignored em `data/dossie-solla-/`. +5. **Summary final.** Uma linha por entrada, na ordem da lista: `entrada · status + (ok|failed) · briefing (quando ok) · motivo (quando falha)`. + +O teto de **4 páginas é rígido**: se uma folha estourar, o build **corta por +prioridade declarada** (`qa` até o mínimo de 4 com os dois lados → `defesas` → +`conferir` → `evitar` até o mínimo de 3) e **declara o resto na folha** ("e mais +N no briefing completo (.md)"); o companion `.md` carrega **tudo**. Essencial, +roteiro e identificação **nunca** são cortados; se nem o mínimo couber, o build +falha fechado apontando a lista a encurtar. + +## Contrato do `.briefing.json` + +Escrito pelo autor, validado pelo build (`scripts/lib/briefingContent.mjs`): + +```jsonc +{ + "unitId": "municipality", // municipality | institution | theme + "municipalitySlug": "miguel-calmon", // chave = slugField da unidade (institutionSlug | themeSlug) + "generatedAt": "2026-09-22T12:00:00.000Z", // obrigatório + "subtitle": "Piemonte da Diamantina · consulta antes e durante o contato", // opcional + "lede": "≤380 chars — como usar o essencial", + "essential": [ // ≥3; cada item: factId XOR gapReason + { "factId": "era_b_equipamentos", "title": "≤120", "note": "≤200" }, + { "gapReason": "sem fala própria localizada", "title": "…", "note": "…" } + ], + "defenses": [ { "factId": "…", "title": "…", "note": "…" } ], // opcional (C209); [] = linha de lacuna + "script": { "steps": [ { "title": "≤90", "note": "≤220" } ] }, // ≥3 passos; o pedido é literal do renderer + "qa": [ // ≥4, com ≥1 "direita" e ≥1 "esquerda" + { "side": "direita|esquerda|entrega", "question": "≤180", + "acknowledge": "≤200", "answer": "≤520", "close": "≤200", + "factId": "era_b_sesab" /* XOR */ "gapReason": "sem registro localizado" } + ], + "avoid": [ { "title": "≤120", "note": "≤200" } ], // ≥3 + "checklist": { "beforeAnswer": ["≤200"], "unsure": ["≤200"] } // ≥2 em cada; **negrito** no prefixo +} +``` + +Regras duras: + +- `unitId` e o slug têm de casar com o recorte do build (outro recorte = erro); +- cada item de `essential`/`defenses`/`qa` exige **exatamente um** de + `factId | gapReason`; `factId` tem de resolver num fato do ledger **com + `sourceUrl`** — **sem fonte, o item não entra**; +- `qa` cobre os dois lados (≥1 `direita` e ≥1 `esquerda`); `entrega` é opcional; +- **proibido** qualquer chave de cenário/estimativa/staff-only + (`estimatedVotes`, `scenario`, `projection`, `polls`, …) — o briefing não tem + campo numérico; valor/fase vêm do fato-âncora no render; +- caps de texto acima do limite são **aviso** (o autor encurta); quem falha + fechado é a guarda de fit/páginas; +- no `checklist`, o autor marca em `**negrito**` o prefixo que a folha imprime + como palavra-chave de varredura (`"**Fonte e data** do fato…"`) — é o único + marcador inline aceito. + +O pedido é **literal do renderer** (não reescreva no JSON): "Posso contar com +você? Para deputado federal, **vote 1313, Jorge Solla**." — com plano de voto +(onde/quando/como) e compromisso nomeado nos passos do roteiro. + +## Recibo do autor + +Só este recibo curto (≤ ~15 linhas) volta ao orquestrador — nunca o corpo do +JSON: + +```jsonc +{ + "recorte": "cidade", + "slug": "miguel-calmon", + "status": "ok", // ou "failed" + "briefingPath": "data/dossie-solla-cidade/miguel-calmon.briefing.json", + "essentialCount": 4, + "qaCount": 5, + "sides": ["direita", "esquerda"], + "anchoredFactIds": ["era_b_equipamentos", "era_b_programas", "era_c_atuacao"], + "gapCount": 3, // itens com gapReason + lacunas do recorte + "failureReason": "…" // opcional (só quando status = failed) +} +``` + +`status: "failed"` não escreve arquivo parcial; o orquestrador agrega o motivo +no summary final. Arquivo ausente = o build falha com o ponteiro para esta +skill (não há fallback determinístico: o briefing é redação, não consolidação). + +## Conteúdo do briefing (4 folhas fixas) + +- **Folha 1 — O essencial do recorte** (`data-page="essencial"`): identificação + do recorte + lede ("como usar") + fatos-âncora com fonte ou lacuna declarada + + regra do recorte (esfera/fase) no rodapé. +- **Folha 2 — Defesas e pedido** (`data-page="defesas"`): "o que Solla defende" + (C209 quando existir; sem registro = linha de lacuna, nunca proposta genérica) + + roteiro do pedido com o literal do voto 1313 + 3–5 passos (relação → fato → + pedido explícito → plano de voto → compromisso nomeado). +- **Folha 3 — Perguntas prováveis × melhores respostas** (`data-page="qa"`): + régua visível (reconhecer → fato local verificável → fechar no pedido) + Q&A + dos dois lados; resposta ancorada num fato-âncora ou declarada como lacuna ("o + dossiê não sustenta; vou conferir"). +- **Folha 4 — O que evitar + conferência + limites** (`data-page="evitar"`): + anti-padrões (confronto, humilhação, repetir o ataque, broadcast impessoal, + prometer sem lastro), "o que conferir no dossiê", "se você não souber" e os + limites/defeso (inclusive: evidência internacional orienta o método, não mede + o Brasil). + +## Guardrails de produto (não negociáveis) + +- **Rótulo literal** `Insumo interno de capacitação — não publicar` em **todas** + as folhas; sem CTA público, sem marca de campanha, sem peça publicável. +- **Teto rígido de 4 páginas A4**: corte por prioridade declarada, resto sempre + contado na folha e completo no `.md`; nenhuma folha é cortada em silêncio + (`.sheet{overflow:hidden}` esconde o corte — as guardas de contagem e de + altura falham fechado). +- **Sem segunda pesquisa factual**: só fatos do dossiê; âncora obrigatória com + fonte; lacuna explícita em vez de inferência. +- **Sem cenário eleitoral/estimativa/staff-only** (nada de `estimatedVotes`, + projeção, pesquisa); o briefing não promete efeito. +- **Empenho ≠ pagamento**; esfera explícita nunca somada; leitura relativa + (nunca % estadual absoluto); PII mínima (telefone/e-mail nunca entram). +- **Voz com dono único**: reusa `.opencode/skills/solla-comunicacao/SKILL.md` + + `referencia/tom-e-exemplos.md` (rebates e fórmula de resposta a crítica) — não + gema tom. +- **Falha isolada** por recorte não cancela o lote (**sucesso parcial**). +- **Artefato gitignored** (repo público): PDF/MD/JSON nunca são commitados. +- **Sem schema/migration/DB write/rota** — o build é offline (JSON + Chromium). + +## Troubleshooting + +- **`briefing.json` ausente**: rode a etapa do autor (sub-agente) e repita; o + build não inventa redação. +- **`factId` não resolve em fato com fonte**: o item não existe no dossiê ou + está sem fonte — troque por um `factId` real ou declare `gapReason`; nunca + "aproxime" o texto. +- **Folha estourou**: o build corta por prioridade e declara o resto; se a + mensagem disser que nem o mínimo cabe, **encurte as listas** (QA/essencial), + não o layout. +- **`unitId`/slug de outro recorte**: o build recusa o par — pare e regenere o + `briefing.json` para o recorte certo. +- **Sem C209 (defesas)**: `defenses: []` é válido e imprime a linha de lacuna; + não complete por memória. +- **Cenário/staff-only no JSON**: o build falha com a chave no erro — remova-a; + cenário eleitoral é de outro fluxo (C163), não do briefing. +- **Chromium**: vem do `@playwright/test`; se faltar binário, + `pnpm exec playwright install chromium`. + +## Referências + +- Intenção: `docs/plans/briefing-capacitacao-solla.md`; impl: + `docs/plans/briefing-capacitacao-solla-impl.md`. +- Design hi-fi (fonte de verdade do port): + `docs/plans/briefing-capacitacao-solla-ui-design.html`. +- Scripts: `scripts/build-dossie-solla-briefing.mjs`, + `scripts/lib/briefingContent.mjs` (contrato/shed), + `scripts/lib/briefingRender.mjs` (4 folhas + `.md`), + `scripts/lib/buildPdf.mjs` (`emitHtmlSinglePdf`/guarda de páginas), + `scripts/lib/dossieBlocks.mjs` (`bulletinFacts`), `scripts/lib/dossieUnit.mjs`. +- Skills irmãs (dossiê + boletim): `.agents/skills/dossie-solla-cidade/SKILL.md`, + `.agents/skills/dossie-solla-instituicao/SKILL.md`, + `.agents/skills/dossie-solla-tema/SKILL.md`. +- Voz/rebates: `.opencode/skills/solla-comunicacao/SKILL.md`, + `.opencode/skills/solla-comunicacao/referencia/tom-e-exemplos.md`. +- Evidência de mobilização citada no treinamento: Nickerson & Rogers 2010; + Gerber & Green 2000; Kalla & Broockman 2018/2020; Schein et al. 2021; + Michelson et al. 2024; Nickerson 2008; Gerber & Rogers 2009; Gerber, Green & + Larimer 2008; Cialdini & Goldstein 2004; Broockman & Kalla 2016; Grimmer, + Messing & Westwood 2012; Wood & Porter 2019; Lau, Sigelman & Rovner 2007; + Ecker et al. 2022; Debunking Handbook 2020. diff --git a/.agents/skills/dossie-solla-cidade/SKILL.md b/.agents/skills/dossie-solla-cidade/SKILL.md index 3a2b734b..93481fea 100644 --- a/.agents/skills/dossie-solla-cidade/SKILL.md +++ b/.agents/skills/dossie-solla-cidade/SKILL.md @@ -1,6 +1,6 @@ --- name: dossie-solla-cidade -description: 'Gera o dossiê Solla por cidade (PDF A4 + .md, tudo o que Solla fez pela cidade/região por era) e o Boletim Informativo modelo de 1 página, a partir da base Teqo read-only + pesquisa web datada + fontes oficiais; aceita um município ou um lote separado por vírgula.' +description: 'Gera o dossiê Solla por cidade (PDF A4 + .md, tudo o que Solla fez pela cidade/região por era) e o Boletim Informativo modelo de 1 página, a partir da base Teqo read-only + pesquisa web datada + fontes oficiais; aceita um município ou um lote separado por vírgula, e o Briefing de capacitação de até 4 páginas.' --- # Dossiê Solla por cidade + boletim modelo (C186) @@ -11,13 +11,17 @@ Entrega, por município, um **par PDF A4 datado + companion `.md`** com tudo o q Secretaria de Atenção à Saúde do MS, SESAB 2007–2014 e Câmara 2015–2027) — toda afirmação não trivial com **URL + data**, tudo sem fonte vira **lacuna explícita** — **e** o **Boletim Informativo modelo** (1 página A4, linguagem de -eleitor, sem declaração de fontes). Os dois layouts vêm dos artefatos hi-fi -aprovados pelo `designer`. +eleitor, sem declaração de fontes) **e** o **Briefing de capacitação** (3º +entregável: até 4 páginas para quem vai pedir o voto 1313, insumo interno). Os +layouts vêm dos artefatos hi-fi aprovados pelo `designer`. ## Quando usar -- A comunicação pede "o dossiê de " e/ou o "boletim modelo de " - — ou várias numa invocação (`/dossie-solla-cidade Ilheus, Itacare, Una`). +- A comunicação pede "o dossiê de ", o "boletim modelo de " + e/ou o **briefing de capacitação** de quem vai pedir voto — ou várias numa + invocação (`/dossie-solla-cidade Ilheus, Itacare, Una`). +- O briefing também roda sozinho: `/briefing-capacitacao-solla cidade:` + (skill `briefing-capacitacao-solla`, mesmo build). - Quem executa são o **orquestrador** (agente principal) + um sub-agente **researcher por era, por cidade**, em paralelo + um sub-agente **redator** (redação de abertura e parágrafos por era) + os scripts (`extract` no @@ -44,8 +48,8 @@ Parsing (orquestrador, **antes** de qualquer pesquisa): - token inválido/ambíguo vira **falha isolada** com o motivo no summary final — **nunca** aborta o lote nem inventa slug. -Uma cidade = um dossiê (PDF+MD) + **um** boletim PDF; **sem** índice/PDF -agregado. Falha de uma cidade/era **não** cancela as demais (**sucesso +Uma cidade = um dossiê (PDF+MD) + **um** boletim PDF + **um** briefing +(PDF+MD); **sem** índice/PDF agregado. Falha de uma cidade/era **não** cancela as demais (**sucesso parcial** explícito). ## Pipeline (etapas) @@ -97,8 +101,11 @@ parcial** explícito). `--emendas=` (replay sem rede), `--author=` (default `JORGE SOLLA`), `--generated-at=`, `DOSSIER_STRICT=1` (falha o run se houver lacuna — conferência, não entrega). + O **briefing de capacitação** (3º entregável) sai do mesmo fluxo — seção + própria abaixo; o build é `scripts/build-dossie-solla-briefing.mjs` + (`--unit=municipality`) e o autor é `.opencode/agent/briefing-capacitacao-solla.md`. 7. **Summary final.** Uma linha por entrada, na ordem da lista: `entrada · status - (ok|failed) · dossiê/boletim (quando ok) · motivo (quando falha)`. + (ok|failed) · dossiê/boletim/briefing (quando ok) · motivo (quando falha)`. ## Briefing por era (A/B/C) @@ -317,6 +324,31 @@ continuação com o texto inteiro. - **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem CTA de campanha; herda **apenas** fatos com fonte do dossiê. +## Briefing de capacitação (3º entregável, C210) + +- A mesma invocação entrega o **Briefing de capacitação** do recorte — PDF A4 de + **até 4 páginas** + companion `.md`, para quem vai pedir o voto 1313 — além do + dossiê e do boletim. A fonte canônica do fluxo é a skill + `.agents/skills/briefing-capacitacao-solla/SKILL.md` (não a transcreva). +- Depois de o dossiê estar pesquisado, o orquestrador dispara o **autor** + (`.opencode/agent/briefing-capacitacao-solla.md`) — que escreve + `data/dossie-solla-cidade/.briefing.json` só a partir dos itens com fonte; **audita** as + citações e **roda** o mesmo build da família: + ```bash + NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-briefing.mjs \ + --unit=municipality \ + --snapshot=data/dossie-solla-cidade/.snapshot.json \ + --research-dir=data/dossie-solla-cidade \ + --out-dir=docs/research/dossie-solla-cidade + ``` + Saídas: `docs/research/dossie-solla-cidade/--briefing.pdf` + `-briefing.md` + (gitignored, insumo interno com rótulo literal; nunca publicar). +- Rótulo literal `Insumo interno de capacitação — não publicar` em todas as + folhas; sem CTA público; sem cenário/estimativa/staff-only; só fatos com fonte + do dossiê (**sem segunda pesquisa factual**). Teto rígido de **4 páginas** com + corte por prioridade declarado (o resto fica contado na folha e completo no + `.md`); essencial, roteiro e identificação nunca são cortados. + ## Guardrails de produto (não negociáveis) - **Sem fonte, não publica**: afirmação não trivial sem URL+data vira lacuna. @@ -340,6 +372,7 @@ continuação com o texto inteiro. - **Artefato gitignored** (repo público): PDF/MD/JSON nunca são commitados. - **Defeso**: dossiê e boletim saem como insumo interno/modelo, sem CTA; a peça final publicável é da comunicação/campanha. +- **Briefing de capacitação (3º entregável)**: até **4 páginas**, rótulo literal `Insumo interno de capacitação — não publicar`, sem CTA público, sem cenário/estimativa/staff-only; deriva do dossiê (mesmo build), corte por prioridade declarado e o restante no `.md`. ## Troubleshooting @@ -368,6 +401,9 @@ continuação com o texto inteiro. ## Referências +- Briefing de capacitação (3º entregável): `.agents/skills/briefing-capacitacao-solla/SKILL.md`, `scripts/build-dossie-solla-briefing.mjs` e o design `docs/plans/briefing-capacitacao-solla-ui-design.html` (intenção/impl: `docs/plans/briefing-capacitacao-solla.md`). + + - Intenção: `docs/plans/dossie-solla-cidade.md`; impl: `docs/plans/dossie-solla-cidade-impl.md`. - Designs hi-fi (fonte de verdade do port): `docs/plans/dossie-solla-cidade-ui-design.html` diff --git a/.agents/skills/dossie-solla-instituicao/SKILL.md b/.agents/skills/dossie-solla-instituicao/SKILL.md index 3d971737..de7d8674 100644 --- a/.agents/skills/dossie-solla-instituicao/SKILL.md +++ b/.agents/skills/dossie-solla-instituicao/SKILL.md @@ -1,6 +1,6 @@ --- name: dossie-solla-instituicao -description: 'Gera o dossiê Solla por instituição (PDF A4 + .md, tudo o que Solla fez pela/na/com a instituição por era) e o Boletim Informativo modelo de 1 página, a partir da base Teqo read-only + pesquisa web datada + fontes oficiais; aceita uma instituição ou um lote separado por vírgula.' +description: 'Gera o dossiê Solla por instituição (PDF A4 + .md, tudo o que Solla fez pela/na/com a instituição por era) e o Boletim Informativo modelo de 1 página, a partir da base Teqo read-only + pesquisa web datada + fontes oficiais; aceita uma instituição ou um lote separado por vírgula, e o Briefing de capacitação de até 4 páginas.' --- # Dossiê Solla por instituição + boletim modelo (C187) @@ -11,8 +11,10 @@ Entrega, por instituição (universidade, instituto, empresa pública, autarquia e com a instituição ao longo da carreira** — toda afirmação não trivial com **URL + data**, tudo sem fonte vira **lacuna explícita** — **e** o **Boletim Informativo modelo** (1 página A4, linguagem de eleitor, sem declaração de -fontes). Skill irmã da `dossie-solla-cidade` (C186): mesmo pipeline, recorte -institucional. O layout vem dos artefatos hi-fi aprovados pelo `designer`. +fontes) **e** o **Briefing de capacitação** (3º entregável: até 4 páginas para +quem vai pedir o voto 1313, insumo interno). Skill irmã da +`dossie-solla-cidade` (C186): mesmo pipeline, recorte institucional. O layout +vem dos artefatos hi-fi aprovados pelo `designer`. O dossiê institucional **não usa caps**: cada seção (eras, abrangência, títulos, lacunas, notícias, acervo) flui por quantas páginas precisar, com quebra só entre @@ -23,9 +25,11 @@ era abre com um **parágrafo de consolidação** do que ela entrega. ## Quando usar -- A comunicação pede "o dossiê da " e/ou o "boletim modelo da - " — ou várias numa invocação (`/dossie-solla-instituicao UFBA, - Correios`). +- A comunicação pede "o dossiê da ", o "boletim modelo da + " e/ou o **briefing de capacitação** de quem vai pedir voto — ou + várias numa invocação (`/dossie-solla-instituicao UFBA, Correios`). +- O briefing também roda sozinho: `/briefing-capacitacao-solla instituicao:` + (skill `briefing-capacitacao-solla`, mesmo build). - Quem executa são o **orquestrador** (agente principal) + um sub-agente **researcher por era, por instituição**, em paralelo + um sub-agente **redator** (redação de abertura e parágrafos por era) + os scripts (`extract` @@ -54,8 +58,8 @@ Parsing (orquestrador, **antes** de qualquer pesquisa): - token inválido/ambíguo vira **falha isolada** com o motivo no summary final — **nunca** aborta o lote. -Uma instituição = um dossiê (PDF+MD) + **um** boletim PDF; **sem** índice/PDF -agregado. Falha de uma instituição/era **não** cancela as demais (**sucesso +Uma instituição = um dossiê (PDF+MD) + **um** boletim PDF + **um** briefing +(PDF+MD); **sem** índice/PDF agregado. Falha de uma instituição/era **não** cancela as demais (**sucesso parcial** explícito). ## Pipeline (etapas) @@ -106,8 +110,11 @@ parcial** explícito). `data/dossie-solla-instituicao/` (HTML, JSONs, logs). Flags úteis: `--generated-at=`, `DOSSIER_STRICT=1` (falha o run se houver lacuna — conferência, não entrega). + O **briefing de capacitação** (3º entregável) sai do mesmo fluxo — seção + própria abaixo; o build é `scripts/build-dossie-solla-briefing.mjs` + (`--unit=institution`) e o autor é `.opencode/agent/briefing-capacitacao-solla.md`. 7. **Summary final.** Uma linha por entrada, na ordem da lista: `entrada · status - (ok|failed) · dossiê/boletim (quando ok) · motivo (quando falha)`. + (ok|failed) · dossiê/boletim/briefing (quando ok) · motivo (quando falha)`. ## Briefing por era (A/B/C) @@ -300,6 +307,31 @@ nenhum número, data, nome ou órgão novo. - **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem CTA de campanha; herda **apenas** fatos com fonte do dossiê. +## Briefing de capacitação (3º entregável, C210) + +- A mesma invocação entrega o **Briefing de capacitação** do recorte — PDF A4 de + **até 4 páginas** + companion `.md`, para quem vai pedir o voto 1313 — além do + dossiê e do boletim. A fonte canônica do fluxo é a skill + `.agents/skills/briefing-capacitacao-solla/SKILL.md` (não a transcreva). +- Depois de o dossiê estar pesquisado, o orquestrador dispara o **autor** + (`.opencode/agent/briefing-capacitacao-solla.md`) — que escreve + `data/dossie-solla-instituicao/.briefing.json` só a partir dos itens com fonte; **audita** as + citações e **roda** o mesmo build da família: + ```bash + NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-briefing.mjs \ + --unit=institution \ + --snapshot=data/dossie-solla-instituicao/.institution.snapshot.json \ + --research-dir=data/dossie-solla-instituicao \ + --out-dir=docs/research/dossie-solla-instituicao + ``` + Saídas: `docs/research/dossie-solla-instituicao/--briefing.pdf` + `-briefing.md` + (gitignored, insumo interno com rótulo literal; nunca publicar). +- Rótulo literal `Insumo interno de capacitação — não publicar` em todas as + folhas; sem CTA público; sem cenário/estimativa/staff-only; só fatos com fonte + do dossiê (**sem segunda pesquisa factual**). Teto rígido de **4 páginas** com + corte por prioridade declarado (o resto fica contado na folha e completo no + `.md`); essencial, roteiro e identificação nunca são cortados. + ## Guardrails de produto (não negociáveis) - **Sem fonte, não publica**: afirmação não trivial sem URL+data vira lacuna. @@ -320,6 +352,7 @@ nenhum número, data, nome ou órgão novo. - **Artefato gitignored** (repo público): PDF/MD/JSON nunca são commitados. - **Defeso**: dossiê e boletim saem como insumo interno/modelo, sem CTA; a peça final publicável é da comunicação/campanha. **Sem schema/migration/DB write.** +- **Briefing de capacitação (3º entregável)**: até **4 páginas**, rótulo literal `Insumo interno de capacitação — não publicar`, sem CTA público, sem cenário/estimativa/staff-only; deriva do dossiê (mesmo build), corte por prioridade declarado e o restante no `.md`. ## Troubleshooting @@ -349,6 +382,9 @@ nenhum número, data, nome ou órgão novo. ## Referências +- Briefing de capacitação (3º entregável): `.agents/skills/briefing-capacitacao-solla/SKILL.md`, `scripts/build-dossie-solla-briefing.mjs` e o design `docs/plans/briefing-capacitacao-solla-ui-design.html` (intenção/impl: `docs/plans/briefing-capacitacao-solla.md`). + + - Intenção: `docs/plans/dossie-solla-instituicao.md`; impl: `docs/plans/dossie-solla-instituicao-impl.md`. - Designs hi-fi (fonte de verdade do port): diff --git a/.agents/skills/dossie-solla-tema/SKILL.md b/.agents/skills/dossie-solla-tema/SKILL.md index f83beddb..5bdd0792 100644 --- a/.agents/skills/dossie-solla-tema/SKILL.md +++ b/.agents/skills/dossie-solla-tema/SKILL.md @@ -1,6 +1,6 @@ --- name: dossie-solla-tema -description: 'Gera o dossiê Solla por tema/área (PDF A4 + .md, tudo o que Solla fez pela área por era) e o Boletim Informativo modelo de 1 página, a partir da base Teqo read-only + pesquisa web datada + fontes oficiais; aceita uma área da taxonomia do acervo ou um lote separado por vírgula.' +description: 'Gera o dossiê Solla por tema/área (PDF A4 + .md, tudo o que Solla fez pela área por era) e o Boletim Informativo modelo de 1 página, a partir da base Teqo read-only + pesquisa web datada + fontes oficiais; aceita uma área da taxonomia do acervo ou um lote separado por vírgula, e o Briefing de capacitação de até 4 páginas.' --- # Dossiê Solla por tema/área + boletim modelo (C190) @@ -10,9 +10,11 @@ Educação, Cultura, Esporte, …), um **par PDF A4 datado + companion `.md`** c tudo o que **Jorge Solla fez pela área ao longo da carreira** — toda afirmação não trivial com **URL + data**, tudo sem fonte vira **lacuna explícita** — **e** o **Boletim Informativo modelo** (1 página A4, linguagem de eleitor, sem -declaração de fontes). Skill irmã da `dossie-solla-cidade` (C186) e da -`dossie-solla-instituicao` (C187): mesmo pipeline, recorte por área. O layout -vem dos artefatos hi-fi aprovados pelo `designer`. +declaração de fontes) **e** o **Briefing de capacitação** (3º entregável: até 4 +páginas para quem vai pedir o voto 1313, insumo interno). Skill irmã da +`dossie-solla-cidade` (C186) e da `dossie-solla-instituicao` (C187): mesmo +pipeline, recorte por área. O layout vem dos artefatos hi-fi aprovados pelo +`designer`. O recorte da área é **nativo do acervo**: cada fala já é classificada por tema (`Speech.topics`, taxonomia `SPEECH_TOPICS`, C153/C154), então aqui o acervo é @@ -28,8 +30,11 @@ entrega. ## Quando usar -- A comunicação pede "o dossiê da <área>" e/ou o "boletim modelo da <área>" — ou - várias numa invocação (`/dossie-solla-tema Educação, Saúde`). +- A comunicação pede "o dossiê da <área>", o "boletim modelo da <área>" e/ou o + **briefing de capacitação** de quem vai pedir voto — ou várias numa invocação + (`/dossie-solla-tema Educação, Saúde`). +- O briefing também roda sozinho: `/briefing-capacitacao-solla tema:<área>` + (skill `briefing-capacitacao-solla`, mesmo build). - Quem executa são o **orquestrador** (agente principal) + um sub-agente **researcher por era, por área**, em paralelo + um sub-agente **redator** + os scripts (`extract` no homeserver, `build` local). O detalhe está em @@ -57,7 +62,8 @@ Parsing (orquestrador, **antes** de qualquer pesquisa): - token inválido vira **falha isolada** com o motivo no summary final — **nunca** aborta o lote. -Uma área = um dossiê (PDF+MD) + **um** boletim PDF; **sem** índice/PDF agregado. +Uma área = um dossiê (PDF+MD) + **um** boletim PDF + **um** briefing +(PDF+MD); **sem** índice/PDF agregado. Falha de uma área/era **não** cancela as demais (**sucesso parcial** explícito). ## Pipeline (etapas) @@ -108,8 +114,11 @@ Falha de uma área/era **não** cancela as demais (**sucesso parcial** explícit `data/dossie-solla-tema/` (HTML, JSONs, logs). Flags úteis: `--generated-at=`, `DOSSIER_STRICT=1` (falha o run se houver lacuna — conferência, não entrega). + O **briefing de capacitação** (3º entregável) sai do mesmo fluxo — seção + própria abaixo; o build é `scripts/build-dossie-solla-briefing.mjs` + (`--unit=theme`) e o autor é `.opencode/agent/briefing-capacitacao-solla.md`. 7. **Summary final.** Uma linha por entrada, na ordem da lista: `entrada · status - (ok|failed) · dossiê/boletim (quando ok) · motivo (quando falha)`. + (ok|failed) · dossiê/boletim/briefing (quando ok) · motivo (quando falha)`. ## Briefing por era (A/B/C) @@ -322,6 +331,31 @@ nenhum número, data, nome ou órgão novo. - **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem CTA de campanha; herda **apenas** fatos com fonte do dossiê. +## Briefing de capacitação (3º entregável, C210) + +- A mesma invocação entrega o **Briefing de capacitação** do recorte — PDF A4 de + **até 4 páginas** + companion `.md`, para quem vai pedir o voto 1313 — além do + dossiê e do boletim. A fonte canônica do fluxo é a skill + `.agents/skills/briefing-capacitacao-solla/SKILL.md` (não a transcreva). +- Depois de o dossiê estar pesquisado, o orquestrador dispara o **autor** + (`.opencode/agent/briefing-capacitacao-solla.md`) — que escreve + `data/dossie-solla-tema/.briefing.json` só a partir dos itens com fonte; **audita** as + citações e **roda** o mesmo build da família: + ```bash + NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-briefing.mjs \ + --unit=theme \ + --snapshot=data/dossie-solla-tema/.theme.snapshot.json \ + --research-dir=data/dossie-solla-tema \ + --out-dir=docs/research/dossie-solla-tema + ``` + Saídas: `docs/research/dossie-solla-tema/--briefing.pdf` + `-briefing.md` + (gitignored, insumo interno com rótulo literal; nunca publicar). +- Rótulo literal `Insumo interno de capacitação — não publicar` em todas as + folhas; sem CTA público; sem cenário/estimativa/staff-only; só fatos com fonte + do dossiê (**sem segunda pesquisa factual**). Teto rígido de **4 páginas** com + corte por prioridade declarado (o resto fica contado na folha e completo no + `.md`); essencial, roteiro e identificação nunca são cortados. + ## Guardrails de produto (não negociáveis) - **Sem fonte, não publica**: afirmação não trivial sem URL+data vira lacuna. @@ -345,6 +379,7 @@ nenhum número, data, nome ou órgão novo. - **Artefato gitignored** (repo público): PDF/MD/JSON nunca são commitados. - **Defeso**: dossiê e boletim saem como insumo interno/modelo, sem CTA; a peça final publicável é da comunicação/campanha. **Sem schema/migration/DB write.** +- **Briefing de capacitação (3º entregável)**: até **4 páginas**, rótulo literal `Insumo interno de capacitação — não publicar`, sem CTA público, sem cenário/estimativa/staff-only; deriva do dossiê (mesmo build), corte por prioridade declarado e o restante no `.md`. ## Troubleshooting @@ -370,6 +405,9 @@ nenhum número, data, nome ou órgão novo. ## Referências +- Briefing de capacitação (3º entregável): `.agents/skills/briefing-capacitacao-solla/SKILL.md`, `scripts/build-dossie-solla-briefing.mjs` e o design `docs/plans/briefing-capacitacao-solla-ui-design.html` (intenção/impl: `docs/plans/briefing-capacitacao-solla.md`). + + - Intenção: `docs/plans/dossie-solla-tema.md`; impl: `docs/plans/dossie-solla-tema-impl.md`. - Designs hi-fi (fonte de verdade do port): diff --git a/.opencode/agent/briefing-capacitacao-solla.md b/.opencode/agent/briefing-capacitacao-solla.md new file mode 100644 index 00000000..3ec3b72b --- /dev/null +++ b/.opencode/agent/briefing-capacitacao-solla.md @@ -0,0 +1,45 @@ +--- +description: Autor do Briefing de capacitação Solla 1313 — escreve o briefing.json de um recorte (cidade · instituição · tema) somente a partir dos itens com fonte do dossiê e devolve só o recibo curto (etapa do fluxo /briefing-capacitacao-solla) +mode: subagent +--- + +# Briefing de capacitação Solla 1313 — autor (1 recorte) + +Você é a etapa **redação** da skill +`.agents/skills/briefing-capacitacao-solla/SKILL.md` (fonte canônica: contrato do +`briefing.json`, as 4 folhas e as regras editoriais). Leia-a antes de escrever. +Você é invocado **uma vez por recorte**, pelo agente principal, depois de o +dossiê daquele recorte estar pesquisado. + +## Papel + +1. Ler os `data/dossie-solla-/.{a,b,c}.research.json` (`items[]` + com `answer`/`summary`/`brief`/`numbers`/`sphere`/`sourceUrl`/`sourceDate`; + `gaps[]` são lacunas explícitas) e, quando existir, o + `.narrative.json`. +2. Escrever `data/dossie-solla-/.briefing.json` no contrato da + skill: `unitId` + slug do recorte, `generatedAt`, `lede`, `essential` + (≥3, cada item `factId` **ou** `gapReason`), `defenses` (opcional), + `script.steps` (≥3), `qa` (≥4, com os dois lados), `avoid` (≥3) e + `checklist.beforeAnswer`/`unsure` (≥2 cada; marque em `**negrito**` o + prefixo de varredura, ex. `"**Fonte e data** do fato que pretende usar."`). +3. Devolver **apenas o recibo curto** (seção "Recibo do autor" da skill). + +## Limites + +- **Só fatos dos itens com fonte**: cada `factId` precisa ser o `id` de um item + real do research daquele recorte; nunca invente id, número, data, órgão ou + valor. O build **falha fechado** se o `factId` não resolver em fato com fonte. +- Sem lastro **não é resposta**: vira `gapReason` e o texto da resposta assume a + lacuna ("não tenho esse dado confirmado aqui; vou conferir no dossiê"). +- **Nunca** use `região`/`setor`/`segmento`/`rede` como se fosse entrega + exclusiva do recorte; valor sempre com a fase; **empenho ≠ pagamento**. +- **Proibido** qualquer campo de cenário/estimativa/staff-only + (`estimatedVotes`, `scenario`, `projection`, `polls`, …): o briefing é + capacitação, não leitura eleitoral. +- Copy pt-BR, sem PII (telefone/e-mail nunca), sem CTA público, sem promessa de + entrega; o pedido literal do voto 1313 é do renderer — não o reescreva. +- Reuse a voz do `solla-comunicacao` (rebates e fórmula de resposta a crítica), + sem inventar tom novo. +- **Não** faça pesquisa web nova, **não** edite os `research.json`, **não** rode + build/ssh; **não** commite nada (artefato gitignored, repo público). diff --git a/.opencode/commands/briefing-capacitacao-solla.md b/.opencode/commands/briefing-capacitacao-solla.md new file mode 100644 index 00000000..6cedd07e --- /dev/null +++ b/.opencode/commands/briefing-capacitacao-solla.md @@ -0,0 +1,9 @@ +--- +description: Gera o Briefing de capacitação Solla 1313 por recorte (cidade · instituição · tema) — até 4 páginas para quem vai pedir voto — para um recorte ou um lote separado por vírgula +--- + +Carregue a skill `briefing-capacitacao-solla` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia os recortes (`$ARGUMENTS`, prefixados `cidade:`/`instituicao:`/`tema:` e separados por vírgula), resolve os slugs nos catálogos das skills irmãs, dispara um sub-agente autor por recorte (escreve o `briefing.json` só a partir dos itens com fonte), audita as citações, roda o build local offline (4 folhas fixas com âncora de página, teto rígido, resto declarado no PDF e completo no `.md`) e fecha o summary. A fonte canônica é `.agents/skills/briefing-capacitacao-solla/SKILL.md` — não a transcreva nem recrie o fluxo. + +Uso: `/briefing-capacitacao-solla cidade:[, instituicao:, tema:<Área>]` — ex.: `/briefing-capacitacao-solla cidade:Ilheus, instituicao:UFBA`. Um recorte = briefing (`--briefing.pdf` + `-briefing.md`), insumo interno de capacitação. + +$ARGUMENTS diff --git a/.opencode/commands/dossie-solla-cidade.md b/.opencode/commands/dossie-solla-cidade.md index 9573c6ec..6452f610 100644 --- a/.opencode/commands/dossie-solla-cidade.md +++ b/.opencode/commands/dossie-solla-cidade.md @@ -1,9 +1,9 @@ --- -description: Gera o dossiê Solla por cidade (PDF A4 + .md) e o Boletim Informativo modelo de 1 página para um município ou um lote separado por vírgula +description: Gera o dossiê Solla por cidade (PDF A4 + .md), o Boletim Informativo modelo de 1 página e o Briefing de capacitação (até 4 páginas) para um município ou um lote separado por vírgula --- -Carregue a skill `dossie-solla-cidade` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os slugs canônicos, dispara um researcher por era (A/B/C) por cidade, dispara o redator (redação de abertura + parágrafos por era, auditados contra os itens), roda a extração read-only, busca as fontes oficiais e o build local (dossiê + boletim, folhas correntes sem caps) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-cidade/SKILL.md` — não a transcreva nem recrie o fluxo. +Carregue a skill `dossie-solla-cidade` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os slugs canônicos, dispara um researcher por era (A/B/C) por cidade, dispara o redator (redação de abertura + parágrafos por era, auditados contra os itens), roda a extração read-only, busca as fontes oficiais e o build local (dossiê + boletim, folhas correntes sem caps), entrega o briefing de capacitação do recorte (mesma skill/build de briefing; até 4 páginas, insumo interno) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-cidade/SKILL.md` — não a transcreva nem recrie o fluxo. -Uso: `/dossie-solla-cidade [, ...]` — ex.: `/dossie-solla-cidade Ilhéus, Itacaré, Una`. Uma cidade = dossiê (`--dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). +Uso: `/dossie-solla-cidade [, ...]` — ex.: `/dossie-solla-cidade Ilhéus, Itacaré, Una`. Uma cidade = dossiê (`--dossie.pdf`/`.md`) + boletim (`-boletim.pdf`) + briefing (`-briefing.pdf`/`-briefing.md`). $ARGUMENTS diff --git a/.opencode/commands/dossie-solla-instituicao.md b/.opencode/commands/dossie-solla-instituicao.md index 7b9e6f13..4c99e8c2 100644 --- a/.opencode/commands/dossie-solla-instituicao.md +++ b/.opencode/commands/dossie-solla-instituicao.md @@ -1,9 +1,9 @@ --- -description: Gera o dossiê Solla por instituição (PDF A4 + .md) e o Boletim Informativo modelo de 1 página para uma instituição ou um lote separado por vírgula +description: Gera o dossiê Solla por instituição (PDF A4 + .md), o Boletim Informativo modelo de 1 página e o Briefing de capacitação (até 4 páginas) para uma instituição ou um lote separado por vírgula --- -Carregue a skill `dossie-solla-instituicao` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os slugs canônicos no catálogo de instituições, dispara um researcher por era (A/B/C) por instituição, dispara o redator (redação de abertura + parágrafos por era, auditados contra os itens), roda a extração read-only do acervo (por tema→instituição) e o build local (dossiê + boletim, folhas correntes sem caps) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-instituicao/SKILL.md` — não a transcreva nem recrie o fluxo. +Carregue a skill `dossie-solla-instituicao` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os slugs canônicos no catálogo de instituições, dispara um researcher por era (A/B/C) por instituição, dispara o redator (redação de abertura + parágrafos por era, auditados contra os itens), roda a extração read-only do acervo (por tema→instituição) e o build local (dossiê + boletim, folhas correntes sem caps), entrega o briefing de capacitação do recorte (mesma skill/build de briefing; até 4 páginas, insumo interno) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-instituicao/SKILL.md` — não a transcreva nem recrie o fluxo. -Uso: `/dossie-solla-instituicao [, ...]` — ex.: `/dossie-solla-instituicao UFBA, Correios`. Uma instituição = dossiê (`--dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). Token fora do catálogo falha fechado; use o escape one-off `--slug= --name=""`. +Uso: `/dossie-solla-instituicao [, ...]` — ex.: `/dossie-solla-instituicao UFBA, Correios`. Uma instituição = dossiê (`--dossie.pdf`/`.md`) + boletim (`-boletim.pdf`) + briefing (`-briefing.pdf`/`-briefing.md`). Token fora do catálogo falha fechado; use o escape one-off `--slug= --name=""`. $ARGUMENTS diff --git a/.opencode/commands/dossie-solla-tema.md b/.opencode/commands/dossie-solla-tema.md index 00064eb0..484fcd14 100644 --- a/.opencode/commands/dossie-solla-tema.md +++ b/.opencode/commands/dossie-solla-tema.md @@ -1,9 +1,9 @@ --- -description: Gera o dossiê Solla por tema/área (PDF A4 + .md) e o Boletim Informativo modelo de 1 página para uma área ou um lote separado por vírgula +description: Gera o dossiê Solla por tema/área (PDF A4 + .md), o Boletim Informativo modelo de 1 página e o Briefing de capacitação (até 4 páginas) para uma área ou um lote separado por vírgula --- -Carregue a skill `dossie-solla-tema` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os tokens na taxonomia canônica do acervo (`SPEECH_TOPICS`/`resolveSpeechTopic`), dispara um researcher por era (A/B/C) por área, dispara o redator (redação de abertura + parágrafos por era, auditados contra os itens), roda a extração read-only do acervo (por `Speech.topics`) e o build local (dossiê + boletim, folhas correntes sem caps) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-tema/SKILL.md` — não a transcreva nem recrie o fluxo. +Carregue a skill `dossie-solla-tema` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os tokens na taxonomia canônica do acervo (`SPEECH_TOPICS`/`resolveSpeechTopic`), dispara um researcher por era (A/B/C) por área, dispara o redator (redação de abertura + parágrafos por era, auditados contra os itens), roda a extração read-only do acervo (por `Speech.topics`) e o build local (dossiê + boletim, folhas correntes sem caps), entrega o briefing de capacitação do recorte (mesma skill/build de briefing; até 4 páginas, insumo interno) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-tema/SKILL.md` — não a transcreva nem recrie o fluxo. -Uso: `/dossie-solla-tema <Área> [<Área>, ...]` — ex.: `/dossie-solla-tema Educação, Saúde`. Uma área = dossiê (`--dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). Token fora da taxonomia dos 18 temas falha fechado, listando as áreas canônicas; nunca invente slug. +Uso: `/dossie-solla-tema <Área> [<Área>, ...]` — ex.: `/dossie-solla-tema Educação, Saúde`. Uma área = dossiê (`--dossie.pdf`/`.md`) + boletim (`-boletim.pdf`) + briefing (`-briefing.pdf`/`-briefing.md`). Token fora da taxonomia dos 18 temas falha fechado, listando as áreas canônicas; nunca invente slug. $ARGUMENTS diff --git a/docs/changelog/2026-09-22-c210.md b/docs/changelog/2026-09-22-c210.md new file mode 100644 index 00000000..891f08fc --- /dev/null +++ b/docs/changelog/2026-09-22-c210.md @@ -0,0 +1 @@ +**C210 — Briefing de capacitação Solla 1313: até 4 páginas para quem vai pedir voto no recorte (2026-09-22):** a peça que faltava entre o dossiê e quem pede o voto passa a ser o **3º entregável** da família `dossie-solla-{cidade,instituicao,tema}` — dossiê + boletim + **briefing de capacitação** na mesma invocação — e também roda sozinho (`/briefing-capacitacao-solla cidade:Miguel Calmon`, `instituicao:UFBA`, `tema:Educacao`, lote por vírgula). Um recorte = **um PDF A4 de até 4 páginas + companion `.md`**, portado classe-a-classe do design aprovado (`docs/plans/briefing-capacitacao-solla-ui-design.html`): folha 1 essencial com fato-âncora e fonte; folha 2 "o que Solla defende" (C209 quando existir; sem registro = linha de lacuna) + roteiro do pedido com o literal **"vote 1313"**, plano de voto e compromisso nomeado; folha 3 perguntas prováveis × melhores respostas dos dois lados (régua reconhecer → fato verificável → pedido); folha 4 o que evitar + o que conferir + limites/defeso, com o rótulo **"Insumo interno de capacitação — não publicar"** em todas as folhas. Ele **deriva do dossiê, sem segunda pesquisa factual**: o autor (subagente `.opencode/agent/briefing-capacitacao-solla.md`) escreve um `.briefing.json` e o build (`scripts/build-dossie-solla-briefing.mjs`, offline) resolve cada `factId` contra o ledger `bulletinFacts` de `buildDossierReport` — âncora sem fonte **falha fechado**, lacuna vira `gapReason`, chave de cenário/estimativa/staff-only é proibida por deny-list, e a redação é auditada citação por citação pelo orquestrador. O teto de 4 páginas é rígido e provado por três guardas novas em `scripts/lib/buildPdf.mjs`: `assertPageCount` (contagem de `[data-page]` ≤ `maxPages`), fit por folha e `emitHtmlSinglePdf` (emit aditivo de documento único, com `emitHtmlPairPdf` e os 3 builders publicados intocados); no estouro, `trimBriefing` (`scripts/lib/briefingContent.mjs`) corta por prioridade declarada — `qa` até o mínimo de 4 com os dois lados → `defesas` → `conferir` → `evitar` até 3 —, declara o resto na folha ("e mais N no briefing completo (.md)") e o `.md` (`scripts/lib/briefingRender.mjs`) carrega tudo; essencial, roteiro e identificação nunca são cortados, e abaixo do mínimo o build morre apontando a lista a encurtar. Sem schema/migration/DB/rota; artefatos gitignored (`data/dossie-solla-*/`, `docs/research/dossie-solla-*/`); sem CTA público e sem cenário eleitoral (o briefing não promete efeito). As 3 SKILLs irmãs e os 3 commands ganharam o ponteiro do 3º entregável (mesmo build, nunca um segundo pipeline). Verificação: `pnpm gate:fast` verde (389 arquivos / 4284 unit, lint 0 warnings, `tsc --noEmit`), knip limpo (`SCRIPTS_SPEC_PINNED` atualizado com os 2 libs pinados por spec), build real de Miguel Calmon em **PDF de 4 páginas** com o rótulo em todas (conferido por `pdfinfo`/`pdftotext`), recorte tema (`--unit=theme`, Educação) também em 4 páginas, e a prova do corte: fixture de estresse com 12 perguntas sai em 4 páginas com "e mais 6 perguntas" na folha e as 12 no `.md`. Crítica final do `designer` (tier primário `openai/gpt-5.6-sol`): **Certificado** — paridade estrutural e visual confirmada contra o hi-fi aprovado, com 3 ajustes não bloqueantes aplicados (kicker com o nome do recorte nas folhas 2–4, negrito de varredura no checklist, respiro/ênfase na nota do shed). diff --git a/docs/plans/briefing-capacitacao-solla-impl.md b/docs/plans/briefing-capacitacao-solla-impl.md new file mode 100644 index 00000000..ed4cfa9b --- /dev/null +++ b/docs/plans/briefing-capacitacao-solla-impl.md @@ -0,0 +1,215 @@ +# Impl: Briefing de capacitação Solla 1313 — até 4 páginas para quem vai pedir voto no recorte (cidade · instituição · tema) + +Status: aprovado +Atualizado em: 2026-09-22 +Issue: #1252 +Intenção: docs/plans/briefing-capacitacao-solla.md +Appetite restante: ~3–4 dias eng (herdado). Quota concentrada na redação com lastro (subagente autor), no port das 4 folhas do hi-fi e na integração das 3 skills irmãs. C209 é dependência **soft** (sem bloqueio); sem C209 a folha de defesas imprime a linha de lacuna. + +## Leitura da intenção + +- **Outcome:** um **briefing de capacitação por recorte** (cidade · instituição · tema), PDF A4 de **até 4 páginas** + companion `.md`, derivado dos artefatos do dossiê (**sem segunda pesquisa factual**), com "o essencial" (fatos-âncora com fonte), "o que Solla defende" (herdado do C209 quando houver), **roteiro do pedido de voto** ("vote 1313" + plano de voto + compromisso nomeado), **perguntas prováveis × melhores respostas** (direita e esquerda), **o que evitar** e **o que conferir no dossiê**; entregue como **3º entregável** pelas três skills de dossiê e invocável sozinho por recorte. +- **O que NÃO negociar:** rótulo literal `Insumo interno de capacitação — não publicar` em **todas** as folhas; teto de **4 páginas rígido**; sem CTA público/marca publicável/defeso; **sem fonte, o item não entra** (âncora obrigatória); sem cenário eleitoral, `estimatedVotes`, projeção ou qualquer dado staff-only; empenho ≠ pagamento (fase acompanha o valor); esfera explícita nunca somada; leitura relativa/local (nunca % estadual absoluto); PII mínima; artefato **gitignored**; nenhum schema/migration/DB write/rota/componente React; "nunca um segundo pipeline" de pesquisa ou render. +- **O que reavaliar (hipóteses da "Direção no codebase"):** + - "PDF via `emitHtmlPairPdf`" — o dono exige `[data-page="resumo"]` **e** `[data-page="boletim"]` e emite 2 PDFs; o briefing é **documento único**. Saída: **aditiva** no dono (`emitHtmlSinglePdf`), `emitHtmlPairPdf` intocado. + - "o teto de 4 páginas não é expressável hoje" — passa a ser: **esqueleto fixo de 4 folhas** (`data-page` adicionadas no port) + contagem de folhas + fit por folha + **shed determinístico por prioridade** (precedente `onBulletinOverflow`) + fail-closed. + - "subagente" — confirmado: a redação é o passo pesado de contexto e vira **um subagente autor por recorte**, espelhando o redator do dossiê (o `narrative.json` também é escrito por subagente). O orquestrador da skill **coordena, audita e roda o build**. + - O hi-fi usa Tailwind CDN e **não tem `data-page`**; o port troca por CSS inline (padrão da família) e adiciona as âncoras, classe a classe no resto. + - As 3 SKILLs de dossiê são reescritas pelo **C209** na mesma janela; tocar apenas seções pontuais (3º entregável) e rebasar (C210 é P1). + +## Abordagem recomendada + +```mermaid +flowchart LR + A["/briefing-capacitacao-solla cidade:Ilheus, instituicao:UFBA, tema:Saude"] --> B["orquestrador: resolve os recortes
isMunicipalitySlug / resolveInstitutionName / resolveSpeechTopic"] + B --> C["subagente autor (1 por recorte)
lê research + voz solla-comunicacao"] + C --> D["slug.briefing.json
Q&A/roteiro/evitar/conferir só com fato-âncora"] + D --> E["build-dossie-solla-briefing.mjs
normaliza + resolve âncoras contra bulletinFacts"] + F["dossiê já pesquisado
slug.a/b/c.research.json + snapshot"] --> E + E --> G["briefingContent: plano de impressão
+ shed determinístico por prioridade"] + G --> H["briefingRender: 4 folhas data-page + companion .md"] + H --> I["emitHtmlSinglePdf
maxPages=4 · fit por folha · fail-closed"] + I --> J["docs/research/dossie-solla-/slug-YYYY-MM-DD-briefing.pdf/.md"] + K["skills dossie-solla-{cidade,instituicao,tema}
dossiê + boletim + briefing na mesma invocação"] --> A +``` + +**Opções consideradas (forma geral):** (a) skill/command/build novos do briefing consumindo o **engine** do dossiê (`buildDossierReport`, `dossieResearch`, `dossieUnit`, `buildPdf`) | (b) um modo briefing dentro dos 3 builders existentes | (c) um segundo pipeline de pesquisa/render dedicado. +**Recomendação:** (a) — o engine já é unit-aware; o briefing é um **consumidor** do dossiê (ledger `bulletinFacts`), não um quarto recorte. Um dono por concern, custo de reverter baixo (scripts + prosa, nada em produção). A integração nas 3 skills cita a skill e chama **o mesmo build**. +**Rejeitadas:** (b) acopla o teto/emit de documento único ao caminho pinado dos 3 builders publicados (risco de regressão par a par) e multiplica o parse em 3 CLIs; (c) reabre a "sem segunda pesquisa factual" e twin-a as guardas de fonte/esfera/fase. + +### Decisões de engenharia (A–F) + +#### A. Onde mora o pipeline do briefing + +**Opções:** A1) builder novo unit-aware (`scripts/build-dossie-solla-briefing.mjs`) + `scripts/lib/briefingContent.mjs` (contrato/validação/plano de impressão) + `scripts/lib/briefingRender.mjs` (HTML/MD), reusando o engine; A2) modo `--briefing` nos três builders `build-dossie-solla-{cidade,instituicao,tema}.mjs`; A3) pipeline próprio de pesquisa/render do briefing. +**Recomendação:** A1 — script unit-driven (`--unit=municipality|institution|theme`, default `municipality`), **offline** (não busca Câmara/Portal/IBGE; deriva dos `research.json` + snapshot), consumindo `buildDossierReport`/`bulletinFacts`. O fluxo dos dossiês chama **este mesmo script** para o 3º entregável. +**Rejeitadas:** A2 — os builders já fazem rede e emitem par; um modo briefing ali duplica a plumbing de cache/rede em 3 CLIs e toca o caminho com obrigação de byte-parity; A3 — proibido pela intenção ("nunca um segundo pipeline") e twin das guardas de fonte. + +#### B. Forma do conteúdo autoral e onde validar + +**Opções:** B1) contrato novo `.briefing.json` validado em `scripts/lib/briefingContent.mjs`, com âncoras resolvidas contra `report.bulletinFacts` (o ledger "só fato com fonte" que já é dono no dossiê); B2) estender `dossieResearch.mjs` com o contrato do briefing; B3) orquestrador escreve o JSON lendo os `research.json` direto. +**Recomendação:** B1 — arquivo autoral único por recorte, escrito pelo **subagente autor** a partir dos itens com fonte (análogo ao `narrative.json`), validado **fail-closed** no build. Shape mínimo: + +```jsonc +{ + "unitId": "municipality", // municipality | institution | theme + "municipalitySlug": "ilheus", // chave = slugField da unidade (institutionSlug | themeSlug) + "generatedAt": "2026-09-22T12:00:00.000Z", // obrigatório + "subtitle": "Piemonte da Diamantina · consulta antes e durante o contato", // opcional + "lede": "≤380 chars — como usar o essencial", + "essential": [ // ≥3; cada item: factId XOR gapReason + { "factId": "era_b_equipamentos", "title": "≤120", "note": "≤200" }, + { "gapReason": "sem fala própria localizada", "title": "…", "note": "…" } + ], + "defenses": [ { "factId": "…", "title": "…", "note": "…" } ], // C209; [] = linha de lacuna + "script": { "steps": [ { "title": "≤90", "note": "≤220" } ] }, // ≥3 passos; o pedido é literal do renderer + "qa": [ // ≥4, com ≥1 "direita" e ≥1 "esquerda" + { "side": "direita|esquerda|entrega", "question": "≤180", + "acknowledge": "≤200", "answer": "≤520", "close": "≤200", + "factId": "era_b_sesab" /* XOR */ "gapReason": "sem registro localizado" } + ], + "avoid": [ { "title": "≤120", "note": "≤200" } ], // ≥3 + "checklist": { "beforeAnswer": ["≤200"], "unsure": ["≤200"] } // ≥2 em cada +} +``` + +Regras de validação (todas `throw` → o builder `die` com o campo no erro): `unitId` casa com a unidade do build; slug + `generatedAt` presentes; cada item exige **exatamente um** de `factId | gapReason`; `factId` tem de resolver em `bulletinFacts` **com `sourceUrl`** (âncora, não texto novo); `side` no enum; Q&A cobre os dois lados; mínimos por lista. **Deny-list recursiva** de chaves (`estimatedVotes`, `estimated`, `scenario`, `cenario`, `projecao`, `projection`, `staffOnly`, `internalVotes`, `polls`) — o contrato não tem campo numérico; valor/fase vêm do **fato-âncora** no render. Caps de texto são **warning** (`console.warn`); quem falha fechado é o fit guard. Arquivo **ausente** → `die` com o ponteiro da skill (o briefing não tem fallback determinístico, ao contrário do `narrative.json`). +**Rejeitadas:** B2 — mistura dois ciclos de vida (pesquisa por era × derivado autoral de 1 arquivo) e exporia o contrato do briefing aos researcher subagents; B3 — obriga o orquestrador a reter o corpo dos `research.json` (contra a regra explícita da família) e serializa a redação no lote. + +#### C. Teto rígido de ≤4 páginas + +**Opções:** C1) esqueleto fixo de 4 folhas (`data-page="essencial|defesas|qa|evitar"`) + contagem de folhas + fit por folha + **shed determinístico por prioridade** com resto **declarado** no PDF (`.line-meta`: `e mais N no briefing completo (.md)`) e completo no `.md` + fail-closed quando o mínimo não cabe; C2) fail-closed puro (sem shed); C3) teto mole com continuação (modelo dossiê). +**Recomendação:** C1 — o renderer emite **sempre 4 folhas** (sem continuação, por construção); o builder mede (`measurePageOverflows` dentro do `emitHtmlSinglePdf`) e, se uma folha estourar, aplica `trimBriefing(content)` — função **pura e determinística** em `briefingContent.mjs` que remove um item por vez na ordem: `qa` (até o mínimo 4) → `defenses` (até 0) → `checklist` (até 2+2) → `avoid` (até 3) — re-renderiza e mede até estabilizar (teto de tentativas compartilhado `MAX_FIT_ATTEMPTS`, precedente do boletim C190); o QA só perde um item cuja remoção preserva os dois lados. **Essential, roteiro e identificação nunca são cortados**; se nem os mínimos couberem, `die` apontando a lista a encurtar. O resto é declarado na folha (`.line-meta`, estilo já especificado no design) — nunca em silêncio. A guarda de páginas é a proteção real: `.sheet{overflow:hidden}` esconde corte, então **contagem ≤4 + altura por folha + fail-closed** são obrigatórios e testados. +**Rejeitadas:** C2 — transforma cada build em tentativa/erro e ignora o precedente C190 (`onBulletinOverflow`); C3 — o produto decidiu rígido no gate; teto mole destrói o valor ("estudar/consultar em ≤4 páginas"). + +#### D. PDF de documento único + +**Opções:** D1) aditivo no dono: exportar `emitHtmlSinglePdf(browser, { html, pdf, maxPages = 4, requiredAnchors, onOverflow = null })` em `scripts/lib/buildPdf.mjs`, reusando os helpers privados (`openPrintPage`, `measurePageOverflows`, `assertPageFits`, `printA4Pdf`) e mantendo `emitHtmlPairPdf` **intocado**; D2) generalizar `emitHtmlPairPdf` para `documents: []` (1..N); D3) exportar `printA4Pdf` e montar a guarda no builder do briefing. +**Recomendação:** D1 — superfície mínima, um só dono do mm→px/emit, zero mudança no caminho dos 3 builders publicados. O emit único: exige todas as `requiredAnchors`, conta `[data-page]` (guarda pura `assertPageCount` ≤ `maxPages`), mede overflows, chama `onOverflow` (rebuild) até estabilizar, e imprime. O **shed** fica no builder (que tem o conteúdo), não no emitter. +**Rejeitadas:** D2 — mexe no contrato emitido dos 3 builders por um ganho que não existe hoje (generalização especulativa); D3 — twin do loop/guarda fora do dono. + +#### E. Companion `.md` + +**Opções:** E1) `renderBriefingMd(content, { facts, unit, report })` derivado do **mesmo conteúdo normalizado** (+ ledger), carregando **tudo** (inclusive o que o PDF cortou), com `[fonte](url)` por fato-âncora; E2) derivar do HTML; E3) só PDF. +**Recomendação:** E1 — título + recorte + rótulo literal + seções espelhando as 4 folhas + roteiro com o literal "vote 1313" + Q&A em prosa com link de fonte + evitar/conferir + "Limites e defeso" + data de geração. O `.md` é a versão de referência (o PDF é a de campo). +**Rejeitadas:** E2 — parse de HTML é frágil, perde links/fonte; E3 — decisão do gate ("companion sim"). + +#### F. Integração nas 3 skills + command/registry + +**Opções:** F1) **uma** skill/command `briefing-capacitacao-solla`, com seletor literal de recorte (`cidade:` · `instituicao:` · `tema:`, lote por vírgula reusando os resolvedores das irmãs); as 3 SKILLs de dossiê citam a skill e ganham o 3º entregável; F2) três skills gêmeas (uma por recorte); F3) embutir o briefing nos fluxos existentes sem skill própria. +**Recomendação:** F1 — a skill é a fonte canônica do fluxo (parse → autor → auditoria → build → summary); as SKILLs irmãs só **citam** e listam o 3º entregável. Mudam nos 3 SKILLs: `description:`, "Quando usar", "Lote" (uma entrada = dossiê + boletim + briefing), saídas do passo 6 (`-briefing.pdf` + `-briefing.md`), summary do passo 7, guardrails do briefing (rótulo, ≤4 páginas, insumo interno, sem staff-only, sem segunda pesquisa) e Referências (skill + builder). Atualizar a `description:` dos 3 commands (texto de TUI). +**Rejeitadas:** F2 — triplica prosa para o mesmo pipeline (drift garantido); F3 — a intenção decidiu skill única invocável sozinha. + +### Componentes / mudanças + +**Skill / command / subagente (agêntico)** + +- **`.agents/skills/briefing-capacitacao-solla/SKILL.md`** (novo) — fonte canônica. Seções: `Quando usar`, `Lote (recortes)`, `Pipeline (etapas)`, `Recibo do autor`, `Contrato do briefing.json`, `Conteúdo (4 folhas)`, `Guardrails`, `Troubleshooting`, `Referências`. Cita o hi-fi aprovado como fonte de verdade do port e a voz (`solla-comunicacao`). +- **`.opencode/commands/briefing-capacitacao-solla.md`** (novo) — frontmatter só `description:` (sem `model:`); corpo cita a skill pelo nome exato, `$ARGUMENTS` e `.agents/skills/briefing-capacitacao-solla/SKILL.md`. +- **`.opencode/agent/briefing-capacitacao-solla.md`** (novo) — `mode: subagent`, **sem pin openai**: lê os `research.json`/`narrative.json` do recorte + a voz, escreve `/.briefing.json` e devolve só o recibo. Proibido: pesquisa web nova, editar research, rodar build/ssh, commitar. +- **`.agents/skills/dossie-solla-{cidade,instituicao,tema}/SKILL.md`** (editar, pontual) — 3º entregável conforme F1. +- **`.opencode/commands/dossie-solla-{cidade,instituicao,tema}.md`** (editar, `description:` + 1 frase) — citar o briefing. + +**Build (scripts, sem app)** + +- **`scripts/build-dossie-solla-briefing.mjs`** (novo) — unit-aware, **offline**: lê snapshot + `.{a,b,c}.research.json` (mesmos asserts/ENOENT→lacuna dos irmãos), monta o report com `buildDossierReport({ snapshot, research, generatedAt, unit })` (sem `emendas`/`camara`/`health`), lê o `.briefing.json`, normaliza/valida, renderiza, mede/ajusta o plano de impressão e emite **1 PDF** + `.md`. Flags: `--unit`, `--snapshot`, `--research-dir`, `--out-dir`, `--generated-at`. Saídas: `/--briefing.pdf`/`.md` e cache `/.briefing.html` (dirs já gitignored). +- **`scripts/lib/briefingContent.mjs`** (novo) — `normalizeBriefingContent(raw, { unit, facts })` (fail-closed + warnings), `briefingAnchorFact(facts, factId)`, `trimBriefing(content)` (ordem/mínimos do C; o QA preserva os dois lados), `briefingSubjectName(report, content)` (nome do recorte, em `briefingRender.mjs`). Puro. +- **`scripts/lib/briefingRender.mjs`** (novo) — `renderBriefingHtml(content, { unit, report })` (4 `
`, literais de renderer: rótulo interno, "Como usar", "Por que assim", "Régua visível", linha do pedido com `vote 1313`, limites/defeso, rodapés `folha N/4`; CSS inline portado classe a classe do hi-fi, sem Tailwind) e `renderBriefingMd(content, { unit, report })`. Reusa `htmlEscape`/`htmlWithLinks` (`reportText.mjs`). +- **`scripts/lib/buildPdf.mjs`** (editar, **aditivo**) — `emitHtmlSinglePdf` + guarda pura `assertPageCount`; `emitHtmlPairPdf` e demais exports intocados. +- **`scripts/lib/dossieUnit.mjs`** (editar, **aditivo**) — `snapshotField: 'municipality'` em `MUNICIPALITY_UNIT` (hoje só `subject` lê o campo); `briefingNoun` (`cidade|instituição|tema`) e `outDir` nos 3 descritores; nenhum leitor existente muda e os 3 builders não são refatorados. + +**Testes (unit, `tests/unit/`)** + +- `briefingContent.unit.spec.ts`, `briefingRender.unit.spec.ts`, `briefingSkill.unit.spec.ts` (novos); `buildPdf.unit.spec.ts` (aditivo: export + `assertPageCount`); `opencodeCommands.unit.spec.ts` (adicionar `'briefing-capacitacao-solla'` ao array); `dossieBatchSkill.unit.spec.ts` + `dossieInstitutionBatchSkill.unit.spec.ts` + `dossieThemeBatchSkill.unit.spec.ts` (aditivo: 3º entregável). +- **Migration:** sem migration (nenhum schema/DB). +- **Access / Consent:** n/a (nenhuma collection/gravação; PII mínima por contrato). +- **UI:** Impeccable **C**; o hi-fi `docs/plans/briefing-capacitacao-solla-ui-design.html` já passou o gate (design aprovado) — implementação é **port classe a classe** para CSS inline, sem shell React (o "UI" é o HTML de impressão). O resto declarado do shed reusa `.line-meta` (estilo já especificado) — non-trigger; qualquer outra superfície ausente aciona o `designer`. + +### Dados → forma (pergunta 3) + +- **Forma escolhida:** listas de linhas curtas com **fonte por âncora** (essencial/defesas), um **bloco literal de pedido** em destaque (`.request-line` com "vote 1313"), **Q&A em duas colunas** (pergunta | reconhecer→fato→fechar) e listas de evitar/conferir; **nenhum gráfico, placar ou tabela numérica**. É a forma mais pobre que ainda desbloqueia a decisão da persona (o que citar, como responder, o que evitar), cabe em 4 folhas e não convida a somar esfera nem a expor cenário. +- **Rejeitadas:** dashboard/KPIs (perde o lastro item a item e expõe leitura absoluta); tabela de números (briefing é fala, não planilha; estoura o teto); prosa longa (não se consulta em campo); qualquer forma com número sem fase/fonte (anti-goal). + +## Fases verificáveis + +1. **Tracer — contrato + conteúdo + render, sem PDF (meio dia).** `briefingContent.mjs` + `briefingRender.mjs` + specs com fixture derivada de `normalizeDossierResearchInput`/`buildDossierReport` (padrão do `dossieRender.unit.spec.ts`): 4 âncoras, rótulo literal ×4, "vote 1313", link de fonte por Q&A ancorada, MD superset com budget reduzido, deny-list barrando staff-only. Prova o encadeamento mais arriscado (âncora + teto) cedo, sem Chromium. +2. **Emit único + builder.** `emitHtmlSinglePdf` + `assertPageCount` aditivos + teste; `build-dossie-solla-briefing.mjs` com o loop medido de shed; rodar em **1 recorte** com fixture `briefing.json` (dados gitignored), conferindo PDF de 4 páginas, rótulo em todas e `.md` completo. +3. **Superfície agêntica.** `SKILL.md` + command + subagente autor + `briefingSkill.unit.spec.ts` + registry (`opencodeCommands.unit.spec.ts`). +4. **Integração nas 3 skills + contrato dos irmãos.** Editar as 3 SKILLs e as 3 `description:` de command; adicionar o `it` do 3º entregável nos 3 specs de lote; conferir o gitignore com `git check-ignore -v` nos dirs reusados; `docs/changelog/2026-09-22-c210.md`. +5. **Gates.** `pnpm gate:fast` (lint 0 warnings, `tsc --noEmit`, unit) → build real de 1 recorte (≤4 páginas, rótulo, fontes no `.md`) → `pnpm push` (o PR CI roda knip — exports em ERROR —, cycles, int, build e o e2e curado). + +### Testes previstos por camada + +- **Unit:** `briefingContent` (validação fail-closed, âncoras, ordem/mínimos do shed, deny-list, resto declarado), `briefingRender` (âncoras, literais, fontes, escape, MD superset), `briefingSkill` (skill/agent/command: seções, literais `Insumo interno de capacitação — não publicar`/`vote 1313`/`4 páginas`, `mode: subagent`, sem `model:`), `buildPdf` (aditivo `emitHtmlSinglePdf` + `assertPageCount`), `opencodeCommands` (novo comando no array), 3 `*BatchSkill` (3º entregável + skill citada). Nenhum export novo sem consumidor (knip). +- **Int:** nenhum — não há fronteira Payload/DB (scripts puros + leitura de JSON; `tests/int/*` intocados). +- **E2E:** nenhum — não há superfície de runtime no app; o PDF é artefato de script e a verificação é o build real (aceite manual), como no C186. + +## Rabbit holes / Não escopo (engenharia) + +- Segundo pipeline de pesquisa/render; qualquer fetch de rede no build do briefing (Câmara/Portal/IBGE/SIOPS); forkar `dossieResearch`/`buildDossierReport`/`dossieRender`. +- Editar `emitHtmlPairPdf`/`dossieRender`/os 3 builders para "reusar" o briefing; tocar a saída byte a byte dos artefatos publicados. +- "Teto mole", continuação, pack grow/shrink no briefing (o produto quer 4 folhas fixas e curtas). +- Gerar o conteúdo autoral dentro do build determinístico (LLM no script); template automático de Q&A sem fato-âncora. +- Abrir o briefing para cenário/estimativa/projeção/staff-only ("só um numerinho") ou CTA/marca publicável ("aproveita e vira peça"). +- Schema/migration/collection/rota/componente React/índice agregado de lote. +- Reescrever as seções de conteúdo das 3 SKILLs (isso é C209); aqui só o ponteiro do 3º entregável. + +## Riscos e mitigação + +- **Regressão dos 3 builders/dossiê por tocar `buildPdf`/`dossieUnit`:** mudanças **aditivas** com rejeitadas registradas; `emitHtmlPairPdf` e renderers intocados; specs existentes verdes no `gate:fast`/PR CI. +- **Corte escondido pelo `.sheet{overflow:hidden}`:** a guarda mede **toda** folha e **conta** as folhas (`maxPages=4`); qualquer estouro vira shed ou `die` — nada de sair cortado. +- **Conteúdo autoral sem lastro:** âncora obrigatória resolvida contra `bulletinFacts` (fonte garantida), `gapReason` explícito para lacuna, deny-list de staff-only/cenário, auditoria de citações do orquestrador, voz com dono único (`solla-comunicacao`, sem gemar). +- **Teto apertado com conteúdo real:** shed determinístico + resto declarado no PDF e completo no `.md`; `die` orientando a lista a encurtar quando o mínimo não cabe; caps de autor são warnings. +- **C209 ausente/atrasado (soft):** `defenses` é opcional e, sem itens, a folha imprime a linha de lacuna ("sem registro localizado"); C210 não bloqueia nem é bloqueado. +- **Vazamento de artefato no repo público:** dirs já gitignored (`/data/dossie-solla-*/`, `/docs/research/dossie-solla-*/`); conferir com `git check-ignore -v`; subagente proibido de commitar; contrato sem PII. +- **Ambiguidade no comando standalone (ex.: `cidade:Salvador`):** seletor de recorte literal + resolvedores das irmãs; token inválido/ambíguo falha fechado com o motivo no summary, sem inventar slug. +- **Drift entre a prosa da skill e o validador:** `briefingSkill.unit.spec.ts` pina literais/regras (rótulo, "vote 1313", 4 páginas, campos do contrato, ordem do shed). +- **Rebase com C209 nas 3 SKILLs:** C210 é P1 e toca só seções pontuais (saídas/summary/guardrails/referências); resolver conflito preservando o 3º entregável. + +## Adiado com gatilho (triage do simplify, 2026-09-22) + +- **Consumo do C209 (fronteira `defenses`)**: hoje o autor redige as defesas a + partir dos `research.json`; quando o C209 (`#1251`) mergear/exposer o artefato + da dimensão "o que defende", o briefing consome o dono em vez de re-redigir. + — **Gatilho:** C209 mergeado com o artefato de defesas. +- **Contagem real de páginas do PDF**: `assertPageCount` conta as folhas + `[data-page]`; o 1:1 folha↔página é por construção (`.sheet{break-after:page}` + - A4) e a contagem real (4) foi conferida com `pdfinfo` no build. — **Gatilho:** + build real com `pdfinfo` ≠ nº de folhas, ou mudança no CSS de impressão/Chromium. +- **Helper de parse compartilhado dos builders**: o 4º builder repete o padrão + `readJson`/`readEraResearch` dos 3 irmãos (convenção da família). — **Gatilho:** + 5º consumidor do parse, ou primeira correção de parse a aplicar em ≥2 builders. +- **Spec do builder por unit**: o seam `themeSlug` do contrato é testado; o + builder `--unit=theme` foi provado por build real (aceite manual, como no + C186). — **Gatilho:** primeira alteração em `build-dossie-solla-briefing.mjs` + ou relato de quebra por `--unit`. + +## Explicitamente fora (triage do simplify, 2026-09-22) + +- **Unificar a prosa do 3º entregável nas 3 SKILLs irmãs**: cada skill é lida por + si; a extração criaria acoplamento pior que a repetição (decisão registrada). +- **Numerar o `ol.qa-list`**: `list-style: none` é o markup do hi-fi aprovado no + gate de design; numeração não é requisito. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: briefing por recorte (cidade · instituição · tema), PDF A4 **≤4 páginas** + `.md`, com identificação, essencial com fonte, "o que defende" (C209 ou lacuna), roteiro ("vote 1313" + plano + compromisso nomeado), Q&A dos dois lados, evitar e conferir. +- [ ] Integração literal: as 3 SKILLs citam `briefing-capacitacao-solla` e entregam **dossiê + boletim + briefing** na mesma invocação; skill/command invocáveis sozinhos por recorte; mesmo build (sem fork). +- [ ] Rótulo `Insumo interno de capacitação — não publicar` em todas as folhas; sem CTA/marca publicável; artefato gitignored. +- [ ] Guardrails: sem fonte o item não entra (âncora); empenho ≠ pagamento (fase do fato-âncora); esfera explícita nunca somada; leitura relativa; **sem cenário/estimativa/staff-only**; PII mínima. +- [ ] Teto rígido com prova: contagem de folhas ≤4 + fit por folha + shed determinístico (ordem/minimos testados) + resto declarado + fail-closed; `.md` superset. +- [ ] Um dono por concern: `buildDossierReport`/`bulletinFacts` (fatos), `dossieResearch` (pesquisa), `dossieUnit` (vocabulário), `buildPdf` (emit), `reportText` (escape/links), `solla-comunicacao` (voz); nenhum twin; 3 builders/renderers intocados. +- [ ] Sem schema/migration/DB write/rota/componente React; build offline (sem segunda pesquisa factual); nenhum dado staff-only renderizado. +- [ ] Identificadores em inglês; português só em literais visíveis/labels; C210 literais verbatim. +- [ ] Testes unit novos verdes (`briefingContent`, `briefingRender`, `briefingSkill`, `buildPdf` aditivo, `opencodeCommands`, 3 `*BatchSkill`); int/e2e justificadamente ausentes. +- [ ] `pnpm gate:fast` verde; build real aceito em 1 recorte; `pnpm push`; changelog `docs/changelog/2026-09-22-c210.md`. + +### Self-score (decision-quality, gate ≥4/5) + +1. **Decisões caras têm rejeitadas?** A–F com Opções/Recomendação/Rejeitadas explícitas (pipeline, contrato autoral, teto, emit único, companion, integração). — **5/5** +2. **Abordagem cabe no appetite (3–4 dias)?** Sim: scripts + prosa + unit, sem schema/rede, tracer no dia 1 e reuso máximo do engine; a quota está na redação com lastro e no port das 4 folhas. — **5/5** +3. **Rabbit holes nomeados?** Sim: segundo pipeline, fork do emit/render, teto mole, conteúdo inventado no build, cenário/staff-only, schema/rota, reescrita C209. — **5/5** +4. **Depth check: reusa shell/helpers existentes?** Sim — `buildDossierReport`/`bulletinFacts`, `dossieResearch`, `dossieUnit`, `buildPdf`, `reportText`, `cli`, resolvedores das 3 skills, voz `solla-comunicacao`; o que é novo tem responsabilidade própria (contrato autoral, plano de impressão, render do briefing). — **4/5** (o único ponto de atenção é o aditivo em `buildPdf`/`dossieUnit`, coberto por specs e pela regra "não tocar os builders"). +5. **Intenção (aceite de produto) permanece satisfeita?** Sim — ≤4 páginas, 3º entregável nas 3 skills, rótulo literal, Q&A dos dois lados, sem cenário/staff-only, derivado do dossiê; a engenharia não reescreveu o outcome. — **5/5** + +**Total 24/25 (média 4,8/5) — passa o gate ≥4/5.** diff --git a/scripts/build-dossie-solla-briefing.mjs b/scripts/build-dossie-solla-briefing.mjs new file mode 100644 index 00000000..1b6711c9 --- /dev/null +++ b/scripts/build-dossie-solla-briefing.mjs @@ -0,0 +1,180 @@ +/** + * Briefing de capacitação builder (C210): snapshot + per-era research + the + * authored `.briefing.json` → one internal A4 PDF (fixed four sheets) and + * the `.md` companion. + * + * Runs on the workstation, offline: no database access, no official-source + * fetch — the briefing derives from the dossiê JSONs already researched, and + * every anchor must resolve in the `bulletinFacts` ledger (facts with source). + * The four-page cap is a hard guard: on overflow the lowest-priority lists shed + * by priority (declared on the sheet, complete in the `.md`), never silently. + * + * Usage: + * NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-briefing.mjs \ + * --unit=municipality \ + * --snapshot=data/dossie-solla-cidade/.snapshot.json \ + * --research-dir=data/dossie-solla-cidade \ + * --out-dir=docs/research/dossie-solla-cidade + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + BRIEFING_ANCHORS, + BRIEFING_PAGE_TOTAL, + normalizeBriefingContent, + trimBriefing, +} from './lib/briefingContent.mjs' +import { briefingSubjectName, renderBriefingHtml, renderBriefingMd } from './lib/briefingRender.mjs' +import { emitHtmlSinglePdf, launchPdfBrowser } from './lib/buildPdf.mjs' +import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs' +import { buildDossierReport } from './lib/dossieBlocks.mjs' +import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs' +import { mergeDossierResearch, normalizeDossierResearchInput } from './lib/dossieResearch.mjs' +import { resolveDossierUnit } from './lib/dossieUnit.mjs' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const LABEL = 'build-dossie-solla-briefing' +const die = dieWithLabel(LABEL) + +const { flags } = parseEqualsFlags(process.argv.slice(2)) +const unitId = typeof flags.unit === 'string' ? flags.unit : 'municipality' +let unit +try { + unit = resolveDossierUnit(unitId) +} catch (error) { + die(error instanceof Error ? error.message : String(error)) +} +const snapshotPath = typeof flags.snapshot === 'string' ? flags.snapshot : null +const researchDir = + typeof flags['research-dir'] === 'string' ? flags['research-dir'] : unit.researchDir +const outDir = typeof flags['out-dir'] === 'string' ? flags['out-dir'] : unit.outDir +const generatedAt = + typeof flags['generated-at'] === 'string' ? new Date(flags['generated-at']) : new Date() + +if (!snapshotPath) { + die( + 'Uso: --snapshot= [--unit=municipality|institution|theme] [--research-dir=] [--out-dir=] [--generated-at=]', + ) +} +if (Number.isNaN(generatedAt.getTime())) die(`--generated-at inválida: ${flags['generated-at']}`) + +const readJson = async (relativePath, label) => { + try { + return JSON.parse(await readFile(resolve(ROOT, relativePath), 'utf8')) + } catch (error) { + die( + `Falha ao ler ${label} (${relativePath}): ${error instanceof Error ? error.message : error}`, + ) + } +} + +const snapshot = await readJson(snapshotPath, 'snapshot') +const slug = snapshot[unit.snapshotField]?.slug +if (!slug) die(`Snapshot sem "${unit.snapshotField}.slug" — extração inválida.`) + +const readEraResearch = async (era) => { + const path = join(researchDir, `${slug}.${era.toLowerCase()}.research.json`) + try { + const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8')) + if (raw[unit.slugField] !== slug) { + die( + `Pesquisa da Era ${era} é de "${raw[unit.slugField]}", não de "${slug}" (${path}) — pare e regenere.`, + ) + } + if (raw.era !== era) { + die(`Pesquisa em ${path} declara Era ${raw.era}, não Era ${era} — pare e regenere.`) + } + return normalizeDossierResearchInput(raw, { unit }) + } catch (error) { + if (error?.code === 'ENOENT') { + console.log(`[${LABEL}] Era ${era}: sem pesquisa (${path}) — vira lacuna explícita.`) + return normalizeDossierResearchInput( + { [unit.slugField]: slug, era, researchedAt: generatedAt.toISOString(), items: [] }, + { unit }, + ) + } + die( + `Pesquisa da Era ${era} inválida (${path}): ${error instanceof Error ? error.message : error}`, + ) + } +} + +const researches = [] +for (const era of DOSSIER_ERA_IDS) researches.push(await readEraResearch(era)) +const research = mergeDossierResearch(researches, { unit }) + +const report = buildDossierReport({ snapshot, research, generatedAt, unit }) +const facts = report.bulletinFacts +const sourcedFactIds = new Set( + facts.filter((fact) => Boolean(fact.sourceUrl) && !fact.sourcePanel).map((fact) => fact.id), +) + +const readBriefing = async () => { + const path = join(researchDir, `${slug}.briefing.json`) + try { + return JSON.parse(await readFile(resolve(ROOT, path), 'utf8')) + } catch (error) { + if (error?.code === 'ENOENT') { + die( + `Briefing autoral ausente (${path}) — rode a etapa de redação da skill briefing-capacitacao-solla e tente de novo.`, + ) + } + die(`Briefing inválido (${path}): ${error instanceof Error ? error.message : error}`) + } +} + +const rawBriefing = await readBriefing() +const content = normalizeBriefingContent(rawBriefing, { unit, slug, facts }) +for (const warning of content.warnings) console.warn(`[${LABEL}] AVISO: ${warning}`) + +const baseName = `${slug}-${generatedAt.toISOString().slice(0, 10)}` +const briefingPdfFile = join(outDir, `${baseName}-briefing.pdf`) +const briefingMdFile = join(outDir, `${baseName}-briefing.md`) +const htmlCacheFile = join(researchDir, `${baseName}.briefing.html`) + +const briefingMd = renderBriefingMd(content, { unit, report }) + +let printContent = content +let briefingHtml = renderBriefingHtml(printContent, { unit, report }) +const browser = await launchPdfBrowser() +try { + await emitHtmlSinglePdf(browser, { + html: briefingHtml, + pdf: resolve(ROOT, briefingPdfFile), + maxPages: BRIEFING_PAGE_TOTAL, + requiredAnchors: BRIEFING_ANCHORS, + label: 'O briefing', + // Four fixed sheets: on overflow the lowest-priority list sheds one item + // per pass (qa → defesas → conferir → evitar) until it fits; the remainder + // stays counted on the sheet and complete in the `.md`. + onOverflow: () => { + const trimmed = trimBriefing(printContent) + if (!trimmed) return null + printContent = trimmed + briefingHtml = renderBriefingHtml(printContent, { unit, report }) + return briefingHtml + }, + }) +} catch (error) { + die(error instanceof Error ? error.message : String(error)) +} finally { + await browser.close() +} + +await mkdir(resolve(ROOT, researchDir), { recursive: true }) +await writeFile(resolve(ROOT, htmlCacheFile), briefingHtml) + +await mkdir(resolve(ROOT, outDir), { recursive: true }) +await writeFile(resolve(ROOT, briefingMdFile), briefingMd) +console.log(`[${LABEL}] companion → ${briefingMdFile}`) +console.log(`[${LABEL}] PDF → ${briefingPdfFile}`) + +const shedTotal = Object.values(printContent.shed).reduce((total, count) => total + count, 0) +console.log( + `[${LABEL}] ${briefingSubjectName(report, content)}: fatos_com_fonte=${sourcedFactIds.size} ` + + `essencial=${printContent.essential.length} qa=${printContent.qa.length} defesas=${printContent.defenses.length} ` + + `folhas=${BRIEFING_PAGE_TOTAL} shed=${shedTotal} html_bytes=${Buffer.byteLength(briefingHtml)}`, +) diff --git a/scripts/lib/briefingContent.mjs b/scripts/lib/briefingContent.mjs new file mode 100644 index 00000000..62b7060c --- /dev/null +++ b/scripts/lib/briefingContent.mjs @@ -0,0 +1,380 @@ +/** + * Briefing de capacitação — content contract and print budget (C210, owner: the + * briefing build). + * + * The briefing is an internal training handout derived from the dossiê: the + * authored `.briefing.json` may only point at facts whose source the + * dossiê ledger already carries (`buildDossierReport().bulletinFacts`), never at + * new research. This module owns the fail-closed validation, the deny-list that + * keeps staff-only/scenario keys out and the deterministic shed order that keeps + * the four-page cap honest: the PDF prints at most four fixed sheets, so on + * overflow the lowest-priority lists lose items (declared, never silent) and + * everything stays in the companion `.md`. + */ + +export const BRIEFING_LABEL = 'Insumo interno de capacitação — não publicar' +export const BRIEFING_PAGE_TOTAL = 4 +export const BRIEFING_ANCHORS = ['essencial', 'defesas', 'qa', 'evitar'] +export const BRIEFING_QA_SIDES = ['direita', 'esquerda', 'entrega'] +/** Sides the briefing must always print at least once (C210 guardrail). */ +const REQUIRED_QA_SIDES = ['direita', 'esquerda'] + +export const BRIEFING_MINIMUMS = { + essential: 3, + scriptSteps: 3, + qa: 4, + avoid: 3, + beforeAnswer: 2, + unsure: 2, +} + +/** Staff-only electoral scenarios never enter the handout (intention guardrail). */ +const FORBIDDEN_KEYS = [ + 'estimatedVotes', + 'estimated', + 'scenario', + 'cenario', + 'cenarios', + 'projection', + 'projecao', + 'staffOnly', + 'internalVotes', + 'polls', +] + +/** Author-facing caps: reported as warnings — the page-fit guard is the hard limit. */ +const TEXT_CAPS = { + lede: 380, + subtitle: 160, + essentialTitle: 120, + essentialNote: 200, + defenseTitle: 120, + defenseNote: 200, + scriptTitle: 90, + scriptNote: 220, + qaQuestion: 180, + qaAcknowledge: 200, + qaAnswer: 520, + qaClose: 200, + avoidTitle: 120, + avoidNote: 200, + checklistItem: 200, +} + +const isNonEmptyString = (value) => typeof value === 'string' && value.trim() !== '' + +const fail = (message) => { + throw new Error(message) +} + +const assertKey = (raw, key, label) => { + if (!isNonEmptyString(raw?.[key])) fail(`${label}: campo "${key}" obrigatório e não vazio.`) +} + +const assertNoForbiddenKeys = (value, label) => { + if (Array.isArray(value)) { + for (const entry of value) assertNoForbiddenKeys(entry, label) + return + } + if (!value || typeof value !== 'object') return + for (const [key, entry] of Object.entries(value)) { + if (FORBIDDEN_KEYS.includes(key)) { + fail(`${label}: chave "${key}" proibida — cenário/estimativa é staff-only, fora do briefing.`) + } + assertNoForbiddenKeys(entry, label) + } +} + +const assertList = (value, label) => { + if (!Array.isArray(value)) fail(`${label}: lista obrigatória.`) +} + +const assertMinimum = (list, minimum, label) => { + if (list.length < minimum) { + fail(`${label}: mínimo de ${minimum} item(ns) — recebidos ${list.length}.`) + } +} + +const warnIfOver = (warnings, text, max, label) => { + if (typeof text === 'string' && text.length > max) { + warnings.push(`${label}: ${text.length} chars (máx. ${max}) — encurte para caber na folha.`) + } +} + +/** + * The ledger fact the anchor points at (the `bulletinFacts` contract). The + * acervo sample (`sourcePanel`) is a declared panel, not a finding — the + * bulletin already refuses it a printed slot, so the briefing refuses it an + * anchor: an "essencial" item is never a speech sample. + * + * @param {any[]} facts + * @param {string} factId + * @returns {any|null} + */ +export const briefingAnchorFact = (facts, factId) => + (Array.isArray(facts) ? facts : []).find((fact) => fact?.id === factId && !fact.sourcePanel) ?? + null + +/** + * The anchor rule of every item: exactly one of `factId | gapReason`, and a + * `factId` only counts when it resolves in a sourced, non-panel ledger fact. + * Returns the resolved anchor; `normalizeAnchor` adds the copy validation. + */ +const resolveAnchor = (item, { facts, label }) => { + const hasFact = isNonEmptyString(item.factId) + const hasGap = isNonEmptyString(item.gapReason) + if (hasFact === hasGap) { + fail(`${label}: exige exatamente um de "factId" | "gapReason".`) + } + if (hasGap) return { factId: null, gapReason: item.gapReason.trim(), fact: null } + const fact = briefingAnchorFact(facts, item.factId) + if (!fact?.sourceUrl) { + fail( + `${label}: factId "${item.factId}" não resolve em fato com fonte do dossiê — sem fonte, não entra.`, + ) + } + return { factId: fact.id, gapReason: null, fact } +} + +const normalizeAnchor = ( + item, + { facts, label, warnings, titleKey, noteKey, titleCap, noteCap }, +) => { + assertKey(item, titleKey, label) + assertKey(item, noteKey, label) + warnIfOver(warnings, item[titleKey], titleCap, `${label}.${titleKey}`) + warnIfOver(warnings, item[noteKey], noteCap, `${label}.${noteKey}`) + return { + title: item[titleKey].trim(), + note: item[noteKey].trim(), + ...resolveAnchor(item, { facts, label }), + } +} + +const normalizeEssential = (raw, options) => + (raw.essential ?? []).map((item, index) => + normalizeAnchor(item, { + ...options, + label: `essential[${index}]`, + titleKey: 'title', + noteKey: 'note', + titleCap: TEXT_CAPS.essentialTitle, + noteCap: TEXT_CAPS.essentialNote, + }), + ) + +const normalizeDefenses = (raw, options) => + (raw.defenses ?? []).map((item, index) => + normalizeAnchor(item, { + ...options, + label: `defenses[${index}]`, + titleKey: 'title', + noteKey: 'note', + titleCap: TEXT_CAPS.defenseTitle, + noteCap: TEXT_CAPS.defenseNote, + }), + ) + +const normalizeScript = (raw, options) => { + const steps = raw.script?.steps ?? [] + assertList(steps, 'script.steps') + assertMinimum(steps, BRIEFING_MINIMUMS.scriptSteps, 'script.steps') + return { + steps: steps.map((step, index) => { + assertKey(step, 'title', `script.steps[${index}]`) + assertKey(step, 'note', `script.steps[${index}]`) + warnIfOver( + options.warnings, + step.title, + TEXT_CAPS.scriptTitle, + `script.steps[${index}].title`, + ) + warnIfOver(options.warnings, step.note, TEXT_CAPS.scriptNote, `script.steps[${index}].note`) + return { title: step.title.trim(), note: step.note.trim() } + }), + } +} + +const normalizeQa = (raw, options) => { + const qa = raw.qa ?? [] + assertList(qa, 'qa') + assertMinimum(qa, BRIEFING_MINIMUMS.qa, 'qa') + const items = qa.map((item, index) => { + const label = `qa[${index}]` + if (!BRIEFING_QA_SIDES.includes(item?.side)) { + fail(`${label}: side inválido — use ${BRIEFING_QA_SIDES.join(' | ')}.`) + } + for (const key of ['question', 'acknowledge', 'answer', 'close']) { + assertKey(item, key, label) + } + warnIfOver(options.warnings, item.question, TEXT_CAPS.qaQuestion, `${label}.question`) + warnIfOver(options.warnings, item.acknowledge, TEXT_CAPS.qaAcknowledge, `${label}.acknowledge`) + warnIfOver(options.warnings, item.answer, TEXT_CAPS.qaAnswer, `${label}.answer`) + warnIfOver(options.warnings, item.close, TEXT_CAPS.qaClose, `${label}.close`) + const anchor = resolveAnchor( + { factId: item.factId, gapReason: item.gapReason }, + { facts: options.facts, label }, + ) + return { + side: item.side, + question: item.question.trim(), + acknowledge: item.acknowledge.trim(), + answer: item.answer.trim(), + close: item.close.trim(), + ...anchor, + } + }) + const sides = new Set(items.map((item) => item.side)) + if (!REQUIRED_QA_SIDES.every((side) => sides.has(side))) { + fail('qa: o briefing cobre os dois lados — exige ao menos 1 "direita" e 1 "esquerda".') + } + return items +} + +const normalizeAvoid = (raw, options) => { + const avoid = raw.avoid ?? [] + assertList(avoid, 'avoid') + assertMinimum(avoid, BRIEFING_MINIMUMS.avoid, 'avoid') + return avoid.map((item, index) => { + assertKey(item, 'title', `avoid[${index}]`) + assertKey(item, 'note', `avoid[${index}]`) + warnIfOver(options.warnings, item.title, TEXT_CAPS.avoidTitle, `avoid[${index}].title`) + warnIfOver(options.warnings, item.note, TEXT_CAPS.avoidNote, `avoid[${index}].note`) + return { title: item.title.trim(), note: item.note.trim() } + }) +} + +const normalizeChecklist = (raw, options) => { + const beforeAnswer = raw.checklist?.beforeAnswer ?? [] + const unsure = raw.checklist?.unsure ?? [] + assertList(beforeAnswer, 'checklist.beforeAnswer') + assertList(unsure, 'checklist.unsure') + assertMinimum(beforeAnswer, BRIEFING_MINIMUMS.beforeAnswer, 'checklist.beforeAnswer') + assertMinimum(unsure, BRIEFING_MINIMUMS.unsure, 'checklist.unsure') + const items = (list, label) => + list.map((value, index) => { + if (!isNonEmptyString(value)) fail(`${label}[${index}]: item não vazio.`) + warnIfOver(options.warnings, value, TEXT_CAPS.checklistItem, `${label}[${index}]`) + return value.trim() + }) + return { + beforeAnswer: items(beforeAnswer, 'checklist.beforeAnswer'), + unsure: items(unsure, 'checklist.unsure'), + } +} + +/** + * Fail-closed normalization of the authored briefing. Every anchor must resolve + * in the sourced ledger; a lacuna is declared with `gapReason`, never invented. + * + * @param {any} raw parsed `.briefing.json` + * @param {{ unit: any, slug: string, facts?: any[] }} options + * @returns {any} normalized content + warnings + */ +export const normalizeBriefingContent = (raw, { unit, slug, facts = [] }) => { + if (!raw || typeof raw !== 'object') fail('briefing.json inválido: objeto esperado.') + if (unit?.id && raw.unitId !== unit.id) { + fail(`briefing.json: unitId "${raw.unitId ?? '—'}" não é "${unit.id}".`) + } + const slugField = unit?.slugField ?? 'slug' + assertKey(raw, slugField, 'briefing.json') + if (raw[slugField].trim() !== slug) { + fail(`briefing.json: ${slugField} "${raw[slugField]}" não é "${slug}" — pare e regenere.`) + } + assertKey(raw, 'generatedAt', 'briefing.json') + if (Number.isNaN(new Date(raw.generatedAt).getTime())) { + fail(`briefing.json: generatedAt inválido ("${raw.generatedAt}").`) + } + assertKey(raw, 'lede', 'briefing.json') + assertNoForbiddenKeys(raw, 'briefing.json') + + const warnings = [] + warnIfOver(warnings, raw.lede, TEXT_CAPS.lede, 'lede') + warnIfOver(warnings, raw.subtitle, TEXT_CAPS.subtitle, 'subtitle') + + const essential = normalizeEssential(raw, { facts, warnings }) + assertMinimum(essential, BRIEFING_MINIMUMS.essential, 'essential') + + return { + slug: raw[slugField].trim(), + generatedAt: new Date(raw.generatedAt), + subtitle: isNonEmptyString(raw.subtitle) ? raw.subtitle.trim() : null, + lede: raw.lede.trim(), + essential, + defenses: normalizeDefenses(raw, { facts, warnings }), + script: normalizeScript(raw, { facts, warnings }), + qa: normalizeQa(raw, { facts, warnings }), + avoid: normalizeAvoid(raw, { facts, warnings }), + checklist: normalizeChecklist(raw, { facts, warnings }), + shed: { qa: 0, defenses: 0, checklist: 0, avoid: 0 }, + warnings, + } +} + +/** + * The last qa item whose removal still leaves one representative of each + * required side — the shed never silences a side (the bulletin/briefing + * contract prints both). `-1` when only the minimums remain. + */ +const qaShedIndex = (qa) => { + for (let index = qa.length - 1; index >= 0; index -= 1) { + const sides = new Set( + qa.filter((_item, position) => position !== index).map((item) => item.side), + ) + if (REQUIRED_QA_SIDES.every((side) => sides.has(side))) return index + } + return -1 +} + +/** + * One shed step: drop a single item from the lowest-priority list that is still + * above its minimum (qa → defesas → conferir → evitar), never breaking the + * two-sides coverage. Essential, roteiro and identification never shed. Returns + * a new content object, or `null` when nothing else may be dropped — the caller + * then fails closed. + * + * @param {any} content + * @returns {any|null} + */ +export const trimBriefing = (content) => { + const shed = { ...content.shed } + if (content.qa.length > BRIEFING_MINIMUMS.qa) { + const index = qaShedIndex(content.qa) + if (index !== -1) { + shed.qa += 1 + return { + ...content, + qa: content.qa.filter((_item, position) => position !== index), + shed, + } + } + } + if (content.defenses.length > 0) { + shed.defenses += 1 + return { ...content, defenses: content.defenses.slice(0, -1), shed } + } + const checklistTotal = content.checklist.beforeAnswer.length + content.checklist.unsure.length + if (checklistTotal > BRIEFING_MINIMUMS.beforeAnswer + BRIEFING_MINIMUMS.unsure) { + shed.checklist += 1 + if (content.checklist.unsure.length > BRIEFING_MINIMUMS.unsure) { + return { + ...content, + checklist: { ...content.checklist, unsure: content.checklist.unsure.slice(0, -1) }, + shed, + } + } + return { + ...content, + checklist: { + ...content.checklist, + beforeAnswer: content.checklist.beforeAnswer.slice(0, -1), + }, + shed, + } + } + if (content.avoid.length > BRIEFING_MINIMUMS.avoid) { + shed.avoid += 1 + return { ...content, avoid: content.avoid.slice(0, -1), shed } + } + return null +} diff --git a/scripts/lib/briefingRender.mjs b/scripts/lib/briefingRender.mjs new file mode 100644 index 00000000..4433d3e3 --- /dev/null +++ b/scripts/lib/briefingRender.mjs @@ -0,0 +1,367 @@ +/** + * Briefing de capacitação — print renderer (C210). + * + * Ports the approved gate design (`docs/plans/briefing-capacitacao-solla-ui-design.html`) + * class by class into the inline print CSS the report family uses: four fixed + * A4 sheets (`essencial|defesas|qa|evitar`), sober, no campaign brand, the + * literal internal label on every sheet and the vote request as a rendered + * literal. The companion `.md` carries everything the PDF may have shed. + */ + +import { BRIEFING_LABEL, BRIEFING_PAGE_TOTAL } from './briefingContent.mjs' +import { formatDateBr, formatDateTimeBr } from './cityReportFormat.mjs' +import { resolveDossierUnit } from './dossieUnit.mjs' +import { htmlEscape, moreItemsLabel } from './reportText.mjs' + +const BRIEFING_REQUEST_LINE = + '“Posso contar com você? Para deputado federal, vote 1313, Jorge Solla.”' + +const BRIEFING_PRINT_CSS = ` + :root { + --ink: #18232d; + --ink-soft: #52616d; + --accent: #4d6d7c; + --line: #bdc8ce; + --line-light: #dce3e6; + --paper: #ffffff; + --workspace: #e9edef; + --note: #f2f5f6; + } + * { box-sizing: border-box; } + html, body { margin: 0; padding: 0; } + body { color: var(--ink); background: var(--paper); font-family: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; -webkit-font-smoothing: antialiased; } + @page { size: A4; margin: 0; } + .sheet { + display: flex; + width: 210mm; + height: 297mm; + padding: 14mm 16mm 12mm; + overflow: hidden; + flex-direction: column; + background: var(--paper); + break-after: page; + font-size: 9.4pt; + line-height: 1.42; + } + .sheet:last-child { break-after: auto; } + .sheet p, .sheet ul, .sheet ol { margin-top: 0; } + .sheet a { color: var(--accent); } + .brief-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 9mm; padding-bottom: 3mm; border-bottom: .35mm solid var(--ink); } + .kicker, .eyebrow { margin: 0; color: var(--accent); font-size: 7.3pt; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } + .brief-title { margin: 1mm 0 0; font-size: 19pt; font-weight: 650; line-height: 1.05; letter-spacing: -.025em; } + .brief-subtitle { margin: 1.2mm 0 0; color: var(--ink-soft); font-size: 8.7pt; } + .internal-label { max-width: 47mm; margin: 0; color: var(--ink); font-size: 7.2pt; font-weight: 700; line-height: 1.4; text-align: right; text-transform: uppercase; } + .page-no { display: block; margin-top: 1mm; color: var(--ink-soft); font-weight: 500; text-transform: none; } + .section { margin-top: 5mm; } + .section + .section { margin-top: 6mm; } + .section-title { margin: 1.2mm 0 0; font-size: 13.5pt; font-weight: 650; line-height: 1.15; letter-spacing: -.015em; } + .lede { max-width: 158mm; margin: 3mm 0 0; font-size: 10.2pt; line-height: 1.5; } + .rule-note { margin-top: 4mm; padding: 2.5mm 0 2.5mm 4mm; border-top: .25mm solid var(--line); border-bottom: .25mm solid var(--line); border-left: 1mm solid var(--accent); color: var(--ink-soft); font-size: 8.4pt; line-height: 1.42; } + .rule-note strong { color: var(--ink); } + .line-list, .script-list, .avoid-list, .check-list { margin: 3mm 0 0; padding: 0; border-top: .35mm solid var(--ink); list-style: none; } + .line-list li, .script-list li, .avoid-list li, .check-list li { position: relative; padding: 2.5mm 0 2.5mm 6mm; border-bottom: .2mm solid var(--line-light); } + .line-list li::before, .script-list li::before, .avoid-list li::before, .check-list li::before { position: absolute; top: 3.7mm; left: 0; width: 2.2mm; height: .45mm; background: var(--accent); content: ''; } + .line-title { display: block; font-weight: 700; } + .line-meta { display: block; margin-top: .8mm; color: var(--ink-soft); font-size: 7.8pt; line-height: 1.4; } + .more-note { margin: 3mm 0 0; color: var(--ink-soft); font-size: 8pt; font-weight: 600; line-height: 1.4; } + .source { color: var(--accent); font-size: 7.4pt; font-weight: 700; } + .request-line { margin: 3.5mm 0 0; padding: 4mm 0; border-top: .6mm solid var(--ink); border-bottom: .6mm solid var(--ink); font-size: 14pt; font-weight: 650; line-height: 1.3; letter-spacing: -.015em; } + .request-line em { color: var(--accent); font-style: normal; } + .script-list { counter-reset: script-step; } + .script-list li { min-height: 14mm; padding-left: 13mm; counter-increment: script-step; } + .script-list li::before { top: 2.7mm; width: 8mm; height: auto; color: var(--accent); background: none; content: counter(script-step, decimal-leading-zero); font-size: 8pt; font-weight: 700; } + .qa-list { margin: 3mm 0 0; padding: 0; border-top: .35mm solid var(--ink); list-style: none; } + .qa-item { display: grid; padding: 3.2mm 0; border-bottom: .2mm solid var(--line-light); grid-template-columns: 46mm 1fr; gap: 5mm; } + .qa-question { margin: 0; font-size: 8.6pt; font-weight: 700; line-height: 1.38; } + .qa-side { display: block; margin-bottom: 1mm; color: var(--accent); font-size: 6.8pt; letter-spacing: .07em; text-transform: uppercase; } + .qa-answer { margin: 0; font-size: 8.6pt; line-height: 1.45; } + .qa-answer b { font-weight: 700; } + .avoid-list strong, .check-list strong { font-weight: 700; } + .two-column { display: grid; margin-top: 5mm; grid-template-columns: 1fr 1fr; gap: 8mm; } + .limits { margin-top: 5mm; padding-top: 3mm; border-top: .6mm solid var(--ink); } + .limits p { margin: 1.5mm 0 0; color: var(--ink-soft); font-size: 8pt; line-height: 1.42; } + .brief-footer { display: flex; margin-top: auto; padding-top: 2.5mm; border-top: .25mm solid var(--line); justify-content: space-between; gap: 8mm; color: var(--ink-soft); font-size: 7.2pt; line-height: 1.3; } + .brief-footer strong { color: var(--ink); } + .sheet-compact .section { margin-top: 3.8mm; } + .sheet-compact .avoid-list, .sheet-compact .check-list { margin-top: 2mm; } + .sheet-compact .avoid-list li, .sheet-compact .check-list li { padding-top: 1.65mm; padding-bottom: 1.65mm; font-size: 8.7pt; line-height: 1.32; } + .sheet-compact .avoid-list li::before, .sheet-compact .check-list li::before { top: 2.8mm; } + .sheet-compact .two-column { margin-top: 3.8mm; gap: 6mm; } + .sheet-compact .section-title { font-size: 12.5pt; } + .sheet-compact .limits { margin-top: 3.5mm; padding-top: 2mm; } + .sheet-compact .limits p { margin-top: 1mm; font-size: 7.5pt; line-height: 1.32; } + .sheet-compact .brief-footer { padding-top: 1.8mm; } +` + +const HOW_TO_USE_COPY = + 'Antes: escolha dois fatos e ensaie o pedido. Durante: escute, responda com um fato verificável e peça o voto. Depois: registre a dúvida que ficou sem resposta para o próximo ciclo do dossiê.' + +const WHY_COPY = + 'contato pessoal e relacional mobiliza melhor do que broadcast; escuta e narrativa vêm antes do argumento. Evidência internacional orienta o método, mas EUA ≠ Brasil — aqui o plano ajuda também a lembrar o número na urna.' + +const RULER_COPY = + 'reconheça a preocupação em uma frase, sem repetir o ataque; responda com um fato local verificável; feche perguntando se pode contar com o voto 1313. Se o dossiê não sustenta a resposta, diga que vai conferir.' + +const limitsCopyA = (name) => + `Material interno de capacitação. Não publicar, encaminhar como peça, impulsionar nem adaptar sem revisão editorial e jurídica. Sem CTA público, marca de campanha, cenário eleitoral, estimativa de votos ou dado staff-only. Os fatos foram selecionados do dossiê de ${name} e devem ser conferidos na fonte antes de uso público.` + +const LIMITS_COPY_B = + 'A evidência de mobilização citada no treinamento é majoritariamente internacional e não mede automaticamente o Brasil. Use os princípios para orientar contato pessoal, plano de voto e lembrança do número — nunca para prometer efeito ou misturar resultados de estudos diferentes.' + +const QA_SIDE_LABELS = { + direita: 'Objeção à direita', + esquerda: 'Objeção à esquerda', + entrega: 'Dúvida sobre entrega', +} + +/** + * The recorte name printed in headers/footers: the dossiê subject (municipality + * or institution/theme), falling back to the content slug. Shared with the + * builder log so the name has one owner. + */ +export const briefingSubjectName = (report, content) => + report?.meta?.municipalityName ?? report?.meta?.subjectName ?? content?.slug ?? '—' + +const inlineBold = (value) => htmlEscape(value).replace(/\*\*([^*]+)\*\*/g, '$1') + +const anchorHtml = (item) => + item.fact + ? `fonte${ + item.fact.sourceDate ? ` · ${htmlEscape(formatDateBr(item.fact.sourceDate))}` : '' + }` + : 'lacuna' + +const anchorMd = (item) => + item.fact + ? `[fonte](${item.fact.sourceUrl})${item.fact.sourceDate ? ` · ${formatDateBr(item.fact.sourceDate)}` : ''}` + : `_lacuna: ${item.gapReason}_` + +const headerHtml = (unit, title, subtitle, page, kickerSubject) => ` +
+
+

Briefing de capacitação · ${htmlEscape(kickerSubject ?? unit.briefingNoun ?? unit.id)}

+

${htmlEscape(title)}

+

${htmlEscape(subtitle)}

+
+

+ ${BRIEFING_LABEL} + folha ${page}/${BRIEFING_PAGE_TOTAL} +

+
` + +const footerHtml = (left, page) => + `
${left}Briefing de capacitação · ${page}/${BRIEFING_PAGE_TOTAL}
` + +const moreNote = (count, singular, plural) => + count > 0 + ? `

${htmlEscape(moreItemsLabel(count, singular, plural))} no briefing completo (.md)

` + : '' + +const lineItemsHtml = (items) => + (items ?? []) + .map( + (item) => + `
  • ${htmlEscape(item.title)}${htmlEscape(item.note)} ${anchorHtml(item)}
  • `, + ) + .join('\n') + +const howToUseNoteHtml = () => + `
    Como usar. ${htmlEscape(HOW_TO_USE_COPY)}
    ` + +const sheetEssential = (content, { unit, report }) => ` +
    + ${headerHtml(unit, briefingSubjectName(report, content), content.subtitle ?? 'Consulta antes e durante o contato', 1, unit.briefingNoun ?? unit.id)} +
    +

    Comece por aqui

    +

    O essencial do recorte

    +

    ${htmlEscape(content.lede)}

    +
      +${lineItemsHtml(content.essential)} +
    +
    + ${howToUseNoteHtml()} + ${footerHtml(`Regra: ${unit.sumGuardNote ? `${htmlEscape(unit.sumGuardNote)} · ` : ''}fase acompanha valor · sem fonte, não afirmar`, 1)} +
    ` + +const sheetDefenses = (content, { unit, report }) => { + const defenses = + content.defenses.length > 0 + ? lineItemsHtml(content.defenses) + : content.shed.defenses > 0 + ? '' + : '
  • Sem registro localizado no dossiê.Não completar por memória nem apresentar ação regional como promessa do recorte. Confira no dossiê antes de afirmar.
  • ' + return ` +
    + ${headerHtml(unit, 'Defesas e pedido de voto', 'Do fato local ao compromisso nomeado', 2, briefingSubjectName(report, content))} +
    +

    Leitura dos registros · não é opinião

    +

    O que Solla defende

    +
      +${defenses} +
    + ${moreNote(content.shed.defenses, 'defesa', 'defesas')} +
    +
    +

    Roteiro curto · fale como pessoa, não como disparo

    +

    Faça o pedido com clareza

    +

    ${BRIEFING_REQUEST_LINE}

    +
      +${content.script.steps + .map( + (step) => + `
    1. ${htmlEscape(step.title)}${htmlEscape(step.note)}
    2. `, + ) + .join('\n')} +
    +
    +
    Por que assim: ${htmlEscape(WHY_COPY)}
    + ${footerHtml(`Base de capacitação: Nickerson & Rogers 2010 · Schein et al. 2021 · Kalla & Broockman 2020`, 2)} +
    ` +} + +const sheetQa = (content, { unit, report }) => ` +
    + ${headerHtml(unit, 'Perguntas prováveis × melhores respostas', 'Reconhecer → fato local verificável → pedido', 3, briefingSubjectName(report, content))} +
    Régua visível: ${htmlEscape(RULER_COPY)}
    +
      +${content.qa + .map( + (item) => `
    1. +

      ${htmlEscape(QA_SIDE_LABELS[item.side] ?? item.side)}${htmlEscape(item.question)}

      +

      Reconhecer: ${htmlEscape(item.acknowledge)} Fato: ${htmlEscape(item.answer)} ${anchorHtml(item)} Fechar: ${htmlEscape(item.close)}

      +
    2. `, + ) + .join('\n')} +
    + ${moreNote(content.shed.qa, 'pergunta', 'perguntas')} + ${footerHtml(`Não debata para vencer. Preserve a relação, responda o verificável e anote a lacuna.`, 3)} +
    ` + +const sheetAvoid = (content, { unit, report }) => ` +
    + ${headerHtml(unit, 'O que evitar', 'Proteja a relação, a verdade do recorte e os limites do material', 4, briefingSubjectName(report, content))} +
    +
      +${content.avoid + .map((item) => `
    • ${htmlEscape(item.title)} ${htmlEscape(item.note)}
    • `) + .join('\n')} +
    + ${moreNote(content.shed.avoid, 'anti-padrão', 'anti-padrões')} +
    +
    +
    +

    Antes de responder

    +

    O que conferir no dossiê

    +
      +${content.checklist.beforeAnswer.map((item) => `
    • ${inlineBold(item)}
    • `).join('\n')} +
    +
    +
    +

    Consulta em campo

    +

    Se você não souber

    +
      +${content.checklist.unsure.map((item) => `
    • ${inlineBold(item)}
    • `).join('\n')} +
    +
    +
    + ${moreNote(content.shed.checklist, 'item de conferência', 'itens de conferência')} +
    +

    Limites e defeso

    +

    ${htmlEscape(limitsCopyA(briefingSubjectName(report, content)))}

    +

    ${htmlEscape(LIMITS_COPY_B)}

    +
    + ${footerHtml('Anti-padrões: Lau et al. 2007 · Ecker et al. 2022 · Debunking Handbook 2020', 4)} +
    ` + +/** + * @param {any} content normalized briefing content + * @param {{ unit?: any, report: any }} options + */ +export const renderBriefingHtml = (content, { unit, report }) => { + const resolvedUnit = resolveDossierUnit(unit ?? report?.unit) + const parts = [ + sheetEssential(content, { unit: resolvedUnit, report }), + sheetDefenses(content, { unit: resolvedUnit, report }), + sheetQa(content, { unit: resolvedUnit, report }), + sheetAvoid(content, { unit: resolvedUnit, report }), + ] + return ` + +Briefing de capacitação — ${htmlEscape(briefingSubjectName(report, content))} + +${parts.join('\n')} + +` +} + +/** Companion `.md`: the reference copy — carries everything the PDF may shed. */ +export const renderBriefingMd = (content, { unit, report }) => { + const resolvedUnit = resolveDossierUnit(unit ?? report?.unit) + const name = briefingSubjectName(report, content) + const lines = [ + `# Briefing de capacitação — ${name}`, + '', + `> **${BRIEFING_LABEL}**`, + '', + `- **Recorte:** ${resolvedUnit.briefingNoun ?? resolvedUnit.id} · ${name}`, + `- **Gerado em:** ${formatDateTimeBr(content.generatedAt)}`, + ] + if (content.subtitle) lines.push(`- **Uso:** ${content.subtitle}`) + lines.push( + '', + '## O essencial do recorte', + '', + content.lede, + '', + `**Como usar.** ${HOW_TO_USE_COPY}`, + '', + ...content.essential.map((item) => `- **${item.title}** — ${item.note} (${anchorMd(item)})`), + '', + '## O que Solla defende', + '', + ...(content.defenses.length > 0 + ? content.defenses.map((item) => `- **${item.title}** — ${item.note} (${anchorMd(item)})`) + : ['- Sem registro localizado no dossiê — não completar por memória.']), + '', + '## Faça o pedido com clareza', + '', + '> “Posso contar com você? Para deputado federal, vote 1313, Jorge Solla.”', + '', + `**Por que assim:** ${WHY_COPY}`, + '', + ...content.script.steps.map((step, index) => `${index + 1}. **${step.title}** — ${step.note}`), + '', + '## Perguntas prováveis × melhores respostas', + '', + `**Régua visível:** ${RULER_COPY}`, + '', + ...content.qa.flatMap((item) => [ + `### ${QA_SIDE_LABELS[item.side] ?? item.side} — ${item.question}`, + '', + `- **Reconhecer:** ${item.acknowledge}`, + `- **Fato:** ${item.answer} (${anchorMd(item)})`, + `- **Fechar:** ${item.close}`, + '', + ]), + '## O que evitar', + '', + ...content.avoid.map((item) => `- **${item.title}** ${item.note}`), + '', + '## O que conferir no dossiê', + '', + ...content.checklist.beforeAnswer.map((item) => `- ${item}`), + '', + '## Se você não souber', + '', + ...content.checklist.unsure.map((item) => `- ${item}`), + '', + '## Limites e defeso', + '', + limitsCopyA(name), + '', + LIMITS_COPY_B, + '', + `_Documento interno de campanha, datado (${formatDateTimeBr(content.generatedAt)}). O dado envelhece: confira a data do dossiê._`, + ) + return `${lines.join('\n')}\n` +} diff --git a/scripts/lib/buildPdf.mjs b/scripts/lib/buildPdf.mjs index d4b3a596..5732e610 100644 --- a/scripts/lib/buildPdf.mjs +++ b/scripts/lib/buildPdf.mjs @@ -21,12 +21,12 @@ export const PAGE_FIT_TOLERANCE_PX = 10 export const A4_PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX /** - * Bounded re-renders of the one-page boletim while an `onBulletinOverflow` - * callback keeps handing back a shorter document (C190). Each pass drops at - * least one printed fact, so the cap only exists to fail closed on a callback - * that stops shrinking. + * Bounded re-renders of a shrinking document: the one-page boletim (C190) and + * the four-sheet briefing (C210) while their `on*Overflow` callback keeps + * handing back a shorter document. Each pass drops at least one item, so the + * cap only exists to fail closed on a callback that stops shrinking. */ -const MAX_BULLETIN_FIT_ATTEMPTS = 24 +const MAX_FIT_ATTEMPTS = 24 export const launchPdfBrowser = () => chromium.launch() @@ -197,6 +197,83 @@ export const assertPageFits = (overflows, label, hint) => { ) } +/** + * Page-count guard of the single-document emit (C210): the briefing is a fixed + * four-sheet document, so a fifth sheet is a hard failure, never a flowing + * continuation. `.sheet{overflow:hidden}` hides a cut, which is why the count + * and the per-sheet fit are the guards that make the four-page cap honest. + */ +export const assertPageCount = (count, maxPages, label, hint) => { + if (count <= maxPages) return + throw new Error(`${label} tem ${count} folhas (> ${maxPages}). ${hint}`) +} + +/** + * One browser run, one document, one PDF (C210 briefing): asserts every + * required `[data-page]` anchor exists, fails closed when the sheet count + * exceeds `maxPages`, measures the per-sheet fit and, when `onOverflow` hands + * back a shorter document, re-renders until it fits. Additive to — and + * independent of — the two-document `emitHtmlPairPdf` contract. + * + * @param {any} browser Chromium from `launchPdfBrowser` + * @param {{ + * html: string, + * pdf: string, + * maxPages?: number, + * requiredAnchors?: string[], + * onOverflow?: (() => string | null | Promise) | null, + * label?: string, + * }} options + */ +export const emitHtmlSinglePdf = async ( + browser, + { html, pdf, maxPages = 4, requiredAnchors = [], onOverflow = null, label = 'O documento' }, +) => { + const page = await openPrintPage(browser, html) + let currentHtml = html + try { + for (let attempt = 0; ; attempt += 1) { + // The first pass already rendered through `openPrintPage`; only a rebuild + // needs a new content load. + if (attempt > 0) { + await page.setContent(currentHtml, { waitUntil: 'load' }) + await page.emulateMedia({ media: 'print' }) + } + const anchors = await page.evaluate(() => + [...document.querySelectorAll('[data-page]')].map((element) => + element.getAttribute('data-page'), + ), + ) + for (const anchor of requiredAnchors) { + if (!anchors.includes(anchor)) { + throw new Error(`${label}: folha "${anchor}" ausente no HTML — renderer quebrado.`) + } + } + assertPageCount( + anchors.length, + maxPages, + label, + 'O teto de páginas é rígido: encurte as listas — nenhuma folha extra é emitida.', + ) + const overflows = await measurePageOverflows(page, A4_PAGE_BUDGET_PX) + if (overflows.length === 0) break + const failClosed = () => + assertPageFits( + overflows, + label, + 'Encurte as listas/caps — nenhuma folha pode ser cortada pelo overflow.', + ) + if (!onOverflow || attempt >= MAX_FIT_ATTEMPTS) failClosed() + const rebuilt = await onOverflow() + if (!rebuilt) failClosed() + currentHtml = rebuilt + } + await printA4Pdf(page, pdf) + } finally { + await page.close() + } +} + const printA4Pdf = (page, outPath) => page.pdf({ path: outPath, @@ -284,7 +361,7 @@ export const emitHtmlPairPdf = async ( if (!hasBulletin) throw new Error('Bloco do boletim ausente no HTML — renderer quebrado.') const overflows = await measurePageOverflows(page, A4_PAGE_BUDGET_PX) if (overflows.length === 0) break - if (!onBulletinOverflow || attempt >= MAX_BULLETIN_FIT_ATTEMPTS) { + if (!onBulletinOverflow || attempt >= MAX_FIT_ATTEMPTS) { assertPageFits( overflows, 'O boletim', diff --git a/scripts/lib/dossieUnit.mjs b/scripts/lib/dossieUnit.mjs index a4127137..22c30d01 100644 --- a/scripts/lib/dossieUnit.mjs +++ b/scripts/lib/dossieUnit.mjs @@ -50,6 +50,8 @@ export const MUNICIPALITY_UNIT = { id: 'municipality', /** Key that carries the resolved unit slug in research/snapshot files. */ slugField: 'municipalitySlug', + /** Key that holds the recorte identity in the snapshot file. */ + snapshotField: 'municipality', spheres: ['municipio', 'regiao', 'polo'], defaultSphere: 'municipio', sphereLabels: { municipio: 'município', regiao: 'região', polo: 'polo' }, @@ -57,6 +59,9 @@ export const MUNICIPALITY_UNIT = { /** Bulletin ranking: a direct item outranks a shared-scope one. */ breadthRank: { municipio: 0, regiao: 1, polo: 2 }, researchDir: 'data/dossie-solla-cidade', + outDir: 'docs/research/dossie-solla-cidade', + /** How the C210 briefing names this recorte in copy ("Briefing de capacitação · cidade"). */ + briefingNoun: 'cidade', series: 'Série municipal · BA', title: 'Dossiê Solla por cidade', bulletinTitle: 'Boletim informativo modelo', @@ -86,6 +91,8 @@ export const INSTITUTION_UNIT = { sphereBadgeClass: { instituicao: '', setor: 'scope-sector', rede: 'scope-network' }, breadthRank: { instituicao: 0, setor: 1, rede: 2 }, researchDir: 'data/dossie-solla-instituicao', + outDir: 'docs/research/dossie-solla-instituicao', + briefingNoun: 'instituição', series: 'Série institucional · agenda com lastro', title: 'Dossiê Solla por instituição', bulletinTitle: 'Boletim informativo modelo', @@ -230,6 +237,8 @@ export const THEME_UNIT = { sphereBadgeClass: { area: '', segmento: 'scope-sector', rede: 'scope-network' }, breadthRank: { area: 0, segmento: 1, rede: 2 }, researchDir: 'data/dossie-solla-tema', + outDir: 'docs/research/dossie-solla-tema', + briefingNoun: 'tema', series: 'Série temática · por área', title: 'Dossiê Solla por tema/área', bulletinTitle: 'Boletim informativo modelo', diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 66da87d0..cd957b5f 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -44,6 +44,8 @@ export const SCRIPTS_SPEC_PINNED = [ 'scripts/lib/agent-session.mjs', 'scripts/lib/ansi.mjs', 'scripts/lib/auto-unblock.mjs', + 'scripts/lib/briefingContent.mjs', + 'scripts/lib/briefingRender.mjs', 'scripts/lib/buildPdf.mjs', 'scripts/lib/camaraFetch.mjs', 'scripts/lib/camaraSpeeches.mjs', diff --git a/tests/unit/briefingContent.unit.spec.ts b/tests/unit/briefingContent.unit.spec.ts new file mode 100644 index 00000000..c25d5745 --- /dev/null +++ b/tests/unit/briefingContent.unit.spec.ts @@ -0,0 +1,355 @@ +import { describe, expect, it } from 'vitest' + +import { + BRIEFING_LABEL, + BRIEFING_MINIMUMS, + BRIEFING_QA_SIDES, + briefingAnchorFact, + normalizeBriefingContent, + trimBriefing, +} from '../../scripts/lib/briefingContent.mjs' +import { buildDossierReport } from '../../scripts/lib/dossieBlocks.mjs' +import { + mergeDossierResearch, + normalizeDossierResearchInput, +} from '../../scripts/lib/dossieResearch.mjs' +import { MUNICIPALITY_UNIT, THEME_UNIT } from '../../scripts/lib/dossieUnit.mjs' + +// C210: the authored briefing may only anchor on the dossiê ledger (facts with +// source). These tests pin the fail-closed validation, the deny-list and the +// deterministic shed order that keeps the four-sheet PDF honest. + +const generatedAt = new Date('2026-09-22T12:00:00.000Z') +const snapshot = { + meta: { readAt: '2026-09-16T10:00:00.000Z', database: 'teqo_test' }, + municipality: { slug: 'ilheus', name: 'Ilhéus', region: 'Litoral Sul', ibgeCode: '2913606' }, +} + +const research = mergeDossierResearch([ + normalizeDossierResearchInput({ + municipalitySlug: 'ilheus', + era: 'B', + researchedAt: '2026-09-16T10:00:00.000Z', + items: [ + { + id: 'era_b_equipamentos', + answer: 'Ambulância e atenção domiciliar no município', + brief: { title: 'Ambulância e atenção domiciliar', note: 'Registro de 2012.' }, + sourceUrl: 'https://saude.test/ambulancia', + sourceDate: '2012-01-25', + }, + { + id: 'era_b_programas', + answer: 'Saúde em Movimento com menção direta', + sourceUrl: 'https://saude.test/programas', + sourceDate: '2013-05-10', + }, + ], + news: [], + gaps: [], + }), + normalizeDossierResearchInput({ + municipalitySlug: 'ilheus', + era: 'C', + researchedAt: '2026-09-16T10:00:00.000Z', + items: [ + { + id: 'era_c_atuacao', + answer: 'Articulação regional pela UFBA', + sourceUrl: 'https://camara.test/ufba', + sourceDate: '2023-09-22', + }, + ], + news: [], + gaps: [], + }), +]) + +const report = buildDossierReport({ snapshot, research, generatedAt }) +const facts = report.bulletinFacts + +const validRaw = (overrides: Record = {}) => ({ + unitId: 'municipality', + municipalitySlug: 'ilheus', + generatedAt: generatedAt.toISOString(), + subtitle: 'Litoral Sul · consulta antes e durante o contato', + lede: 'Use um fato local de cada vez.', + essential: [ + { factId: 'era_b_equipamentos', title: 'Ambulância', note: 'Registro local de 2012.' }, + { factId: 'era_b_programas', title: 'Saúde em Movimento', note: 'Menção direta.' }, + { gapReason: 'sem fala própria localizada', title: 'Sem fala local', note: 'Anote a lacuna.' }, + ], + defenses: [{ factId: 'era_c_atuacao', title: 'Ensino superior', note: 'Defesa regional.' }], + script: { + steps: [ + { title: 'Comece pela relação', note: 'Escute antes de argumentar.' }, + { title: 'Use um fato do recorte', note: 'Diga o alcance.' }, + { title: 'Peça explicitamente', note: 'Nome e número.' }, + ], + }, + qa: [ + { + side: 'direita', + question: 'Não voto no PT.', + acknowledge: 'Entendo que a escolha pesa.', + answer: 'Há registro nominal na saúde.', + close: 'Posso contar com o 1313?', + factId: 'era_b_equipamentos', + }, + { + side: 'esquerda', + question: 'Por que ainda falta tanto?', + acknowledge: 'A cobrança é legítima.', + answer: 'O levantamento registra lacunas.', + close: 'Posso contar com o 1313?', + gapReason: 'sem registro localizado', + }, + { + side: 'entrega', + question: 'Ele prometeu o quê?', + acknowledge: 'Promessa precisa estar registrada.', + answer: 'Não foi localizada promessa nominal.', + close: 'Pelo trabalho comprovado, vota 1313?', + gapReason: 'sem promessa nominal', + }, + { + side: 'direita', + question: 'E o governo de Jerônimo?', + acknowledge: 'É justo cobrar o que chegou.', + answer: 'O briefing registra outros períodos.', + close: 'Posso pedir seu voto 1313?', + gapReason: 'sem entrega do governo atual', + }, + { + side: 'esquerda', + question: 'Por que falar de Jacobina?', + acknowledge: 'Você tem razão em separar cidade e região.', + answer: 'As iniciativas aparecem como regionais.', + close: 'Posso contar com o 1313?', + factId: 'era_c_atuacao', + }, + ], + avoid: [ + { title: 'Confrontar para ganhar', note: 'Encerre com respeito.' }, + { title: 'Envergonhar o eleitor', note: 'Pressão pesada gera reação.' }, + { title: 'Prometer sem lastro', note: 'Indicação não é obra.' }, + { title: 'Repetir o ataque', note: 'Ofereça a explicação alternativa.' }, + { title: 'Broadcast impessoal', note: 'WhatsApp 1-a-1 vem antes.' }, + ], + checklist: { + beforeAnswer: ['Fonte e data', 'Alcance', 'Fase do valor'], + unsure: ['Diga que não tem o dado', 'Anote a pergunta', 'Combine retorno'], + }, + ...overrides, +}) + +const normalize = (raw: Record = validRaw()) => + normalizeBriefingContent(raw, { unit: MUNICIPALITY_UNIT, slug: 'ilheus', facts }) + +describe('normalizeBriefingContent', () => { + it('resolves every anchor against the sourced ledger and keeps the lacuna explicit', () => { + const content = normalize() + expect(content.essential).toHaveLength(3) + expect(content.essential[0].fact?.sourceUrl).toBe('https://saude.test/ambulancia') + expect(content.essential[2].gapReason).toBe('sem fala própria localizada') + expect(content.qa).toHaveLength(5) + expect(content.warnings).toEqual([]) + }) + + it('rejects a unitId or slug from another recorte', () => { + expect(() => normalize(validRaw({ unitId: 'theme' }))).toThrow(/unitId/) + expect(() => normalize(validRaw({ municipalitySlug: 'itabuna' }))).toThrow(/não é "ilheus"/) + }) + + it('requires generatedAt and a lede', () => { + expect(() => normalize(validRaw({ generatedAt: 'ontem' }))).toThrow(/generatedAt inválido/) + expect(() => normalize(validRaw({ lede: '' }))).toThrow(/"lede" obrigatório/) + }) + + it('demands exactly one of factId | gapReason per item', () => { + const both = validRaw() + const essential = (both.essential as Record[]).map((item, index) => + index === 0 ? { ...item, gapReason: 'também lacuna' } : item, + ) + expect(() => normalize({ ...both, essential })).toThrow( + /exatamente um de "factId" \| "gapReason"/, + ) + const neither = (both.essential as Record[]).map((item, index) => + index === 0 ? { title: 'x', note: 'y' } : item, + ) + expect(() => normalize({ ...both, essential: neither })).toThrow(/exatamente um/) + }) + + it('fails closed when the factId does not resolve in a sourced fact', () => { + const raw = validRaw() + const essential = (raw.essential as Record[]).map((item, index) => + index === 0 ? { ...item, factId: 'era_z_inexistente' } : item, + ) + expect(() => normalize({ ...raw, essential })).toThrow(/não resolve em fato com fonte/) + expect(() => + normalizeBriefingContent(validRaw(), { + unit: MUNICIPALITY_UNIT, + slug: 'ilheus', + facts: [{ id: 'era_b_equipamentos' }], + }), + ).toThrow(/não resolve em fato com fonte/) + }) + + it('blocks staff-only/scenario keys recursively', () => { + expect(() => normalize(validRaw({ scenario: { estimatedVotes: 1234 } }))).toThrow( + /cenário\/estimativa é staff-only/, + ) + expect(() => + normalize( + validRaw({ + essential: [{ factId: 'era_b_equipamentos', title: 'x', note: 'y', estimatedVotes: 10 }], + }), + ), + ).toThrow(/proibida/) + }) + + it('covers both sides and enforces the list minimums', () => { + const raw = validRaw() + expect(() => + normalize({ + ...raw, + qa: (raw.qa as Record[]).map((item) => + item.side === 'esquerda' ? { ...item, side: 'direita' } : item, + ), + }), + ).toThrow(/dois lados/) + expect(() => normalize({ ...raw, avoid: [] })).toThrow(/avoid: mínimo de 3/) + expect(() => + normalize({ ...raw, checklist: { beforeAnswer: ['só um'], unsure: ['a', 'b'] } }), + ).toThrow(/checklist.beforeAnswer: mínimo de 2/) + }) + + it('reports over-cap text as a warning, never as a hard failure', () => { + const raw = validRaw() + const content = normalize({ ...raw, lede: 'a'.repeat(500) }) + expect(content.warnings.join('\n')).toMatch(/lede: 500 chars/) + }) + + it('resolves anchors through the unit slugField (theme seam)', () => { + const themeFacts = ['era_b_equipamentos', 'era_b_programas', 'era_c_atuacao'].map((id) => ({ + id, + sourceUrl: `https://x.test/${id}`, + sourceDate: '2011-08-30', + })) + const raw = validRaw({ unitId: 'theme' }) + expect(() => + normalizeBriefingContent(raw, { unit: THEME_UNIT, slug: 'educacao', facts: themeFacts }), + ).toThrow(/themeSlug/) + const content = normalizeBriefingContent( + { ...raw, themeSlug: 'educacao' }, + { unit: THEME_UNIT, slug: 'educacao', facts: themeFacts }, + ) + expect(content.slug).toBe('educacao') + expect(THEME_UNIT.briefingNoun).toBe('tema') + }) + + it('refuses the acervo sample (sourcePanel) as an anchor', () => { + const panelFacts = [ + { + id: 'fala-1', + headline: 'Pronunciamento', + sourceUrl: 'https://x.test/fala', + sourcePanel: true, + }, + ] + expect(briefingAnchorFact(panelFacts, 'fala-1')).toBeNull() + expect(() => + normalizeBriefingContent(validRaw(), { + unit: MUNICIPALITY_UNIT, + slug: 'ilheus', + facts: panelFacts, + }), + ).toThrow(/não resolve em fato com fonte/) + }) + + it('reports qa caps with the real field names, once each', () => { + const raw = validRaw() + const qa = (raw.qa as Record[]).map((item, index) => + index === 0 ? { ...item, question: 'q'.repeat(300), answer: 'a'.repeat(700) } : item, + ) + const content = normalize({ ...raw, qa }) + expect(content.warnings.join('\n')).toContain('qa[0].question: 300 chars') + expect(content.warnings.join('\n')).toContain('qa[0].answer: 700 chars') + expect(content.warnings).toHaveLength(2) + }) + + it('keeps the label literal and the qa sides pinned for the renderer', () => { + expect(BRIEFING_LABEL).toBe('Insumo interno de capacitação — não publicar') + expect(BRIEFING_QA_SIDES).toEqual(['direita', 'esquerda', 'entrega']) + }) +}) + +describe('briefingAnchorFact', () => { + it('returns the ledger fact or null', () => { + expect(briefingAnchorFact(facts, 'era_b_programas')?.sourceUrl).toBe( + 'https://saude.test/programas', + ) + expect(briefingAnchorFact(facts, 'não-existe')).toBeNull() + }) +}) + +describe('trimBriefing (deterministic shed order)', () => { + const content = normalize() + + it('drops qa first, then defenses, then checklist, then avoid', () => { + const first = trimBriefing(content)! + expect(first.qa).toHaveLength(BRIEFING_MINIMUMS.qa) + expect(first.shed).toEqual({ qa: 1, defenses: 0, checklist: 0, avoid: 0 }) + const second = trimBriefing(first)! + expect(second.defenses).toHaveLength(0) + expect(second.shed).toEqual({ qa: 1, defenses: 1, checklist: 0, avoid: 0 }) + const third = trimBriefing(second)! + expect(third.shed.checklist).toBe(1) + const fourth = trimBriefing(third)! + expect(fourth.shed.checklist).toBe(2) + expect(fourth.checklist.beforeAnswer.length + fourth.checklist.unsure.length).toBe( + BRIEFING_MINIMUMS.beforeAnswer + BRIEFING_MINIMUMS.unsure, + ) + const fifth = trimBriefing(fourth)! + expect(fifth.avoid).toHaveLength(BRIEFING_MINIMUMS.avoid + 1) + expect(fifth.shed.avoid).toBe(1) + }) + + it('never silences a required side when shedding qa', () => { + const base = normalize() + const qa = [ + { ...base.qa[1], side: 'esquerda' }, + { ...base.qa[2], side: 'esquerda' }, + { ...base.qa[3], side: 'entrega' }, + { ...base.qa[4], side: 'esquerda' }, + { ...base.qa[0], side: 'direita' }, + ] + const trimmed = trimBriefing({ ...base, qa })! + expect(trimmed.qa).toHaveLength(BRIEFING_MINIMUMS.qa) + expect(trimmed.shed.qa).toBe(1) + const sides = new Set(trimmed.qa.map((item: { side: string }) => item.side)) + expect(sides.has('direita')).toBe(true) + expect(sides.has('esquerda')).toBe(true) + // The unique "direita" (last item) survives — the shed picked an "esquerda". + expect(trimmed.qa.at(-1)?.side).toBe('direita') + }) + + it('never touches essential or the script and stops at the minimums', () => { + let current = content + let next = trimBriefing(current) + let guard = 0 + while (next && guard < 50) { + current = next + next = trimBriefing(current) + guard += 1 + } + expect(current.qa).toHaveLength(BRIEFING_MINIMUMS.qa) + expect(current.defenses).toHaveLength(0) + expect(current.checklist.beforeAnswer).toHaveLength(BRIEFING_MINIMUMS.beforeAnswer) + expect(current.checklist.unsure).toHaveLength(BRIEFING_MINIMUMS.unsure) + expect(current.avoid).toHaveLength(BRIEFING_MINIMUMS.avoid) + expect(current.essential).toEqual(content.essential) + expect(current.script).toEqual(content.script) + expect(next).toBeNull() + }) +}) diff --git a/tests/unit/briefingRender.unit.spec.ts b/tests/unit/briefingRender.unit.spec.ts new file mode 100644 index 00000000..ed24d47c --- /dev/null +++ b/tests/unit/briefingRender.unit.spec.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' + +import { normalizeBriefingContent, trimBriefing } from '../../scripts/lib/briefingContent.mjs' +import { renderBriefingHtml, renderBriefingMd } from '../../scripts/lib/briefingRender.mjs' +import { buildDossierReport } from '../../scripts/lib/dossieBlocks.mjs' +import { + mergeDossierResearch, + normalizeDossierResearchInput, +} from '../../scripts/lib/dossieResearch.mjs' +import { MUNICIPALITY_UNIT } from '../../scripts/lib/dossieUnit.mjs' + +// C210: the briefing is the approved gate design ported to inline print CSS — +// four fixed A4 sheets, the internal label on each and the request literal. The +// tests pin the anchors the page guard reads, the guards that cannot regress +// (literal label, "vote 1313", source per anchor) and the `.md` superset. + +const generatedAt = new Date('2026-09-22T12:00:00.000Z') +const snapshot = { + meta: { readAt: '2026-09-16T10:00:00.000Z', database: 'teqo_test' }, + municipality: { slug: 'ilheus', name: 'Ilhéus', region: 'Litoral Sul', ibgeCode: '2913606' }, +} + +const research = mergeDossierResearch([ + normalizeDossierResearchInput({ + municipalitySlug: 'ilheus', + era: 'B', + researchedAt: '2026-09-16T10:00:00.000Z', + items: [ + { + id: 'era_b_equipamentos', + answer: 'Ambulância e atenção domiciliar no município', + sourceUrl: 'https://saude.test/ambulancia', + sourceDate: '2012-01-25', + }, + { + id: 'era_b_programas', + answer: 'Saúde em Movimento com menção direta ', + sourceUrl: 'https://saude.test/programas', + sourceDate: '2013-05-10', + }, + ], + news: [], + gaps: [], + }), +]) + +const report = buildDossierReport({ snapshot, research, generatedAt }) +const facts = report.bulletinFacts + +const rawContent = (qaCount = 5, avoidCount = 5) => ({ + unitId: 'municipality', + municipalitySlug: 'ilheus', + generatedAt: generatedAt.toISOString(), + subtitle: 'Litoral Sul · consulta antes e durante o contato', + lede: 'Use um fato local de cada vez.', + essential: [ + { + factId: 'era_b_equipamentos', + title: 'Ambulância ', + note: 'Registro local de 2012.', + }, + { factId: 'era_b_programas', title: 'Saúde em Movimento', note: 'Menção direta.' }, + { gapReason: 'sem fala própria localizada', title: 'Sem fala local', note: 'Anote a lacuna.' }, + ], + defenses: [{ factId: 'era_b_programas', title: 'Ensino superior', note: 'Defesa regional.' }], + script: { + steps: [ + { title: 'Comece pela relação', note: 'Escute antes de argumentar.' }, + { title: 'Use um fato do recorte', note: 'Diga o alcance.' }, + { title: 'Peça explicitamente', note: 'Nome e número.' }, + ], + }, + qa: Array.from({ length: qaCount }, (_value, index) => ({ + side: index % 2 === 0 ? 'direita' : 'esquerda', + question: `Pergunta ${index}`, + acknowledge: 'Entendo a preocupação.', + answer: 'Há registro nominal no recorte.', + close: 'Posso contar com o 1313?', + factId: 'era_b_equipamentos', + })), + avoid: Array.from({ length: avoidCount }, (_value, index) => ({ + title: `Anti-padrão ${index}`, + note: 'Encerre com respeito.', + })), + checklist: { + beforeAnswer: ['**Fonte e data** do fato', '**Alcance:** município'], + unsure: ['**Diga com clareza:** não tenho', '**Anote a pergunta** sem PII'], + }, +}) + +const content = normalizeBriefingContent(rawContent(), { + unit: MUNICIPALITY_UNIT, + slug: 'ilheus', + facts, +}) + +describe('renderBriefingHtml', () => { + const html = renderBriefingHtml(content, { unit: MUNICIPALITY_UNIT, report }) + + it('renders exactly the four fixed sheets the page guard counts', () => { + for (const anchor of ['essencial', 'defesas', 'qa', 'evitar']) { + expect(html).toContain(`data-page="${anchor}"`) + } + expect((html.match(/class="sheet/g) ?? []).length).toBe(4) + }) + + it('prints the internal label on every sheet and the page count', () => { + expect((html.match(/Insumo interno de capacitação — não publicar/g) ?? []).length).toBe(4) + for (const page of ['folha 1/4', 'folha 2/4', 'folha 3/4', 'folha 4/4']) { + expect(html).toContain(page) + } + }) + + it('renders the literal request with the vote number', () => { + expect(html).toContain('vote 1313, Jorge Solla') + expect(html).toContain('request-line') + }) + + it('links every anchored item to its sourced fact and marks the lacuna', () => { + expect(html).toContain('https://saude.test/ambulancia') + expect(html).toContain('25/01/2012') + expect(html).toContain('lacuna') + }) + + it('escapes authored and research text', () => { + expect(html).not.toContain('') + expect(html).toContain('<script>alert(1)</script>') + }) + + it('declares the shed remainder on the sheet instead of dropping it silently', () => { + const trimmed = trimBriefing(trimBriefing(content)!)! + const shedHtml = renderBriefingHtml(trimmed, { unit: MUNICIPALITY_UNIT, report }) + expect(shedHtml).toContain('no briefing completo (.md)') + expect(trimmed.qa).toHaveLength(4) + }) + + it('does not contradict the shed defenses with the no-record lacuna line', () => { + const trimmed = trimBriefing(trimBriefing(content)!)! + const shedHtml = renderBriefingHtml(trimmed, { unit: MUNICIPALITY_UNIT, report }) + expect(trimmed.defenses).toHaveLength(0) + expect(shedHtml).toContain('e mais 1 defesa no briefing completo (.md)') + expect(shedHtml).not.toContain('Sem registro localizado no dossiê.') + }) + + it('prints the lacuna line only when the recorte never had a defense', () => { + const noDefenses = normalizeBriefingContent( + { ...rawContent(), defenses: [] }, + { unit: MUNICIPALITY_UNIT, slug: 'ilheus', facts }, + ) + expect(renderBriefingHtml(noDefenses, { unit: MUNICIPALITY_UNIT, report })).toContain( + 'Sem registro localizado no dossiê.', + ) + }) + + it('keeps the guard copy of the unit on the first and last sheets', () => { + expect(html).toContain('região e polo não são somados ao município') + expect(html).toContain('Não publicar, encaminhar como peça') + }) + + it('bold-prints the checklist scan prefix and names the recorte in the kicker', () => { + expect(html).toContain('Fonte e data do fato') + expect(html).toContain('Diga com clareza: não tenho') + expect(html).toContain('Briefing de capacitação · Ilhéus') + expect(html).toContain('Briefing de capacitação · cidade') + }) +}) + +describe('renderBriefingMd (companion superset)', () => { + it('carries every item of the full content, including what the PDF shed', () => { + let trimmed = content + while (trimBriefing(trimmed)) trimmed = trimBriefing(trimmed)! + expect(trimmed.qa.length).toBeLessThan(content.qa.length) + expect(trimmed.avoid.length).toBeLessThan(content.avoid.length) + const droppedQuestion = content.qa.find( + (item: { question: string }) => + !trimmed.qa.some((kept: { question: string }) => kept.question === item.question), + )! + const shedHtml = renderBriefingHtml(trimmed, { unit: MUNICIPALITY_UNIT, report }) + expect(shedHtml).not.toContain(droppedQuestion.question) + const md = renderBriefingMd(content, { unit: MUNICIPALITY_UNIT, report }) + expect(md).toContain(droppedQuestion.question) + expect(md).toContain('Insumo interno de capacitação — não publicar') + expect(md).toContain('vote 1313, Jorge Solla') + expect(md).toContain('[fonte](https://saude.test/ambulancia)') + expect(md).toContain('## O que conferir no dossiê') + expect(md).toContain('## Limites e defeso') + // The fixed guidance printed on the sheets is part of the reference copy. + expect(md).toContain('**Como usar.**') + expect(md).toContain('**Por que assim:**') + expect(md).toContain('**Régua visível:**') + expect((md.match(/^### /gm) ?? []).length).toBe(5) + }) +}) diff --git a/tests/unit/briefingSkill.unit.spec.ts b/tests/unit/briefingSkill.unit.spec.ts new file mode 100644 index 00000000..56f3dcbb --- /dev/null +++ b/tests/unit/briefingSkill.unit.spec.ts @@ -0,0 +1,94 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// C210: the briefing skill, the command and the author subagent are prompt +// text, so the product contract (label, 4-page cap, vote literal, anchor +// fail-closed, no second research) lives in the SKILL.md prose. This guard +// fails the build if the flow, the guardrails or the CLI contract are dropped. + +const repoRoot = process.cwd() + +const read = (path: string): string => readFileSync(resolve(repoRoot, path), 'utf8') + +const skill = read('.agents/skills/briefing-capacitacao-solla/SKILL.md') +const agent = read('.opencode/agent/briefing-capacitacao-solla.md') +const command = read('.opencode/commands/briefing-capacitacao-solla.md') + +describe('skill /briefing-capacitacao-solla documents the flow', () => { + it('declares when to use it, the batch selector and the integration', () => { + expect(skill).toContain('## Quando usar') + expect(skill).toContain('## Lote (recortes)') + expect(skill).toContain('cidade:') + expect(skill).toContain('instituicao:') + expect(skill).toContain('tema:') + expect(skill).toContain('/dossie-solla-cidade') + expect(skill).toMatch(/3º entregável/) + }) + + it('pins the canonical builder CLI and the gitignored artifacts', () => { + expect(skill).toContain('node scripts/build-dossie-solla-briefing.mjs') + expect(skill).toContain('--unit=') + expect(skill).toContain('data/dossie-solla-') + expect(skill).toContain('docs/research/dossie-solla-') + expect(skill).toContain('gitignored') + }) + + it('pins the hard product literals', () => { + expect(skill).toContain('Insumo interno de capacitação — não publicar') + expect(skill).toContain('vote 1313') + expect(skill).toMatch(/4 páginas/) + expect(skill).toContain('insumo interno') + expect(skill).toMatch(/sem CTA público/i) + }) + + it('pins the fail-closed content contract', () => { + expect(skill).toContain('## Contrato do') + expect(skill).toContain('factId') + expect(skill).toContain('gapReason') + expect(skill).toContain('estimatedVotes') + expect(skill).toMatch(/sem fonte, o item não entra/i) + expect(skill).toMatch(/dois lados/) + }) + + it('pins the hard four-page cap with declared shed and the .md superset', () => { + expect(skill).toMatch(/teto de \*\*4 páginas é rígido\*\*/) + expect(skill).toMatch(/corta por\s+prioridade/) + expect(skill).toContain('no briefing completo (.md)') + expect(skill).toMatch(/nunca\*\* são cortados/) + }) + + it('pins the offline guardrail: no second factual research', () => { + expect(skill).toMatch(/sem segunda pesquisa factual/i) + expect(skill).toMatch(/nunca faz segunda pesquisa factual/i) + }) +}) + +describe('author subagent is writer-only (briefing.json)', () => { + it('stays a subagent that writes the briefing.json and returns the receipt', () => { + expect(agent).toMatch(/^---\n[\s\S]*mode: subagent[\s\S]*\n---/) + expect(agent).toContain('.briefing.json') + expect(agent).toContain('Recibo do autor') + const frontmatter = agent.match(/^---\n([\s\S]*?)\n---/) + expect(frontmatter, 'agent frontmatter must be delimited by ---').not.toBeNull() + expect(frontmatter![1]).not.toMatch(/^\s*["']?model["']?\s*:/m) + }) + + it('does not run the deterministic build or a new web research', () => { + expect(agent, 'author must not run the builder CLI').not.toContain('--snapshot=') + expect(agent, 'author must not do new web research').toMatch(/não\*\* faça pesquisa web/i) + expect(agent, 'author must not edit the research files').toMatch(/não\*\* edite os/) + }) +}) + +describe('command /briefing-capacitacao-solla couples to the skill', () => { + it('references the canonical skill file and passes arguments', () => { + expect(existsSync(resolve(repoRoot, '.opencode/commands/briefing-capacitacao-solla.md'))).toBe( + true, + ) + expect(command).toContain('`briefing-capacitacao-solla`') + expect(command).toContain('$ARGUMENTS') + expect(command).toContain('.agents/skills/briefing-capacitacao-solla/SKILL.md') + expect(command).toContain('insumo interno') + }) +}) diff --git a/tests/unit/buildPdf.unit.spec.ts b/tests/unit/buildPdf.unit.spec.ts index ee55f2d8..ae9c6b4e 100644 --- a/tests/unit/buildPdf.unit.spec.ts +++ b/tests/unit/buildPdf.unit.spec.ts @@ -36,6 +36,126 @@ describe('assertPageFits', () => { }) }) +import { assertPageCount, emitHtmlSinglePdf } from '../../scripts/lib/buildPdf.mjs' + +// C210: the briefing emits a single four-sheet document. The count guard is the +// hard cap (`.sheet{overflow:hidden}` would hide a cut sheet), so it is pinned +// as a pure function; the emitter itself is Chromium plumbing, like the pair. + +describe('assertPageCount (C210 briefing cap)', () => { + it('passes at or under the cap', () => { + expect(() => assertPageCount(4, 4, 'O briefing', 'hint')).not.toThrow() + }) + + it('fails closed above the cap with the count and the hint', () => { + expect(() => assertPageCount(5, 4, 'O briefing', 'encurte as listas')).toThrow( + /O briefing tem 5 folhas \(> 4\).*encurte as listas/s, + ) + }) +}) + +describe('emitHtmlSinglePdf', () => { + it('is exported as the additive single-document emit contract', () => { + expect(typeof emitHtmlSinglePdf).toBe('function') + }) + + // The four-sheet cap is the product promise, so the emitter's fail-closed + // paths are pinned with a fake page — no Chromium launch in the unit suite. + type EmitScript = { anchors: string[][]; overflows: { page: string; height: number }[][] } + + const fakeEmitBrowser = (script: EmitScript) => { + const calls: string[] = [] + const page = { + setViewportSize: async () => calls.push('viewport'), + setContent: async () => calls.push('content'), + emulateMedia: async () => calls.push('emulate'), + evaluate: async (fn: unknown) => + String(fn).includes('scrollHeight') + ? (script.overflows.shift() ?? []) + : (script.anchors.shift() ?? []), + pdf: async () => calls.push('pdf'), + close: async () => calls.push('close'), + } + return { browser: { newPage: async () => page }, calls } + } + + const sheetAnchors = ['essencial', 'defesas', 'qa', 'evitar'] + + it('fails closed when a required sheet anchor is missing', async () => { + const { browser, calls } = fakeEmitBrowser({ + anchors: [['essencial', 'defesas', 'qa']], + overflows: [], + }) + await expect( + emitHtmlSinglePdf(browser, { + html: '', + pdf: '/tmp/never.pdf', + requiredAnchors: sheetAnchors, + label: 'O briefing', + }), + ).rejects.toThrow(/folha "evitar" ausente/) + expect(calls).not.toContain('pdf') + expect(calls.at(-1)).toBe('close') + }) + + it('fails closed above the page cap', async () => { + const { browser, calls } = fakeEmitBrowser({ + anchors: [[...sheetAnchors, 'extra']], + overflows: [], + }) + await expect( + emitHtmlSinglePdf(browser, { + html: '', + pdf: '/tmp/never.pdf', + maxPages: 4, + label: 'O briefing', + }), + ).rejects.toThrow(/tem 5 folhas \(> 4\)/) + expect(calls).not.toContain('pdf') + }) + + it('rebuilds through onOverflow until the sheet fits and then prints', async () => { + const { browser, calls } = fakeEmitBrowser({ + anchors: [sheetAnchors, sheetAnchors], + overflows: [[{ page: 'qa', height: A4_PAGE_BUDGET_PX + 100 }], []], + }) + let rebuilds = 0 + await emitHtmlSinglePdf(browser, { + html: 'v1', + pdf: '/tmp/out.pdf', + maxPages: 4, + requiredAnchors: sheetAnchors, + label: 'O briefing', + onOverflow: async () => { + rebuilds += 1 + return 'v2' + }, + }) + expect(rebuilds).toBe(1) + expect(calls.filter((call) => call === 'content')).toHaveLength(2) + expect(calls).toContain('pdf') + expect(calls.at(-1)).toBe('close') + }) + + it('fails closed on overflow when there is nothing left to shed', async () => { + const { browser, calls } = fakeEmitBrowser({ + anchors: [sheetAnchors], + overflows: [[{ page: 'qa', height: A4_PAGE_BUDGET_PX + 100 }]], + }) + await expect( + emitHtmlSinglePdf(browser, { + html: '', + pdf: '/tmp/never.pdf', + maxPages: 4, + requiredAnchors: sheetAnchors, + label: 'O briefing', + onOverflow: async () => null, + }), + ).rejects.toThrow(/estourou o A4/) + expect(calls).not.toContain('pdf') + }) +}) + import { emitHtmlPairPdf } from '../../scripts/lib/buildPdf.mjs' describe('emitHtmlPairPdf', () => { diff --git a/tests/unit/dossieBatchSkill.unit.spec.ts b/tests/unit/dossieBatchSkill.unit.spec.ts index b4b88334..cda76235 100644 --- a/tests/unit/dossieBatchSkill.unit.spec.ts +++ b/tests/unit/dossieBatchSkill.unit.spec.ts @@ -136,3 +136,19 @@ describe('subagent .opencode/agent/dossie-solla-cidade-redacao.md is writer-only expect(writer, 'writer must not edit the research files').toMatch(/não\*\* edite os/) }) }) + +describe('briefing de capacitação como 3º entregável (C210)', () => { + it('points at the briefing skill, its build and the third output', () => { + expect(skill).toContain('## Briefing de capacitação (3º entregável, C210)') + expect(skill).toContain('.agents/skills/briefing-capacitacao-solla/SKILL.md') + expect(skill).toContain('scripts/build-dossie-solla-briefing.mjs') + expect(skill).toContain('-briefing.pdf') + expect(skill).toContain('-briefing.md') + }) + + it('pins the handout guardrails inherited from the briefing skill', () => { + expect(skill).toContain('Insumo interno de capacitação — não publicar') + expect(skill).toMatch(/até 4 páginas/i) + expect(skill).toMatch(/sem segunda pesquisa factual/i) + }) +}) diff --git a/tests/unit/dossieInstitutionBatchSkill.unit.spec.ts b/tests/unit/dossieInstitutionBatchSkill.unit.spec.ts index ed3ddc5e..444a74d4 100644 --- a/tests/unit/dossieInstitutionBatchSkill.unit.spec.ts +++ b/tests/unit/dossieInstitutionBatchSkill.unit.spec.ts @@ -140,3 +140,19 @@ describe('subagent .opencode/agent/dossie-solla-instituicao-redacao.md is writer expect(writer, 'writer must not edit the research files').toMatch(/não\*\* edite os/) }) }) + +describe('briefing de capacitação como 3º entregável (C210)', () => { + it('points at the briefing skill, its build and the third output', () => { + expect(skill).toContain('## Briefing de capacitação (3º entregável, C210)') + expect(skill).toContain('.agents/skills/briefing-capacitacao-solla/SKILL.md') + expect(skill).toContain('scripts/build-dossie-solla-briefing.mjs') + expect(skill).toContain('-briefing.pdf') + expect(skill).toContain('-briefing.md') + }) + + it('pins the handout guardrails inherited from the briefing skill', () => { + expect(skill).toContain('Insumo interno de capacitação — não publicar') + expect(skill).toMatch(/até 4 páginas/i) + expect(skill).toMatch(/sem segunda pesquisa factual/i) + }) +}) diff --git a/tests/unit/dossieThemeBatchSkill.unit.spec.ts b/tests/unit/dossieThemeBatchSkill.unit.spec.ts index 763a94a8..669b8717 100644 --- a/tests/unit/dossieThemeBatchSkill.unit.spec.ts +++ b/tests/unit/dossieThemeBatchSkill.unit.spec.ts @@ -136,3 +136,19 @@ describe('subagent .opencode/agent/dossie-solla-tema-redacao.md is writer-only', expect(writer, 'writer must not edit the research files').toMatch(/não\*\* edite os/) }) }) + +describe('briefing de capacitação como 3º entregável (C210)', () => { + it('points at the briefing skill, its build and the third output', () => { + expect(skill).toContain('## Briefing de capacitação (3º entregável, C210)') + expect(skill).toContain('.agents/skills/briefing-capacitacao-solla/SKILL.md') + expect(skill).toContain('scripts/build-dossie-solla-briefing.mjs') + expect(skill).toContain('-briefing.pdf') + expect(skill).toContain('-briefing.md') + }) + + it('pins the handout guardrails inherited from the briefing skill', () => { + expect(skill).toContain('Insumo interno de capacitação — não publicar') + expect(skill).toMatch(/até 4 páginas/i) + expect(skill).toMatch(/sem segunda pesquisa factual/i) + }) +}) diff --git a/tests/unit/opencodeCommands.unit.spec.ts b/tests/unit/opencodeCommands.unit.spec.ts index 6cf9b35b..d979e448 100644 --- a/tests/unit/opencodeCommands.unit.spec.ts +++ b/tests/unit/opencodeCommands.unit.spec.ts @@ -19,6 +19,7 @@ const commands = [ 'dossie-solla-cidade', 'dossie-solla-instituicao', 'dossie-solla-tema', + 'briefing-capacitacao-solla', 'graficos-dados', 'reels-tutoriais', ] as const