feat(C210): briefing de capacitação — até 4 páginas por recorte (3º entregável dos dossiês) (Closes #1252)

This commit is contained in:
Francisco Solla 2026-09-22 20:49:48 -03:00
parent 6e1406c812
commit f63e5cb9b4
25 changed files with 2504 additions and 40 deletions

View file

@ -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 <recorte>" 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 é
`<recorte>:<valor>` 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-<recorte>/<slug>.{a,b,c}.research.json` e (quando houver)
o `<slug>.narrative.json`, redige o `<slug>.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/<slug>.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-<recorte>/<slug>-<YYYY-MM-DD>-briefing.pdf` +
`-briefing.md`; intermediários gitignored em `data/dossie-solla-<recorte>/`.
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 `<slug>.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.

View file

@ -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 <cidade>" e/ou o "boletim modelo de <cidade>"
— ou várias numa invocação (`/dossie-solla-cidade Ilheus, Itacare, Una`).
- A comunicação pede "o dossiê de <cidade>", o "boletim modelo de <cidade>"
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:<slug>`
(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=<json>` (replay sem rede), `--author=<nome>` (default
`JORGE SOLLA`), `--generated-at=<ISO>`, `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/<slug>.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/<slug>.snapshot.json \
--research-dir=data/dossie-solla-cidade \
--out-dir=docs/research/dossie-solla-cidade
```
Saídas: `docs/research/dossie-solla-cidade/<slug>-<YYYY-MM-DD>-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`

View file

@ -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 <instituição>" e/ou o "boletim modelo da
<instituição>" — ou várias numa invocação (`/dossie-solla-instituicao UFBA,
Correios`).
- A comunicação pede "o dossiê da <instituição>", o "boletim modelo da
<instituição>" 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:<slug>`
(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=<ISO>`, `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/<slug>.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/<slug>.institution.snapshot.json \
--research-dir=data/dossie-solla-instituicao \
--out-dir=docs/research/dossie-solla-instituicao
```
Saídas: `docs/research/dossie-solla-instituicao/<slug>-<YYYY-MM-DD>-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):

View file

@ -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=<ISO>`, `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/<slug>.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/<slug>.theme.snapshot.json \
--research-dir=data/dossie-solla-tema \
--out-dir=docs/research/dossie-solla-tema
```
Saídas: `docs/research/dossie-solla-tema/<slug>-<YYYY-MM-DD>-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):

View file

@ -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-<recorte>/<slug>.{a,b,c}.research.json` (`items[]`
com `answer`/`summary`/`brief`/`numbers`/`sphere`/`sourceUrl`/`sourceDate`;
`gaps[]` são lacunas explícitas) e, quando existir, o
`<slug>.narrative.json`.
2. Escrever `data/dossie-solla-<recorte>/<slug>.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).

View file

@ -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:<Município>[, instituicao:<Instituição>, tema:<Área>]` — ex.: `/briefing-capacitacao-solla cidade:Ilheus, instituicao:UFBA`. Um recorte = briefing (`<slug>-<YYYY-MM-DD>-briefing.pdf` + `-briefing.md`), insumo interno de capacitação.
$ARGUMENTS

View file

@ -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 <Cidade> [<Cidade>, ...]` — ex.: `/dossie-solla-cidade Ilhéus, Itacaré, Una`. Uma cidade = dossiê (`<slug>-<YYYY-MM-DD>-dossie.pdf`/`.md`) + boletim (`-boletim.pdf`).
Uso: `/dossie-solla-cidade <Cidade> [<Cidade>, ...]` — ex.: `/dossie-solla-cidade Ilhéus, Itacaré, Una`. Uma cidade = dossiê (`<slug>-<YYYY-MM-DD>-dossie.pdf`/`.md`) + boletim (`-boletim.pdf`) + briefing (`-briefing.pdf`/`-briefing.md`).
$ARGUMENTS

View file

