diff --git a/.agents/skills/briefing-capacitacao-solla/SKILL.md b/.agents/skills/briefing-capacitacao-solla/SKILL.md index f499a8ce..33eae059 100644 --- a/.agents/skills/briefing-capacitacao-solla/SKILL.md +++ b/.agents/skills/briefing-capacitacao-solla/SKILL.md @@ -1,15 +1,22 @@ --- 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.' +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), todo de recorte — princípios/crenças e defesas de Solla com fonte, fatos-âncora e o pedido literal do voto 1313, e perguntas prováveis × melhores respostas dos dois lados; 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**. +páginas + companion `.md`** para quem vai **pedir o voto 1313** no recorte. +Depois do replanejamento (2026-09-23), o briefing é **todo do recorte** — não tem +capa, manual genérico, roteiro de passos, anti-padrões nem limites: + +1. **O que Solla defende** — princípios/crenças do recorte (abertura) + posições + com lastro (fonte e data); +2. **O essencial do recorte** — fatos-âncora com **fase junto ao valor** e o + **pedido literal** do voto + **uma linha** de plano; +3. **Perguntas prováveis × melhores respostas (1/2 e 2/2)** — as perguntas reais + do recorte, metade do documento, com os dois lados. + É **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. @@ -77,7 +84,8 @@ recorte **não** cancela os demais (**sucesso parcial** explícito). 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. + aponta um `factId` real que **lastreia a nota** (o ledger resolve o id pelo + primeiro item daquele id) e todo texto sem lastro é corrigido ou removido. Fato novo não entra: vira `gapReason`. 4. **Build local (por recorte), offline:** ```bash @@ -90,7 +98,8 @@ recorte **não** cancela os demais (**sucesso parcial** explícito). `--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 + fixas do recorte** (`defesas → essencial + pedido → qa 1/2 → qa 2/2`), mede + cada folha e emite **1 PDF** com a guarda de teto de páginas (`emitHtmlSinglePdf`, `maxPages=4`). Saídas: `docs/research/dossie-solla-/--briefing.pdf` + `-briefing.md`; intermediários gitignored em `data/dossie-solla-/`. @@ -98,11 +107,11 @@ recorte **não** cancela os demais (**sucesso parcial** explícito). (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. +prioridade** (`qa` até o mínimo de 4 com os dois lados → `defesas` até esvaziar) +e **declara o resto na folha** ("e mais N no briefing completo (.md)"); o +companion `.md` carrega **tudo**. O essencial (fatos-âncora), o **pedido** e a +identificação **nunca** são cortados; se nem o mínimo couber, o build falha +fechado apontando a lista a encurtar. ## Contrato do `.briefing.json` @@ -112,44 +121,48 @@ Escrito pelo autor, validado pelo build (`scripts/lib/briefingContent.mjs`): { "unitId": "municipality", // municipality | institution | theme "municipalitySlug": "miguel-calmon", // chave = slugField da unidade (institutionSlug | themeSlug) - "generatedAt": "2026-09-22T12:00:00.000Z", // obrigatório + "generatedAt": "2026-09-23T12: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" }, + "lede": "≤380 chars — princípios e crenças do recorte (abre a folha 1)", + "defenses": [ // posições com lastro (folha 1); [] = linha de lacuna + { "factId": "era_b_defesas", "sourceUrl": "https://…", "title": "≤120", "note": "≤200" } + ], + "essential": [ // ≥3; fatos-âncora (folha 2); cada item: factId XOR gapReason + { "factId": "era_b_equipamentos", "sourceUrl": "https://…", "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 + "plan": "≤220 chars — uma linha de plano de voto/compromisso nomeado", // obrigatório "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 + "factId": "era_b_sesab", "sourceUrl": "https://…" /* XOR */ "gapReason": "sem registro localizado" } + ] } ``` 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 +- cada item de `defenses`/`essential`/`qa` exige **exatamente um** de `factId | gapReason`; `factId` tem de resolver num fato do ledger **com `sourceUrl`** — **sem fonte, o item não entra**; +- o id de checklist **se repete** entre itens do research e o ledger resolve o + `factId` puro pelo **primeiro item** daquele id; para ancorar outro item, o + autor copia o `sourceUrl` do item do research no campo `sourceUrl` do âncora + (o par `factId` + `sourceUrl` precisa bater — par errado falha fechado); - `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. +- a folha 1 abre com `lede` (princípios/crenças) e a lista `defenses` — o autor + deve trazer **6–10 defesas** para encher a folha; a folha 3–4 acomoda **8–10 + perguntas** (o build divide a lista ao meio). 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. +você? Para deputado federal, **vote 1313, Jorge Solla**." — o `plan` é a única +linha de orientação que o autor escreve. ## Recibo do autor @@ -162,9 +175,10 @@ JSON: "slug": "miguel-calmon", "status": "ok", // ou "failed" "briefingPath": "data/dossie-solla-cidade/miguel-calmon.briefing.json", - "essentialCount": 4, - "qaCount": 5, - "sides": ["direita", "esquerda"], + "essentialCount": 5, + "defensesCount": 8, + "qaCount": 9, + "sides": ["direita", "esquerda", "entrega"], "anchoredFactIds": ["era_b_equipamentos", "era_b_programas", "era_c_atuacao"], "gapCount": 3, // itens com gapReason + lacunas do recorte "failureReason": "…" // opcional (só quando status = failed) @@ -175,24 +189,25 @@ JSON: 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) +## Conteúdo do briefing (4 folhas fixas, todas do recorte) -- **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). +- **Folha 1 — O que Solla defende** (`data-page="defesas"`): princípios/crenças + do recorte (o `lede`) e as posições (`defenses`) com título, leitura e + `fonte · data`. Sem registro = linha de lacuna, nunca posição genérica. +- **Folha 2 — O essencial do recorte + O pedido** (`data-page="essencial"`): + fatos-âncora com **fase junto ao valor** e fonte (sem trajetória de formação) e + o bloco do pedido com a frase literal + a linha de `plan`. +- **Folha 3 — Perguntas prováveis × melhores respostas (1/2)** (`data-page="qa"`): + régua curta (reconhecer → fato com fonte → fechar no pedido) e a primeira + metade do Q&A (pergunta + reconhecer/fato/fechar), dois lados. +- **Folha 4 — Perguntas prováveis × melhores respostas (2/2)** + (`data-page="qa-2"`): continuação com a segunda metade do Q&A e o "e mais N" + quando houver corte. + +**Fora do briefing** (replanejamento 2026-09-23): capa/objetivo, trajetória, +roteiro de passos, justificativa científica, "o que evitar", "o que conferir", +"se você não souber" e "limites e defeso" — o rótulo interno em todas as folhas +já carrega o aviso de insumo. ## Guardrails de produto (não negociáveis) @@ -223,7 +238,7 @@ skill (não há fallback determinístico: o briefing é redação, não consolid 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), + mensagem disser que nem o mínimo cabe, **encurte as listas** (Q&A/defesas), não o layout. - **`unitId`/slug de outro recorte**: o build recusa o par — pare e regenere o `briefing.json` para o recorte certo. @@ -250,9 +265,3 @@ skill (não há fallback determinístico: o briefing é redação, não consolid `.agents/skills/dossie-solla-tema/SKILL.md`. - Voz/rebates: `.opencode/skills/solla-comunicacao/SKILL.md`, `.opencode/skills/solla-comunicacao/referencia/tom-e-exemplos.md`. -- Evidência de mobilização citada no treinamento: Nickerson & Rogers 2010; - Gerber & Green 2000; Kalla & Broockman 2018/2020; Schein et al. 2021; - Michelson et al. 2024; Nickerson 2008; Gerber & Rogers 2009; Gerber, Green & - Larimer 2008; Cialdini & Goldstein 2004; Broockman & Kalla 2016; Grimmer, - Messing & Westwood 2012; Wood & Porter 2019; Lau, Sigelman & Rovner 2007; - Ecker et al. 2022; Debunking Handbook 2020. diff --git a/.opencode/agent/briefing-capacitacao-solla.md b/.opencode/agent/briefing-capacitacao-solla.md index 3ec3b72b..b657e7f7 100644 --- a/.opencode/agent/briefing-capacitacao-solla.md +++ b/.opencode/agent/briefing-capacitacao-solla.md @@ -18,11 +18,20 @@ dossiê daquele recorte estar pesquisado. `gaps[]` são lacunas explícitas) e, quando existir, o `.narrative.json`. 2. Escrever `data/dossie-solla-/.briefing.json` no contrato da - skill: `unitId` + slug do recorte, `generatedAt`, `lede`, `essential` - (≥3, cada item `factId` **ou** `gapReason`), `defenses` (opcional), - `script.steps` (≥3), `qa` (≥4, com os dois lados), `avoid` (≥3) e - `checklist.beforeAnswer`/`unsure` (≥2 cada; marque em `**negrito**` o - prefixo de varredura, ex. `"**Fonte e data** do fato que pretende usar."`). + skill, **todo de recorte** (o briefing não tem capa, roteiro, anti-padrões + nem limites): + - `lede` — princípios e crenças do recorte (≤380 chars, abre a folha 1); + - `defenses` — **6–10** posições de Solla no recorte, cada uma com título, + leitura e âncora (`factId` real de item com fonte, ou `gapReason`); sem + registro = lacuna, nunca posição genérica; + - `essential` (≥3) — fatos-âncora que sustentam a conversa (sem trajetória de + formação), com a fase junto ao valor; + - `plan` — **uma linha** (≤220 chars) de plano de voto/compromisso nomeado + (o pedido literal é do renderer, não o reescreva); + - `qa` (≥4; mire **8–10**) — perguntas prováveis com `side` + (`direita|esquerda|entrega`), `question`, `acknowledge`, `answer` (ancorada + em `factId` real ou declarada como lacuna) e `close`, cobrindo os dois + lados. 3. Devolver **apenas o recibo curto** (seção "Recibo do autor" da skill). ## Limites @@ -30,6 +39,10 @@ dossiê daquele recorte estar pesquisado. - **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. + Lembre: o id de checklist **se repete** e o ledger resolve o `factId` puro pelo + **primeiro item** daquele id — quando a nota se apoia em outro item, copie o + `sourceUrl` do item do research no campo `sourceUrl` do âncora (o par + `factId` + `sourceUrl` tem de bater; par errado falha fechado). - 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 @@ -38,7 +51,7 @@ dossiê daquele recorte estar pesquisado. (`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. + entrega; o pedido literal do voto 1313 é do renderer. - 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 diff --git a/docs/changelog/2026-09-23-c214.md b/docs/changelog/2026-09-23-c214.md new file mode 100644 index 00000000..8d48902f --- /dev/null +++ b/docs/changelog/2026-09-23-c214.md @@ -0,0 +1 @@ +**C214 — Briefing C210 replanejado: 4 folhas 100% recorte (2026-09-23):** o Briefing de capacitação da família de dossiês (cidade/instituição/tema) foi replanejado a partir da crítica de produto: as 4 folhas fixas deixaram de ter capa/objetivo/trajetória e recomendações genéricas (roteiro de 5 passos, justificativa científica, "o que evitar", "o que conferir", "se você não souber" e "limites/defeso") e passaram a ser **todas do recorte** — `defesas` (princípios/crenças + posições com fonte), `essencial` (fatos-âncora com fase + o pedido literal e uma linha de plano), `qa` e `qa-2` (perguntas prováveis, metade do documento, 1/2 e 2/2). O contrato do `briefing.json` mudou: `script.steps`/`avoid`/`checklist` saíram, entrou `plan` (1 linha), `defenses` virou a folha de abertura e o Q&A foi ampliado (8–10 no alvo); o shed determinístico agora é `qa → defesas`, com essencial/pedido nunca cortados. O dono `scripts/lib/briefingContent.mjs` ganhou **âncora item-level**: como o id de checklist se repete entre itens e o ledger resolvia pelo primeiro, o âncora passou a aceitar `sourceUrl` (copiado do item do research) para resolver o par exato `factId` + `sourceUrl` — par errado falha fechado (o Q&A não repassava o `sourceUrl`, corrigido junto). O design hi-fi `docs/plans/briefing-capacitacao-solla-ui-design.html` foi refeito pelo designer (4 cenas novas, classes `.principles-copy`/`.request-block`/`.request-plan`; removidas `.lede`/`.script-list`/`.avoid-list`/`.check-list`/`.two-column`/`.limits`/`.sheet-compact`), a skill e o agente autor foram atualizados e os specs `briefingContent`/`briefingRender`/`briefingSkill` reescritos. O briefing da UFBA foi re-autorado (10 defesas, 8 fatos-âncora, 12 perguntas, âncoras item-level auditadas) e sai em 4 páginas sem cortes. Sem `src/`, sem schema/migration/rota; `pnpm gate:fast` verde (lint, typecheck, 389 arquivos / 4310 testes). Issue: #1284. diff --git a/docs/plans/briefing-capacitacao-solla-impl.md b/docs/plans/briefing-capacitacao-solla-impl.md index ed4cfa9b..91394df0 100644 --- a/docs/plans/briefing-capacitacao-solla-impl.md +++ b/docs/plans/briefing-capacitacao-solla-impl.md @@ -1,5 +1,7 @@ # Impl: Briefing de capacitação Solla 1313 — até 4 páginas para quem vai pedir voto no recorte (cidade · instituição · tema) +> **Replanejado em 2026-09-23** (4 folhas 100% recorte; âncora item-level por `sourceUrl`; `plan` no lugar do roteiro; sem evitar/conferir/limites): ver `docs/changelog/2026-09-23-c210-replan.md`, a skill atualizada e o design `docs/plans/briefing-capacitacao-solla-ui-design.html`. Este plano fica como histórico do desenho original (C210). + Status: aprovado Atualizado em: 2026-09-22 Issue: #1252 diff --git a/docs/plans/briefing-capacitacao-solla-ui-design.html b/docs/plans/briefing-capacitacao-solla-ui-design.html index 63684944..3379e51e 100644 --- a/docs/plans/briefing-capacitacao-solla-ui-design.html +++ b/docs/plans/briefing-capacitacao-solla-ui-design.html @@ -4,7 +4,7 @@ - Design — Briefing de capacitação Solla + Design — Briefing de capacitação Solla · defesas, essencial e Q&A +Briefing de capacitação — ${htmlEscape(name)} ${parts.join('\n')} @@ -309,31 +249,27 @@ export const renderBriefingMd = (content, { unit, report }) => { if (content.subtitle) lines.push(`- **Uso:** ${content.subtitle}`) lines.push( '', - '## O essencial do recorte', + '## O que Solla defende', '', 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', + '## O essencial do recorte', + '', + ...content.essential.map((item) => `- **${item.title}** — ${item.note} (${anchorMd(item)})`), + '', + '## O pedido', '', '> “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}`), + `**Plano:** ${content.plan}`, '', '## Perguntas prováveis × melhores respostas', '', - `**Régua visível:** ${RULER_COPY}`, + `**Régua:** ${RULER_COPY}`, '', ...content.qa.flatMap((item) => [ `### ${QA_SIDE_LABELS[item.side] ?? item.side} — ${item.question}`, @@ -343,24 +279,6 @@ export const renderBriefingMd = (content, { unit, report }) => { `- **Fechar:** ${item.close}`, '', ]), - '## O que evitar', - '', - ...content.avoid.map((item) => `- **${item.title}** ${item.note}`), - '', - '## O que conferir no dossiê', - '', - ...content.checklist.beforeAnswer.map((item) => `- ${item}`), - '', - '## Se você não souber', - '', - ...content.checklist.unsure.map((item) => `- ${item}`), - '', - '## Limites e defeso', - '', - limitsCopyA(name), - '', - LIMITS_COPY_B, - '', `_Documento interno de campanha, datado (${formatDateTimeBr(content.generatedAt)}). O dado envelhece: confira a data do dossiê._`, ) return `${lines.join('\n')}\n` diff --git a/tests/unit/briefingContent.unit.spec.ts b/tests/unit/briefingContent.unit.spec.ts index c25d5745..29416677 100644 --- a/tests/unit/briefingContent.unit.spec.ts +++ b/tests/unit/briefingContent.unit.spec.ts @@ -15,9 +15,11 @@ import { } 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. +// C210 (replanejado 2026-09-23): the authored briefing may only anchor on the +// dossiê ledger (facts with source) and is 100% recorte — princípios/crenças, +// defesas, fatos-âncora, uma linha de plano e Q&A. 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 = { @@ -73,20 +75,17 @@ const validRaw = (overrides: Record = {}) => ({ municipalitySlug: 'ilheus', generatedAt: generatedAt.toISOString(), subtitle: 'Litoral Sul · consulta antes e durante o contato', - lede: 'Use um fato local de cada vez.', + lede: 'Universidade pública não é gasto: é projeto de país.', + defenses: [ + { factId: 'era_c_atuacao', title: 'Ensino superior', note: 'Defesa regional.' }, + { factId: 'era_b_programas', title: 'Saúde da Família', note: 'Defesa local.' }, + ], 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.' }, - ], - }, + plan: 'Combine onde, quando e como votar e registre o compromisso nomeado.', qa: [ { side: 'direita', @@ -129,17 +128,6 @@ const validRaw = (overrides: Record = {}) => ({ 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, }) @@ -152,7 +140,10 @@ describe('normalizeBriefingContent', () => { 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.defenses).toHaveLength(2) + expect(content.defenses[0].fact?.sourceUrl).toBe('https://camara.test/ufba') expect(content.qa).toHaveLength(5) + expect(content.plan).toMatch(/compromisso nomeado/) expect(content.warnings).toEqual([]) }) @@ -161,9 +152,10 @@ describe('normalizeBriefingContent', () => { expect(() => normalize(validRaw({ municipalitySlug: 'itabuna' }))).toThrow(/não é "ilheus"/) }) - it('requires generatedAt and a lede', () => { + it('requires generatedAt, a lede and the one-line plan', () => { expect(() => normalize(validRaw({ generatedAt: 'ontem' }))).toThrow(/generatedAt inválido/) expect(() => normalize(validRaw({ lede: '' }))).toThrow(/"lede" obrigatório/) + expect(() => normalize(validRaw({ plan: '' }))).toThrow(/"plan" obrigatório/) }) it('demands exactly one of factId | gapReason per item', () => { @@ -218,16 +210,15 @@ describe('normalizeBriefingContent', () => { ), }), ).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/) + expect(() => normalize({ ...raw, qa: [] })).toThrow(/qa: mínimo de 4/) + expect(() => normalize({ ...raw, essential: [] })).toThrow(/essential: mínimo de 3/) }) it('reports over-cap text as a warning, never as a hard failure', () => { const raw = validRaw() - const content = normalize({ ...raw, lede: 'a'.repeat(500) }) + const content = normalize({ ...raw, lede: 'a'.repeat(500), plan: 'b'.repeat(300) }) expect(content.warnings.join('\n')).toMatch(/lede: 500 chars/) + expect(content.warnings.join('\n')).toMatch(/plan: 300 chars/) }) it('resolves anchors through the unit slugField (theme seam)', () => { @@ -267,6 +258,48 @@ describe('normalizeBriefingContent', () => { ).toThrow(/não resolve em fato com fonte/) }) + it('resolves a repeated id through the item sourceUrl and fails closed on a wrong pair', () => { + const duplicated = [ + { + id: 'era_b_equipamentos', + sourceUrl: 'https://saude.test/ambulancia-2', + sourceDate: '2019-01-01', + }, + ...facts, + ] + const raw = validRaw({ + essential: [ + { + factId: 'era_b_equipamentos', + sourceUrl: 'https://saude.test/ambulancia-2', + title: 'Ambulância nova', + note: 'Registro de 2019.', + }, + { 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.', + }, + ], + }) + const content = normalizeBriefingContent(raw, { + unit: MUNICIPALITY_UNIT, + slug: 'ilheus', + facts: duplicated, + }) + expect(content.essential[0].fact?.sourceDate).toBe('2019-01-01') + const wrongPair = (raw.essential as Record[]).map((item, index) => + index === 0 ? { ...item, sourceUrl: 'https://saude.test/nao-existe' } : item, + ) + expect(() => + normalizeBriefingContent( + { ...raw, essential: wrongPair }, + { unit: MUNICIPALITY_UNIT, slug: 'ilheus', facts: duplicated }, + ), + ).toThrow(/não resolve em fato com fonte/) + }) + it('reports qa caps with the real field names, once each', () => { const raw = validRaw() const qa = (raw.qa as Record[]).map((item, index) => @@ -291,28 +324,43 @@ describe('briefingAnchorFact', () => { ) expect(briefingAnchorFact(facts, 'não-existe')).toBeNull() }) + + it('disambiguates a repeated id by the exact sourceUrl', () => { + const duplicated = [ + { + id: 'era_b_programas', + sourceUrl: 'https://saude.test/programas', + sourceDate: '2013-05-10', + }, + { + id: 'era_b_programas', + sourceUrl: 'https://saude.test/programas-2', + sourceDate: '2020-01-01', + }, + ] + expect(briefingAnchorFact(duplicated, 'era_b_programas')?.sourceDate).toBe('2013-05-10') + expect( + briefingAnchorFact(duplicated, 'era_b_programas', 'https://saude.test/programas-2') + ?.sourceDate, + ).toBe('2020-01-01') + expect(briefingAnchorFact(duplicated, 'era_b_programas', 'https://nope.test')).toBeNull() + }) }) describe('trimBriefing (deterministic shed order)', () => { const content = normalize() - it('drops qa first, then defenses, then checklist, then avoid', () => { + it('drops qa first, then defenses, then stops', () => { const first = trimBriefing(content)! expect(first.qa).toHaveLength(BRIEFING_MINIMUMS.qa) - expect(first.shed).toEqual({ qa: 1, defenses: 0, checklist: 0, avoid: 0 }) + expect(first.shed).toEqual({ qa: 1, defenses: 0 }) const second = trimBriefing(first)! - expect(second.defenses).toHaveLength(0) - expect(second.shed).toEqual({ qa: 1, defenses: 1, checklist: 0, avoid: 0 }) + expect(second.defenses).toHaveLength(1) + expect(second.shed).toEqual({ qa: 1, defenses: 1 }) 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) + expect(third.defenses).toHaveLength(0) + expect(third.shed).toEqual({ qa: 1, defenses: 2 }) + expect(trimBriefing(third)).toBeNull() }) it('never silences a required side when shedding qa', () => { @@ -334,7 +382,7 @@ describe('trimBriefing (deterministic shed order)', () => { expect(trimmed.qa.at(-1)?.side).toBe('direita') }) - it('never touches essential or the script and stops at the minimums', () => { + it('never touches the essential, the principles copy or the plan', () => { let current = content let next = trimBriefing(current) let guard = 0 @@ -345,11 +393,9 @@ describe('trimBriefing (deterministic shed order)', () => { } 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(current.lede).toBe(content.lede) + expect(current.plan).toBe(content.plan) expect(next).toBeNull() }) }) diff --git a/tests/unit/briefingRender.unit.spec.ts b/tests/unit/briefingRender.unit.spec.ts index ed24d47c..12202aa7 100644 --- a/tests/unit/briefingRender.unit.spec.ts +++ b/tests/unit/briefingRender.unit.spec.ts @@ -9,8 +9,9 @@ import { } 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 +// C210 (replanejado 2026-09-23): the briefing is the approved gate design ported +// to inline print CSS — four fixed recorte sheets (defesas → essencial + pedido +// → qa 1/2 → qa 2/2), 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. @@ -47,12 +48,16 @@ const research = mergeDossierResearch([ const report = buildDossierReport({ snapshot, research, generatedAt }) const facts = report.bulletinFacts -const rawContent = (qaCount = 5, avoidCount = 5) => ({ +const rawContent = (qaCount = 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.', + lede: 'Universidade pública não é gasto: é projeto de país.', + defenses: [ + { factId: 'era_b_programas', title: 'Ensino superior', note: 'Defesa regional.' }, + { factId: 'era_b_equipamentos', title: 'Saúde perto de casa', note: 'Defesa local.' }, + ], essential: [ { factId: 'era_b_equipamentos', @@ -62,14 +67,7 @@ const rawContent = (qaCount = 5, avoidCount = 5) => ({ { 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.' }, - ], - }, + plan: 'Combine onde, quando e como votar e registre o compromisso nomeado.', qa: Array.from({ length: qaCount }, (_value, index) => ({ side: index % 2 === 0 ? 'direita' : 'esquerda', question: `Pergunta ${index}`, @@ -78,14 +76,6 @@ const rawContent = (qaCount = 5, avoidCount = 5) => ({ 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(), { @@ -97,8 +87,8 @@ const content = normalizeBriefingContent(rawContent(), { 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']) { + it('renders exactly the four fixed recorte sheets the page guard counts', () => { + for (const anchor of ['defesas', 'essencial', 'qa', 'qa-2']) { expect(html).toContain(`data-page="${anchor}"`) } expect((html.match(/class="sheet/g) ?? []).length).toBe(4) @@ -111,9 +101,30 @@ describe('renderBriefingHtml', () => { } }) - it('renders the literal request with the vote number', () => { + it('opens with the principles copy and the defenses, not a cover', () => { + const firstSheet = html.split('data-page="essencial"')[0] + expect(firstSheet).toContain('Princípios e crenças') + expect(firstSheet).toContain('Universidade pública não é gasto: é projeto de país.') + expect(firstSheet).toContain('O que Solla defende') + expect(firstSheet).not.toContain('Como usar') + }) + + it('renders the literal request with the vote number and the one-line plan', () => { expect(html).toContain('vote 1313, Jorge Solla') expect(html).toContain('request-line') + expect(html).toContain('request-block') + expect(html).toContain('compromisso nomeado') + }) + + it('splits the qa list across the two last sheets', () => { + const firstQa = html.split('data-page="qa"')[1].split('data-page="qa-2"')[0] + const secondQa = html.split('data-page="qa-2"')[1] + expect((html.match(/class="qa-item"/g) ?? []).length).toBe(5) + expect(firstQa).toContain('Pergunta 0') + expect(firstQa).toContain('Pergunta 2') + expect(firstQa).not.toContain('Pergunta 4') + expect(secondQa).toContain('Pergunta 3') + expect(secondQa).toContain('Pergunta 4') }) it('links every anchored item to its sourced fact and marks the lacuna', () => { @@ -134,14 +145,6 @@ describe('renderBriefingHtml', () => { 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: [] }, @@ -152,16 +155,10 @@ describe('renderBriefingHtml', () => { ) }) - it('keeps the guard copy of the unit on the first and last sheets', () => { - expect(html).toContain('região e polo não são somados ao município') - expect(html).toContain('Não publicar, encaminhar como peça') - }) - - it('bold-prints the checklist scan prefix and names the recorte in the kicker', () => { - expect(html).toContain('Fonte e data do fato') - expect(html).toContain('Diga com clareza: não tenho') + it('names the recorte in the kicker and the footer', () => { expect(html).toContain('Briefing de capacitação · Ilhéus') expect(html).toContain('Briefing de capacitação · cidade') + expect(html).toContain('Recorte: cidade · Ilhéus') }) }) @@ -170,7 +167,7 @@ describe('renderBriefingMd (companion superset)', () => { 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) + expect(trimmed.defenses.length).toBeLessThan(content.defenses.length) const droppedQuestion = content.qa.find( (item: { question: string }) => !trimmed.qa.some((kept: { question: string }) => kept.question === item.question), @@ -181,13 +178,14 @@ describe('renderBriefingMd (companion superset)', () => { 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('**Plano:** Combine onde, quando e como votar') 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).toContain('## O que Solla defende') + expect(md).toContain('## O essencial do recorte') + expect(md).toContain('## O pedido') + expect(md).toContain('## Perguntas prováveis × melhores respostas') + expect(md).not.toContain('## O que evitar') + expect(md).not.toContain('## Limites e defeso') expect((md.match(/^### /gm) ?? []).length).toBe(5) }) })