@ -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 <Instituição> [<Instituição>, ...]` — ex.: `/dossie-solla-instituicao UFBA, Correios`. Uma instituição = dossiê (`<slug>-<YYYY-MM-DD>-dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). Token fora do catálogo falha fechado; use o escape one-off `--slug=<x> --name="<Nome>"`.
Uso: `/dossie-solla-instituicao <Instituição> [<Instituição>, ...]` — ex.: `/dossie-solla-instituicao UFBA, Correios`. Uma instituição = dossiê (`<slug>-<YYYY-MM-DD>-dossie.pdf`/`.md`) + boletim (`-boletim.pdf`) + briefing (`-briefing.pdf`/`-briefing.md`). Token fora do catálogo falha fechado; use o escape one-off `--slug=<x> --name="<Nome>"`.
$ARGUMENTS

View file

@ -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ê (`<slug>-<YYYY-MM-DD>-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ê (`<slug>-<YYYY-MM-DD>-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

View file

@ -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 `<slug>.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).

View file

@ -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<br/>isMunicipalitySlug / resolveInstitutionName / resolveSpeechTopic"]
B --> C["subagente autor (1 por recorte)<br/>lê research + voz solla-comunicacao"]
C --> D["slug.briefing.json<br/>Q&A/roteiro/evitar/conferir só com fato-âncora"]
D --> E["build-dossie-solla-briefing.mjs<br/>normaliza + resolve âncoras contra bulletinFacts"]
F["dossiê já pesquisado<br/>slug.a/b/c.research.json + snapshot"] --> E
E --> G["briefingContent: plano de impressão<br/>+ shed determinístico por prioridade"]
G --> H["briefingRender: 4 folhas data-page + companion .md"]
H --> I["emitHtmlSinglePdf<br/>maxPages=4 · fit por folha · fail-closed"]
I --> J["docs/research/dossie-solla-<recorte>/slug-YYYY-MM-DD-briefing.pdf/.md"]
K["skills dossie-solla-{cidade,instituicao,tema}<br/>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 `<slug>.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:<token>` · `instituicao:<token>` · `tema:<token>`, 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 `<researchDir>/<slug>.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 + `<slug>.{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 `<slug>.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: `<outDir>/<slug>-<YYYY-MM-DD>-briefing.pdf`/`.md` e cache `<researchDir>/<baseName>.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 `<article class="sheet" data-page="…">`, 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.**

View file

@ -0,0 +1,180 @@
/**
* Briefing de capacitação builder (C210): snapshot + per-era research + the
* authored `<slug>.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/<slug>.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=<json> [--unit=municipality|institution|theme] [--research-dir=<dir>] [--out-dir=<dir>] [--generated-at=<ISO>]',
)
}
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)}`,
)

View file

@ -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 `<slug>.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 `<slug>.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
}

View file

@ -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, <em>vote 1313, Jorge Solla</em>.”'
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, '<strong>$1</strong>')
const anchorHtml = (item) =>
item.fact
? `<a class="source" href="${htmlEscape(item.fact.sourceUrl)}">fonte${
item.fact.sourceDate ? ` · ${htmlEscape(formatDateBr(item.fact.sourceDate))}` : ''
}</a>`
: '<span class="source">lacuna</span>'
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) => `
<header class="brief-head">
<div>
<p class="kicker">Briefing de capacitação · ${htmlEscape(kickerSubject ?? unit.briefingNoun ?? unit.id)}</p>
<h2 class="brief-title">${htmlEscape(title)}</h2>
<p class="brief-subtitle">${htmlEscape(subtitle)}</p>
</div>
<p class="internal-label">
${BRIEFING_LABEL}
<span class="page-no">folha ${page}/${BRIEFING_PAGE_TOTAL}</span>
</p>
</header>`
const footerHtml = (left, page) =>
`<footer class="brief-footer"><span>${left}</span><span>Briefing de capacitação · ${page}/${BRIEFING_PAGE_TOTAL}</span></footer>`
const moreNote = (count, singular, plural) =>
count > 0
? `<p class="more-note">${htmlEscape(moreItemsLabel(count, singular, plural))} no briefing completo (.md)</p>`
: ''
const lineItemsHtml = (items) =>
(items ?? [])
.map(
(item) =>
`<li><span class="line-title">${htmlEscape(item.title)}</span><span class="line-meta">${htmlEscape(item.note)} ${anchorHtml(item)}</span></li>`,
)
.join('\n')
const howToUseNoteHtml = () =>
`<div class="rule-note"><strong>Como usar.</strong> ${htmlEscape(HOW_TO_USE_COPY)}</div>`
const sheetEssential = (content, { unit, report }) => `
<article class="sheet" data-page="essencial" aria-label="O essencial do briefing de ${htmlEscape(briefingSubjectName(report, content))}">
${headerHtml(unit, briefingSubjectName(report, content), content.subtitle ?? 'Consulta antes e durante o contato', 1, unit.briefingNoun ?? unit.id)}
<section class="section">
<p class="eyebrow">Comece por aqui</p>
<h3 class="section-title">O essencial do recorte</h3>
<p class="lede">${htmlEscape(content.lede)}</p>
<ul class="line-list">
${lineItemsHtml(content.essential)}
</ul>
</section>
${howToUseNoteHtml()}
${footerHtml(`<strong>Regra:</strong> ${unit.sumGuardNote ? `${htmlEscape(unit.sumGuardNote)} · ` : ''}fase acompanha valor · sem fonte, não afirmar`, 1)}
</article>`
const sheetDefenses = (content, { unit, report }) => {
const defenses =
content.defenses.length > 0
? lineItemsHtml(content.defenses)
: content.shed.defenses > 0
? ''
: '<li><span class="line-title">Sem registro localizado no dossiê.</span><span class="line-meta">Não completar por memória nem apresentar ação regional como promessa do recorte. Confira no dossiê antes de afirmar.</span></li>'
return `
<article class="sheet" data-page="defesas" aria-label="O que Solla defende e roteiro do pedido de voto">
${headerHtml(unit, 'Defesas e pedido de voto', 'Do fato local ao compromisso nomeado', 2, briefingSubjectName(report, content))}
<section class="section">
<p class="eyebrow">Leitura dos registros · não é opinião</p>
<h3 class="section-title">O que Solla defende</h3>
<ul class="line-list">
${defenses}
</ul>
${moreNote(content.shed.defenses, 'defesa', 'defesas')}
</section>
<section class="section">
<p class="eyebrow">Roteiro curto · fale como pessoa, não como disparo</p>
<h3 class="section-title">Faça o pedido com clareza</h3>
<p class="request-line">${BRIEFING_REQUEST_LINE}</p>
<ol class="script-list">
${content.script.steps
.map(
(step) =>
`<li><span class="line-title">${htmlEscape(step.title)}</span><span class="line-meta">${htmlEscape(step.note)}</span></li>`,
)
.join('\n')}
</ol>
</section>
<div class="rule-note"><strong>Por que assim:</strong> ${htmlEscape(WHY_COPY)}</div>
${footerHtml(`Base de capacitação: Nickerson &amp; Rogers 2010 · Schein et al. 2021 · Kalla &amp; Broockman 2020`, 2)}
</article>`
}
const sheetQa = (content, { unit, report }) => `
<article class="sheet" data-page="qa" aria-label="Perguntas prováveis e melhores respostas">
${headerHtml(unit, 'Perguntas prováveis × melhores respostas', 'Reconhecer → fato local verificável → pedido', 3, briefingSubjectName(report, content))}
<div class="rule-note"><strong>Régua visível:</strong> ${htmlEscape(RULER_COPY)}</div>
<ol class="qa-list">
${content.qa
.map(
(item) => `<li class="qa-item">
<p class="qa-question"><span class="qa-side">${htmlEscape(QA_SIDE_LABELS[item.side] ?? item.side)}</span>${htmlEscape(item.question)}</p>
<p class="qa-answer"><b>Reconhecer:</b> ${htmlEscape(item.acknowledge)} <b>Fato:</b> ${htmlEscape(item.answer)} ${anchorHtml(item)} <b>Fechar:</b> ${htmlEscape(item.close)}</p>
</li>`,
)
.join('\n')}
</ol>
${moreNote(content.shed.qa, 'pergunta', 'perguntas')}
${footerHtml(`<strong>Não debata para vencer.</strong> Preserve a relação, responda o verificável e anote a lacuna.`, 3)}
</article>`
const sheetAvoid = (content, { unit, report }) => `
<article class="sheet sheet-compact" data-page="evitar" aria-label="O que evitar e o que conferir no dossiê">
${headerHtml(unit, 'O que evitar', 'Proteja a relação, a verdade do recorte e os limites do material', 4, briefingSubjectName(report, content))}
<section class="section">
<ul class="avoid-list">
${content.avoid
.map((item) => `<li><strong>${htmlEscape(item.title)}</strong> ${htmlEscape(item.note)}</li>`)
.join('\n')}
</ul>
${moreNote(content.shed.avoid, 'anti-padrão', 'anti-padrões')}
</section>
<div class="two-column">
<section>
<p class="eyebrow">Antes de responder</p>
<h3 class="section-title">O que conferir no dossiê</h3>
<ul class="check-list">
${content.checklist.beforeAnswer.map((item) => `<li>${inlineBold(item)}</li>`).join('\n')}
</ul>
</section>
<section>
<p class="eyebrow">Consulta em campo</p>
<h3 class="section-title">Se você não souber</h3>
<ul class="check-list">
${content.checklist.unsure.map((item) => `<li>${inlineBold(item)}</li>`).join('\n')}
</ul>
</section>
</div>
${moreNote(content.shed.checklist, 'item de conferência', 'itens de conferência')}
<section class="limits">
<p class="eyebrow">Limites e defeso</p>
<p>${htmlEscape(limitsCopyA(briefingSubjectName(report, content)))}</p>
<p>${htmlEscape(LIMITS_COPY_B)}</p>
</section>
${footerHtml('Anti-padrões: Lau et al. 2007 · Ecker et al. 2022 · Debunking Handbook 2020', 4)}
</article>`
/**
* @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 `<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Briefing de capacitação — ${htmlEscape(briefingSubjectName(report, content))}</title><style>${BRIEFING_PRINT_CSS}</style></head>
<body>
${parts.join('\n')}
</body>
</html>`
}
/** 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`
}

View file

@ -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<string | null>) | 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',

View file

@ -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',

View file

@ -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',

View file

@ -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<string, unknown> = {}) => ({
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<string, unknown> = 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<string, unknown>[]).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<string, unknown>[]).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<string, unknown>[]).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<string, unknown>[]).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<string, unknown>[]).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()
})
})

View file

@ -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 <script>alert(1)</script>',
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 <script>alert(1)</script>',
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('<span class="source">lacuna</span>')
})
it('escapes authored and research text', () => {
expect(html).not.toContain('<script>alert(1)</script>')
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;')
})
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('<strong>Fonte e data</strong> do fato')
expect(html).toContain('<strong>Diga com clareza:</strong> 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)
})
})

View file

@ -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')
})
})

View file

@ -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: '<html></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: '<html></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: '<html>v1</html>',
pdf: '/tmp/out.pdf',
maxPages: 4,
requiredAnchors: sheetAnchors,
label: 'O briefing',
onOverflow: async () => {
rebuilds += 1
return '<html>v2</html>'
},
})
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: '<html></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', () => {

View file

@ -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)
})
})

View file

@ -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)
})
})

View file

@ -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)
})
})

View file

@ -19,6 +19,7 @@ const commands = [
'dossie-solla-cidade',
'dossie-solla-instituicao',
'dossie-solla-tema',
'briefing-capacitacao-solla',
'graficos-dados',
'reels-tutoriais',
] as const