feat(C188): relatórios e dossiês sem truncamento mecânico

Dono único de orçamento de texto em reportText.mjs (capList,
excerptDeepDive, joinWithRemainder, summaryText/summarySurfaceText,
summaryListText), reusado por C163 e C186. Contrato summary por item nos
dois arquivos de pesquisa; superfície de resumo nunca corta com "…" e
aponta o aprofundamento; toda lista capada mostra contador; builders com
2 passadas guard-gated (integral → ponteiro → die fail-closed).

Closes #1136
This commit is contained in:
Francisco Solla 2026-09-17 18:57:03 -03:00
parent cb5854d17e
commit df3f3160a9
23 changed files with 1085 additions and 238 deletions

View file

@ -151,6 +151,7 @@ cruza para ele.
{
"id": "era_c_emendas", // id do checklist da era
"answer": "registro integral; aceita {{fonte}} / {{fonte:N}} — vai para o .md e para o lastro",
"summary": "opcional — versão curta, completa e auto-contida do answer, redigida para caber no resumo/boletim (C188)",
"details": "opcional; aceita {{fonte}} / {{fonte:N}}",
"brief": { // obrigatório no item publicado: copy reformulada, curta
"title": "≤80 chars — manchete (o quê + onde)",
@ -187,6 +188,18 @@ Item sem `sourceUrl`/`sourceDate`, item ausente ou item de outra era vira
(default `nao_informado`). O `bulletinFacts` (ledger do boletim) só é populado
por item **com fonte** — o boletim não introduz fato novo.
**`summary` (C188):** o resumo de uma olhada e o boletim **nunca cortam texto no
meio**. Essas superfícies imprimem o `summary` do item quando existe; sem ele,
imprimem o `answer` integral e, se a página não couber, o builder re-renderiza
apontando o aprofundamento (as páginas das eras, que sempre guardam
`answer`/`details` integrais) — **sem "…"**. Escreva `summary` curto e
auto-contido para os itens que viram entrega/gancho no resumo; é a única forma de
o texto que você redigiu aparecer no resumo. Listas capadas (entregas, ganchos,
números, itens regionais, fatos do boletim) sempre exibem o contador `e mais N` /
`Mostrando X de Y` — nenhum item some em silêncio. `summary` e `brief` coexistem:
o `brief` é a copy reformulada que o PDF imprime nas páginas de era/cartões; o
`summary` alimenta as superfícies de resumo do dossiê e o ledger do boletim.
**`brief` (copy reformulada, sem reticências).** As páginas A4 têm altura fixa:
em vez de cortar o texto com `…`, cada item publicado traz um `brief` **reescrito
para caber** (`title` ≤80 / `note` ≤120) preservando o essencial (o quê, onde,
@ -198,6 +211,7 @@ a guarda de fit A4 do builder **falha fechado** (não corta em silêncio). Tabel
longas (lacunas, notícias) não usam `brief`: são **paginadas** em folhas de
continuação com o texto inteiro.
## Conteúdo do dossiê
- **Capa** — série municipal, "INSUMO INTERNO", território, região/polo

View file

@ -140,6 +140,7 @@ de pesquisa que cruza para ele.
{
"id": "prefeito", // id do checklist (abaixo)
"answer": "Nome (Partido), situação",
"summary": "opcional — versão curta, completa e auto-contida do answer, redigida para caber na página 1 (C188)",
"details": "opcional",
"sourceUrl": "https://…", // obrigatório em item publicado
"sourceDate": "2026-09-10", // obrigatório
@ -316,9 +317,15 @@ cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
que a campanha cumpre) com a advertência de defeso, riscos e pontos sem
leitura. O painel fixo "O que NÃO anunciar" **saiu** da página 1 (copy
invariante; o guardrail de defeso permanece na nota do painel de anúncio). Os
textos livres da pesquisa entram **capados** (teto/orçamento de conteúdo, não
layout espremido) e as listas mostram `e mais N` — o texto integral de cada
item fica no aprofundamento (ver "respostas integrais" em `14. Fontes e
textos livres da pesquisa **nunca são cortados no meio** (C188): a página 1
imprime o `summary` do item quando existe; sem ele, imprime o `answer`
integral e, se a página não couber, o builder re-renderiza apontando o
aprofundamento — **sem "…"**. Por isso, escreva `summary` curto e auto-contido
para os itens que aparecem na página 1 (`prefeito`, `vice`, `relacao_campo`,
`vereadores`, `disputa_local`, `quem_investe`, `emendas_web`): é a única forma
de a página 1 manter o texto que você redigiu. As listas mostram `e mais N`; o
texto integral
de cada item fica no aprofundamento (ver "respostas integrais" em `14. Fontes e
limites`).
- **Seções 2+:** `1. Conta eleitoral` · `2. Concorrentes no município (federal
e estadual)` — top 5 por votos de 2022 na base TSE, com série 2014/2018/2022

View file

@ -0,0 +1 @@
**C188 — Relatórios e dossiês sem truncamento mecânico (2026-09-17):** o C163 cortava texto no meio da frase com "…" e o C186 descartava itens de lista em silêncio — informação sumia na última milha. Agora a regra é explícita: **superfície de resumo não corta**; lista capada **sempre** mostra contador; o integral fica no aprofundamento; a guarda de fit segue fail-closed. O orçamento de texto ganhou um dono único (`scripts/lib/reportText.mjs`, reusado por C163 e C186): `capList` (`{items,total,remaining}`), `excerptDeepDive` (o único que pode imprimir "…", só no aprofundamento), `joinWithRemainder`, `summaryText`/`summarySurfaceText` e `summaryListText`. Contrato novo de pesquisa: cada item aceita **`summary`** — curto, completo e auto-contido, redigido pelo researcher para caber no orçamento do resumo (`cityReportResearch.mjs`, `dossieResearch.mjs`); sem ele, prefere o `answer` integral e **amplia o orçamento** enquanto a guarda permitir. Os builders passam a medir a página de resumo em duas passadas (texto integral → ponteiro para o aprofundamento **sem "…"** → `die` fail-closed) e cada superfície capada declara o resto: entregas/ganchos/lacunas/números/ações/itens regionais no dossiê, escopos município×região (`region.evidence`), "e mais N fatos com fonte" no boletim e "Mostrando X de Y" nas tabelas do C163. As skills `relatorio-cidade` e `dossie-solla-cidade` documentam o `summary`. Débitos do review (2-pass do dossiê, ponteiros de escopo, paridade de helpers) registrados em #1139 (C189). Skills/scripts/testes apenas — sem schema, migration, URL pública, Consent ou LGPD. `pnpm gate:fast` + knip + cycles + format verdes (347 arquivos / 3638 testes). Issue: #1136.

View file

@ -0,0 +1,73 @@
# Escala/DRY: débitos do C188 (relatórios/dossiês sem truncamento)
Status: rascunho
Atualizado em: 2026-09-17
Issue: #1139 (C189)
Priority: P3
Kind: chore
Depends: #1136 (C188)
Appetite: ~0,5–1 dia eng
Impeccable: A — scripts-only, sem superfície de UI
## O débito (colhido no /simplify do C188)
Três resíduos de baixo risco no dono de texto/orçamento que o C188 acabou de
consolidar. Nenhum é bug funcional (a guarda fail-closed continua abortando);
são paridade e honestidade de mensagem.
1. **2-pass do dossiê mede toda página (O1).** `build-dossie-solla-cidade.mjs`
mede `[data-page]` inteiro; a passada de ponteiro só reescreve o resumo. Uma
página não-resumo estourada dispara um rebuild de ponteiro inútil e depois
morre com log que diz "re-renderizando com ponteiro" — mentira de fluxo. O
C163 mede só `[data-page="summary"]`. Paridade: chavear o gatilho do dossiê em
`[data-page="resumo"]` e reportar a página ofensora direto no `die`.
2. **Ponteiros de escopo/região pouco honestos (O3 + R1).** O ponteiro municipal
das duas listas de escopo manda "ver a tabela de evidências abaixo" — que é
só regional. E o MD do `era.actions` ainda diz "tabela de respostas da era"
(superfície que não existe no dossiê; o HTML irmão já diz "arquivo de pesquisa
da era"). Declarar/alinhar os cortes escopo (5) × evidência (4) e usar a
mesma copy no HTML e no MD.
3. **Paridade de helpers e da skill (O5 + S10b).** `moreItemsLabel`/
`showingLabel` mantêm defaults de singular/plural que todo call site
sobrescreve (generalidade especulativa). E a skill `relatorio-cidade` lista os
itens de página 1 que precisam de `summary` e omite `vereadores`, que
`buildWhoRows` imprime no resumo.
## Appetite e rabbit holes
- Tocar `build-dossie-solla-cidade.mjs` e `dossieRender.mjs` reusados pelo
**C187 (#1135)** — coordenar: F1 pousa antes/junto do C187.
- Rabbit hole: transformar o ajuste de medição em refactor dos dois builders
(NÃO unificar os loops; só alinhar o gatilho e a mensagem). Não abrir apêndice
de respostas por era (ver Explicitamente fora).
- Sem schema, migration, URL pública, Consent/LGPD.
## Fases (ROI)
1. **F1 (O1)** — 2-pass do dossiê keyed em `[data-page="resumo"]`, com a página
ofensora na mensagem do `die`.
2. **F2 (O3+R1)** — ponteiro municipal → "ver as páginas das eras"; alinhar
escopo 5 × evidência 4 (ou declarar o corte duplo); MD `era.actions` →
"arquivo de pesquisa da era".
3. **F3 (O5+S10b)** — singular/plural obrigatórios em `moreItemsLabel`/
`showingLabel`; incluir `vereadores` na lista de `summary` da página 1 em
`.agents/skills/relatorio-cidade/SKILL.md`.
## Já resolvido no simplify do C188 (não reabrir)
- Dono único `summarySurfaceText` (regra summary→answer→ponteiro) substituindo o
geminado C163/C186; `summaryListText` + tetos de caractere para listas
não-pesquisa; `region.evidence` + contadores de escopo no HTML e no MD;
`capList` no `gapCallout`/`emendasIndicators`; comentários OPS120 obsoletos;
nota do teto de coleta da Câmara no `eraMethod.C`; testes negativos de "…",
invariante do boletim e caso ponteiro+summary.
## Explicitamente fora (defers do triage do C188)
- **Apêndice "respostas por era" no dossiê (O2).** `era.numbers`/`era.actions`
continuam capados com contador, e o integral vive no JSON de pesquisa (não no
PDF). É decisão de produto/design (layout novo, Impeccable C), fora do appetite
do C188. **Gatilho:** quando um dossiê real perder um número/ação de era que a
comunicação precise citar, ou quando o C187 institucional exigir o integral.
- **`factsTotal` no boletim (O4).** Mantido: é a âncora do invariante
`highlights + moreItems + factsRemaining === factsTotal` do spec do boletim.

View file

@ -0,0 +1,159 @@
# Impl: Relatórios e dossiês sem truncamento mecânico — reformular para caber ou ampliar o campo
Status: aprovado
Atualizado em: 2026-09-17
Issue: #1136
Intenção: docs/plans/relatorios-sem-truncamento.md
Appetite restante: herdado (~1–2 dias eng)
Design UI: N/A — superfície inalterada; alvo é o hi-fi já aprovado do C186 e do C163 (Impeccable B)
## Leitura da intenção
- **Outcome:** gerar relatório/dossiê/boletim com texto longo sem **nenhum "…" em superfície de resumo**, sem item sumindo em silêncio (contador em toda lista truncada), com o **integral preservado no aprofundamento** e a **guarda de fit verde** (fail-closed).
- **O que NÃO negociar:** proibido corte mecânico no resumo; contador em toda lista truncada; integral no aprofundamento; guarda de fit fail-closed; **sem redesenho do hi-fi aprovado C163/C186**; o `summary` é redigido pelo researcher — o builder não redige; sem fonte não publica; empenho ≠ pagamento; esfera/abrangência nunca somadas; PII mínima; artefato gitignored; defeso.
- **O que reavaliar:** (a) a "Direção" sugere dono único substituindo `excerpt()` — a hipótese de _onde_ ele vive é decisão de engenharia (D1); (b) a mecânica de "ampliar orçamento enquanto a guarda permitir" precisa de um mecanismo concreto (D5); (c) C157 pode virar sucessor (D-assumida).
**Natureza explícita:** mudança de **`scripts/` + skills**. **Sem Payload schema, sem migration, sem contrato de URL pública, sem Consent/LGPD**; `push:false` intocado; nenhuma collection/global/field tocado.
## Abordagem recomendada
```mermaid
flowchart LR
R["research.json: answer/details integral + summary opcional"] --> B["build CityReport / DossierReport"]
B -->|"item.summary existe"| S["superfície de resumo usa summary"]
B -->|"sem summary"| A["superfície usa answer integral"]
S --> G{"guarda de fit: scrollHeight > orçamento?"}
A --> G
G -->|"cabe"| P["render PDF"]
G -->|"estoura"| F["2ª passada textFallback=pointer:<br/>linha aponta o aprofundamento, SEM ..."]
F --> G2{"guarda de fit"}
G2 -->|"cabe"| P
G2 -->|"estoura"| D["die — fail-closed"]
B --> C["capList: items + total + remaining"]
C --> RC["renderer imprime 'e mais N' / 'Mostrando X de Y'"]
R --> DD["aprofundamento: answer/details integrais"]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — dono único estendido em `reportText.mjs`, `summary` opcional por item, contador `{items,total,remaining}`, fallback de ponteiro sem "…", e ampliação decidida pela guarda em 2 passadas.
**Rejeitadas:** ver D1–D5.
### Decisões de engenharia
**D1 — Onde vive o dono único de orçamento/composição de texto (substitui o `excerpt()` do C163)?**
Opções: A) estender `scripts/lib/reportText.mjs` (contrato compartilhado de texto impresso, já "one owner, two consumers" para C163+C186) | B) módulo novo `scripts/lib/reportBudget.mjs` | C) manter em `cityReportBlocks.mjs` e importar no C186.
Recomendação: **A** — `reportText.mjs` já é o dono declarado do texto impresso consumido pelos dois relatórios; adicionar `capList`/`excerptDeepDive`/`joinWithRemainder`/`summaryText` ali **estende o dono** em vez de gemar, e mantém o módulo puro (não importa modelo de blocos).
Rejeitadas: **B** porque cria um segundo lugar para o mesmo conceito ("texto compartilhado"), exatamente o twin que o AGENTS proíbe; **C** porque faria o C186 (dossiê) depender do modelo de blocos do C163 — inversão de dependência e grafo pesado.
**D2 — Forma e fallback do `summary`.**
Opções: A) `summary` opcional em `items[]` dos dois contratos de pesquisa, sem fonte própria (herda a do item); builder prefere `summary`, senão `answer` | B) `summary` global por arquivo | C) `*Summary` em todo campo livre (`approach`, `leaders`, `leaderAgenda`, `emendasIndicators[].purpose`…).
Recomendação: **A** — resolve na origem e dá ao researcher o controle editorial; é o menor contrato que cobre as superfícies de resumo; herda a fonte do item (não é fato novo, não passa por "sem fonte não publica" de novo).
Rejeitadas: **B** porque o próprio plano de intenção rejeita global ("summary por item, auto-contido por superfície"); **C** porque explode contrato/validador/testes por ganho marginal — `purpose` de indício segue sem corte, coberto pelo fallback de linha.
**D3 — Forma do contador (toda lista truncada).**
Opções: A) `capList(list, max)` devolve `{ items, total, remaining }`; renderizadores imprimem a copy que já existe no repo ("e mais N" em listas; "Mostrando X de Y" em tabelas/painéis) | B) só `remaining` | C) só `total`/`shown`.
Recomendação: **A** — reusa as três formas já impressas (indicatorList, table note, region scope), não perde o total e cobre corte por desenho e por lista.
Rejeitadas: **B** porque não sustenta "Mostrando X de Y" onde o corte é por desenho; **C** porque perde o "e mais N" das listas.
**D4 — O que fazer quando nem `summary` nem o `answer` cabem.**
Opções: A) linha-ponteiro **sem reticências** ("Texto integral no aprofundamento — <seção>") | B) cortar com "…" | C) deixar a guarda abortar.
Recomendação: **A** — literal do plano de intenção ("a linha aponta o aprofundamento sem reticências"); o integral já vive no aprofundamento.
Rejeitadas: **B** porque viola o outcome; **C** porque a guarda fail-closed é a última linha de defesa, não a primeira.
**D5 — Mecânica de "ampliar o orçamento enquanto a guarda permitir".**
Opções: A) 2 passadas guard-gated: (1) `summary ?? answer` integral; se a guarda estourar, (2) rebuild com `textFallback:'pointer'` (só campos sem `summary` viram ponteiro; o `summary` do researcher é preservado); ainda estourou → `die` | B) descida em 3 degraus (integral → caps de referência → ponteiro) | C) manter caps fixos atuais e só trocar "…" por ponteiro.
Recomendação: **A** — a guarda é o teto real ("amplia enquanto permitir"); a 2ª passada cobre o caso comum (researcher sem `summary`) sem corte mecânico; determinístico, testável (o modelo aceita a flag, o builder orquestra as 2 medições). O `die` final permanece e, quando dispara com `summary` presente, aponta o arquivo de pesquisa a encurtar.
Rejeitadas: **B** porque custa mais uma medição/estado para ganho marginal; **C** porque mantém o corte mecânico e não honra "prefere `answer`".
### Componentes / mudanças
- **`scripts/lib/reportText.mjs`** (dono único; atualiza o header para declarar orçamento/composição): ganha `capList(list, max) → {items,total,remaining}`, `excerptDeepDive(text, max)` (corte explícito com "…", **só aprofundamento**), `joinWithRemainder(values, limit)` (junção por contagem, sempre "e mais N", **sem** corte de caractere), `summaryText(item) → {text, fromSummary}` e `SUMMARY_POINTER_COPY`. Puro, sem modelo de blocos. Substitui o `excerpt()` do C163.
- **`scripts/lib/cityReportResearch.mjs`** e **`scripts/lib/dossieResearch.mjs`**: `items[]` passa a aceitar `summary` (`isNonEmptyString → trim`, senão `null`; **não** gera lacuna — é fallback de renderização). `news[].summary` fica como está (não tocado).
- **`scripts/lib/cityReportBlocks.mjs`**: remove `excerpt()`; todos os call-sites de página 1 (`:158,198,232,236,248,255,288,310/311,322,350,368,382`) usam `summaryText(item)`; `joinLimited` → `joinWithRemainder`; slices silenciosos do aprofundamento (`:870` sinal, `:1097` menção, `:1113` sumário de fala) → `excerptDeepDive`; `buildCityReport({ textFallback = 'full' })` — em `'pointer'`, campos sem `summary` imprimem o ponteiro. Integral permanece em `buildResearchAnswersTable`.
- **`scripts/build-city-report.mjs`**: loop de 2 passadas (build → render → medir `[data-page="summary"]`; se `> PAGE_ONE_BUDGET_PX`, rebuild com `textFallback:'pointer'`; se ainda estourar → `die`). Tolerância/budget atuais intocados; mensagem de `die` atualizada.
- **`scripts/lib/dossieBlocks.mjs`**: `limit()` → `capList`; `deliveries/hooks/pending/era.numbers/era.actions/regionItems` carregam `{items,total,remaining}` (fim do descarte silencioso); `buildDossierReport({ textFallback = 'full' })` para os títulos/sumários de página 1.
- **`scripts/lib/dossieRender.mjs`**: `renderDeliveries`/`renderHooksAndPending`/`renderEra`/região imprimem o contador reusando `.meta`/`.muted small` ("Mostrando X de Y — o restante está nas eras"). Região já tem `total` (:308/:317).
- **`scripts/lib/dossieBulletin.mjs`** + **`dossieBulletinRender.mjs`**: boletim passa a expor `remaining`/`total`; renderer imprime "e mais N item(ns) no dossiê" na seção "E mais".
- **`scripts/lib/dossieCamara.mjs`**: `(sumario ?? transcricao).slice(0,280)` → `excerptDeepDive` (aprofundamento, "…" explícito); nota de limite de coleta para o teto de 20 da API (sem inventar total).
- **`scripts/build-dossie-solla-cidade.mjs`**: mesmo loop de 2 passadas para dossiê e boletim; mensagens de `die` atualizadas.
- **Skills**: `.agents/skills/relatorio-cidade/SKILL.md` (§Contrato dos JSONs :131-158 + copy de página 1 :305-322) e `.agents/skills/dossie-solla-cidade/SKILL.md` (§Contrato dos JSONs :141-170 + item/notes :117-139) documentam `summary` por item ("curto, completo e auto-contido, redigido para caber no orçamento da superfície de resumo") e a regra "integral no aprofundamento; sem `summary`, o builder prefere `answer`; nunca há corte no resumo".
- **Migration:** **sem migration** (nenhum schema Payload tocado).
- **Access / Consent:** N/A — sem write path, sem PII, sem auth; nada de `Consent`/`overrideAccess`.
- **UI:** N/A — Impeccable B; encaixe no hi-fi aprovado; sem componente/estado/layout novo. Se algo exigir mudança material de layout, o `designer` entra e volta ao humano no PR.
### Dados → forma (se aplicável)
- **Forma:** contador **textual** reusando padrões existentes — "e mais N" (listas/bullets) e "Mostrando X de Y" (tabelas/painéis/notas) — e ponteiro textual para o aprofundamento no limite. É a menor mudança sobre o hi-fi aprovado e não introduz linguagem visual nova.
- **Rejeitadas:** ícone/`badge` de "há mais" (redesenho e novo vocabulário visual); reticências como marcador de resto (é o defeito a eliminar); paginação/expansão (não cabe em PDF A4 de uma página, é redesign).
## Fases verificáveis
1. **Dono único (tracer)** — `reportText.mjs`: `capList`, `excerptDeepDive`, `joinWithRemainder`, `summaryText` + `SUMMARY_POINTER_COPY`. Novo `tests/unit/reportText.unit.spec.ts`: `capList` devolve `items/total/remaining`; `excerptDeepDive` marca "…" uma única vez e respeita `max`; `summaryText` prefere `summary` e cai para `answer`; **nenhum primitivo de superfície de resumo emite "…"**. Quota ~2–3h.
2. **Contrato `summary`** — `cityReportResearch.mjs` + `dossieResearch.mjs`; casos novos nos specs respectivos (summary trimado, vazio→null, sem lacuna, item segue publicado só com a fonte do `answer`). Skills atualizadas (contrato + copy de página 1). Quota ~2–3h.
3. **C163** — remove `excerpt`, call-sites de página 1 via `summaryText`, `joinWithRemainder`, slices do aprofundamento via `excerptDeepDive`; flag `textFallback`; 2 passadas no `build-city-report.mjs`. Atualiza `tests/unit/cityReportBlocks.unit.spec.ts:702,752,780` (o :752 deixa de esperar `endsWith('…')`) e `tests/unit/cityReportRender.unit.spec.ts:127,166`; novo teste "página 1 sem '…' e integral presente". Quota ~4–5h.
4. **C186 + boletim** — `capList` e contadores em deliveries/hooks/pending/era/region; boletim `remaining`/`total`; `dossieCamara` com corte explícito de aprofundamento; 2 passadas no `build-dossie-solla-cidade.mjs`. Atualiza `tests/unit/dossieBlocks.unit.spec.ts:155` e `tests/unit/dossieBulletin.unit.spec.ts:27,60`; novos testes de contador (nenhum item some; `highlights+moreItems+remaining === total`). Quota ~4–5h.
5. **Gates** — `pnpm lint`, `pnpm typecheck`, `pnpm test:unit` (ou `pnpm gate:fast`); `pnpm knip`; checagem de ciclos do CI; push via `pnpm push`. Quota ~1–2h.
## Rabbit holes / Não escopo (engenharia)
- **Varredura de todo `slice` do repo** — só a família C163/C186.
- **C157** (`build-solla-ceuci-salvador-report.mjs`, self-contained, "…" próprios em :392/:446 e slices :133/:142/:601) — **sucessor**, não este item; gatilho de revisitação: se o dono novo provar drop-in de ~1h no C157, incluir; senão abrir Issue sucessora.
- **Builder redigir/encurtar texto** — proibido; sem `summary`, o texto é integral ou ponteiro; nunca reescrito.
- **`summary` global** — rejeitado (D2).
- **Ampliação como licença para crescer** — a guarda fail-closed é o teto; nada de espremer layout.
- **Confundir "e mais N" (lista) com "…" (frase)** — remediações distintas, ambas tratadas.
- **`news[].summary` validado e nunca renderizado** — não mexer aqui (não é superfície do problema; bullet-in separado se houver demanda).
- **Redesenho do hi-fi C163/C186** — fora; se material, aciona `designer`.
- **Payload schema/migration/URL pública/Consent/LGPD** — nada.
## Riscos e mitigação
- **Página 1 estoura com a pesquisa real** → 2 passadas guard-gated (D5); guarda fail-closed preservada; teste com `answer` longo sem `summary` e com `summary`.
- **Pesquisas existentes sem `summary`** → fallback `answer` integral; `summary` é opt-in, sem regressão e sem lacuna nova.
- **Linha de contador empurra o layout aprovado do C186** → nota curta reusando `.meta`/`.muted small`; se estourar, a guarda aborta e a copy do contador é encurtada antes de qualquer mudança visual; se exigir mudança material → `designer`.
- **Corte explícito com "…" no aprofundamento confundido com resumo** → `excerptDeepDive` só é usado em seções de aprofundamento (sinais, acervo de falas, ações da Câmara); teste de renderer garante ausência no `[data-page="summary"]`.
- **Duplicação C163↔C186** → dono único em `reportText.mjs`; `knip`/ciclos no CI.
- **Testes que hoje afirmam "…"** → atualização deliberada e listada (Fase 3/4), com o novo aceite "sem '…' no resumo".
- **Câmara sem total confiável da API** → contar o que o artefato imprime (era/deliveries) e rotular o limite de coleta; nunca inventar total.
## Decisões assumidas
Registradas do §Questões em aberto da intenção (fluxo `--auto` grava os literais assumidos):
1. **Campo `summary` vs só ampliar orçamento** → **A) `summary` por item + ampliação como fallback** (assumido).
2. **"…" sempre proibido ou só no aprofundamento** → **A) proibir nas superfícies de resumo, manter no aprofundamento** (assumido).
3. **Cobrir C157 agora ou sucessor** → **B) sucessor** (assumido; gatilho de revisitação em Não escopo).
4. **Prioridade P1 vs P2** → **B) P2** (assumido; reavaliar para P1 se o C187 entrar em produção imediatamente).
5. Hipóteses de engenharia assumidas: dono em `reportText.mjs` (D1), contador `{items,total,remaining}` (D3), 2 passadas guard-gated (D5), `summary` herda a fonte do `answer` (D2).
6. Sem Payload schema/migration/URL pública/Consent/LGPD; `push:false` intocado.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: nenhum "…" em superfície de resumo; toda lista truncada com contador; integral no aprofundamento; guarda verde fail-closed; contrato `summary` documentado nas duas skills.
- [ ] Invariantes AGENTS/engineering-standards: "edite o dono, não gema um irmão" (dono único em `reportText.mjs`, reusado por C163+C186); sem Payload/migration/URL/Consent; scripts puros.
- [ ] Testes de domínio previstos (unit) onde os caps/contrato mudam: `cityReportBlocks`, `cityReportRender`, `dossieBlocks`, `dossieBulletin`, `cityReportResearch`/`dossieResearch` e o novo `reportText`.
- [ ] `pnpm gate:fast` (lint + typecheck + unit) e `pnpm knip` verdes; push via `pnpm push`.
- [ ] Nenhuma migration criada; `push:false` inalterado.
## Desvios na execução
- **Boletim sem 2ª passada de ponteiro.** O plano previa o "mesmo loop de 2
passadas para dossiê e boletim". Na execução, o defeito do boletim era o
**descarte silencioso** (teto 20 sem contador), não corte de texto; o texto por
fato já é capado por desenho e a guarda é fail-closed. Então: contador
`factsRemaining` ("e mais N fatos com fonte no dossiê") + `summary ?? answer`
como headline, sem 2ª passada. O dossiê e o C163 mantêm a 2ª passada.
- **Indício de emenda (`emendasIndicators[].purpose`) nunca vira ponteiro.** Não
há cópia do `purpose` no aprofundamento; então ele entra integral (sem "…") e a
guarda fail-closed decide — o researcher encurta se estourar. Coerente com a
decisão D2 ("purpose segue sem corte").
- **Nota do limite de coleta da Câmara no método da Era C**, não num campo novo
de dados (não inventa total): o texto do `eraMethod.C` declara que a coleta é
limitada às páginas consultadas.
- **`region.items`/`era.numbers`/`era.actions` ganham contador, sem paginação.**
O integral dessas listas não vive em outra página; o contador torna o descarte
declarado (fim do silêncio), sem redesenho — conforme o appetite.
## Self-score decision-quality
**Score: 5/5** — (1) decisões caras (dono único, contrato `summary`, contador, fallback, mecânica de ampliação) têm rejeitadas explícitas em D1–D5; (2) cabe no appetite herdado (5 fases, ~14–18h); (3) rabbit holes nomeados e cortados (C157 sucessor, varredura de slices, builder-redator, `news[].summary`); (4) depth check: reusa o dono existente `reportText.mjs` e as três formas de contador já impressas, sem módulo/helper especulativo; (5) o outcome de produto permanece intacto — a engenharia não o reescreveu (nada de "…", integral preservado, guarda fail-closed).

View file

@ -130,38 +130,52 @@ const resolveEmendas = async () => {
}
const emendas = await resolveEmendas()
const report = buildCityReport({ snapshot, research, emendas, generatedAt })
const html = renderReportHtml(report)
const markdown = renderReportMd(report)
const htmlPath = join(CACHE_DIR, `${baseName}.html`)
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
await writeFile(resolve(ROOT, htmlPath), html)
console.log(`[${LABEL}] HTML intermediário → ${htmlPath}`)
await mkdir(resolve(ROOT, outDir), { recursive: true })
const mdFile = join(outDir, `${baseName}.md`)
const pdfFile = join(outDir, `${baseName}.pdf`)
await writeFile(resolve(ROOT, mdFile), markdown)
console.log(`[${LABEL}] companion → ${mdFile}`)
/**
* Renders both artifacts for a given fit fallback. Page 1 never cuts with "…":
* pass 1 prints the researcher's `summary` (or the full answer); if it does not
* fit the one-page guard, pass 2 re-renders with `textFallback: 'pointer'` and
* the lines without a `summary` point to the deep dive (which keeps the
* integral). The guard stays fail-closed if even the pointer does not fit.
*/
const buildArtifacts = (textFallback) => {
const report = buildCityReport({ snapshot, research, emendas, generatedAt, textFallback })
return { report, html: renderReportHtml(report), markdown: renderReportMd(report) }
}
let artifacts = buildArtifacts('full')
const browser = await chromium.launch()
try {
const page = await browser.newPage()
await page.setViewportSize({ width: PRINTABLE_WIDTH_PX, height: PRINTABLE_HEIGHT_PX })
await page.setContent(html, { waitUntil: 'load' })
await page.emulateMedia({ media: 'print' })
const summaryScrollHeight = await page.evaluate(() => {
const element = document.querySelector('[data-page="summary"]')
return element ? element.scrollHeight : null
})
const measureSummary = async () => {
await page.setContent(artifacts.html, { waitUntil: 'load' })
await page.emulateMedia({ media: 'print' })
return page.evaluate(() => {
const element = document.querySelector('[data-page="summary"]')
return element ? element.scrollHeight : null
})
}
let summaryScrollHeight = await measureSummary()
if (summaryScrollHeight === null) die('Bloco de resumo ausente no HTML — renderer quebrado.')
if (summaryScrollHeight > PAGE_ONE_BUDGET_PX) {
die(
`A página 1 estourou (${summaryScrollHeight}px > ${PAGE_ONE_BUDGET_PX}px úteis). ` +
'Corte copy/caps — o resumo TEM de caber em uma página.',
console.log(
`[${LABEL}] página 1 com o texto integral não coube (${summaryScrollHeight}px) — ` +
're-renderizando com ponteiro para o aprofundamento (sem "…").',
)
artifacts = buildArtifacts('pointer')
summaryScrollHeight = await measureSummary()
if (summaryScrollHeight === null) die('Bloco de resumo ausente no HTML — renderer quebrado.')
if (summaryScrollHeight > PAGE_ONE_BUDGET_PX) {
die(
`A página 1 estourou até com o fallback de ponteiro (${summaryScrollHeight}px > ${PAGE_ONE_BUDGET_PX}px úteis). ` +
'Encurte os "summary" da pesquisa ou os itens de lista — o resumo TEM de caber em uma página.',
)
}
}
await page.pdf({
@ -171,7 +185,7 @@ try {
displayHeaderFooter: true,
headerTemplate: '<div></div>',
footerTemplate: `<div style="width:100%;font-family:'Fira Sans','DejaVu Sans',sans-serif;font-size:7pt;color:#a1a1aa;padding:0 12mm;display:flex;justify-content:space-between;">
<span>${report.meta.title} · ${snapshot.municipality.name} · documento interno de campanha</span>
<span>${artifacts.report.meta.title} · ${snapshot.municipality.name} · documento interno de campanha</span>
<span>pág. <span class="pageNumber"></span>/<span class="totalPages"></span></span>
</div>`,
margin: { top: '14mm', bottom: '16mm', left: '12mm', right: '12mm' },
@ -180,6 +194,18 @@ try {
await browser.close()
}
const { html, markdown } = artifacts
const htmlPath = join(CACHE_DIR, `${baseName}.html`)
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
await writeFile(resolve(ROOT, htmlPath), html)
console.log(`[${LABEL}] HTML intermediário → ${htmlPath}`)
await mkdir(resolve(ROOT, outDir), { recursive: true })
const mdFile = join(outDir, `${baseName}.md`)
await writeFile(resolve(ROOT, mdFile), markdown)
console.log(`[${LABEL}] companion → ${mdFile}`)
console.log(`[${LABEL}] PDF → ${pdfFile}`)
console.log(
`[${LABEL}] ${snapshot.municipality.name}: 2022=${snapshot.electoral.rank2022?.votes ?? 'sem dado'} ` +

View file

@ -120,6 +120,8 @@ if (snapshot.meta?.codeSha && currentCodeSha && snapshot.meta.codeSha !== curren
}
const baseName = `${slug}-${generatedAt.toISOString().slice(0, 10)}`
const dossierPdfFile = join(outDir, `${baseName}-dossie.pdf`)
const bulletinPdfFile = join(outDir, `${baseName}-boletim.pdf`)
const emendasCachePath = join(CACHE_DIR, `${baseName}.emendas.json`)
const resolveEmendas = async () => {
@ -163,36 +165,53 @@ console.log(
`[${LABEL}] câmara=${camara.status} ibge=${health.status} lacunas_pesquisa=${research.gaps.length}`,
)
const report = buildDossierReport({ snapshot, research, emendas, camara, health, generatedAt })
const bulletin = buildBulletin({
facts: report.bulletinFacts,
municipality: snapshot.municipality.name,
region: snapshot.municipality.region ?? null,
generatedAt,
})
const dossierHtml = renderDossierHtml(report)
const dossierMd = renderDossierMd(report)
const bulletinHtml = renderBulletinHtml(bulletin)
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml)
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml)
await mkdir(resolve(ROOT, outDir), { recursive: true })
const dossierMdFile = join(outDir, `${baseName}-dossie.md`)
const dossierPdfFile = join(outDir, `${baseName}-dossie.pdf`)
const bulletinPdfFile = join(outDir, `${baseName}-boletim.pdf`)
await writeFile(resolve(ROOT, dossierMdFile), dossierMd)
console.log(`[${LABEL}] companion → ${dossierMdFile}`)
/**
* Builds the dossiê + boletim for a given fit fallback. The resumo page never
* cuts with "…": pass 1 prints the researcher's `summary` (or the full answer);
* if a page overflows the A4 guard, pass 2 re-renders with
* `textFallback: 'pointer'` so the lines without a `summary` point to the era
* pages (which keep the integral). The guard is still fail-closed.
*/
const buildArtifacts = (textFallback) => {
const report = buildDossierReport({
snapshot,
research,
emendas,
camara,
health,
generatedAt,
textFallback,
})
const bulletin = buildBulletin({
facts: report.bulletinFacts,
municipality: snapshot.municipality.name,
region: snapshot.municipality.region ?? null,
generatedAt,
})
return {
report,
bulletin,
dossierHtml: renderDossierHtml(report),
dossierMd: renderDossierMd(report),
bulletinHtml: renderBulletinHtml(bulletin),
}
}
let artifacts = buildArtifacts('full')
const browser = await launchPdfBrowser()
try {
await emitHtmlPairPdf(browser, {
dossierHtml,
bulletinHtml,
dossierHtml: artifacts.dossierHtml,
bulletinHtml: artifacts.bulletinHtml,
dossierPdf: resolve(ROOT, dossierPdfFile),
bulletinPdf: resolve(ROOT, bulletinPdfFile),
// Budget-gated 2-pass (C188): if the resumo sheet with the full text
// overflows, rebuild with pointer lines (no "…") before failing closed.
onDossierOverflow: async () => {
artifacts = buildArtifacts('pointer')
return artifacts.dossierHtml
},
resumoOnly: true,
})
} catch (error) {
die(error instanceof Error ? error.message : String(error))
@ -200,10 +219,21 @@ try {
await browser.close()
}
const { report, bulletin, dossierHtml, dossierMd, bulletinHtml } = artifacts
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml)
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml)
await mkdir(resolve(ROOT, outDir), { recursive: true })
const dossierMdFile = join(outDir, `${baseName}-dossie.md`)
await writeFile(resolve(ROOT, dossierMdFile), dossierMd)
console.log(`[${LABEL}] companion → ${dossierMdFile}`)
console.log(`[${LABEL}] PDF → ${dossierPdfFile}`)
console.log(`[${LABEL}] boletim PDF → ${bulletinPdfFile}`)
console.log(
`[${LABEL}] ${snapshot.municipality.name}: eras=${report.eras.length} entregas=${report.page1.deliveries.length} ` +
`[${LABEL}] ${snapshot.municipality.name}: eras=${report.eras.length} entregas=${report.page1.deliveries.items.length} ` +
`highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} html_bytes=${Buffer.byteLength(dossierHtml)}`,
)

View file

@ -34,6 +34,19 @@ const measurePageOverflows = (page, budgetPx = A4_PAGE_BUDGET_PX) =>
budgetPx,
)
/** Overflows of a single `[data-page="<anchor>"]` sheet (empty if absent). */
const measureSheetOverflows = (page, anchor, budgetPx = A4_PAGE_BUDGET_PX) =>
page.evaluate(
({ max, selector }) =>
[...document.querySelectorAll(selector)]
.map((element) => ({
page: element.getAttribute('data-page'),
height: element.scrollHeight,
}))
.filter((row) => row.height > max),
{ max: budgetPx, selector: `[data-page="${anchor}"]` },
)
/** Throws with the list of overflowing pages — callers turn it into `die`. */
export const assertPageFits = (overflows, label, hint) => {
if (overflows.length === 0) return
@ -56,26 +69,64 @@ const printA4Pdf = (page, outPath) =>
* One browser run, two HTML documents: sets each document, asserts its anchor
* exists, guards the fit and emits the PDF. Shared by the C186 (city) and C187
* (institution) builders — the pages differ, the emit contract does not.
*
* C188 fit fallback: when `onDossierOverflow` is given, only the page-1 sheet
* (`resumoOnly`) is measured first; if it overflows, the callback rebuilds the
* document (pointer lines, never "…") and the fit is checked again — still
* fail-closed if even the pointer does not fit.
*/
export const emitHtmlPairPdf = async (
browser,
{ dossierHtml, bulletinHtml, dossierPdf, bulletinPdf },
{
dossierHtml,
bulletinHtml,
dossierPdf,
bulletinPdf,
onDossierOverflow = null,
resumoOnly = false,
},
) => {
const page = await browser.newPage()
await page.setViewportSize({ width: A4_WIDTH_PX, height: A4_HEIGHT_PX })
await page.setContent(dossierHtml, { waitUntil: 'load' })
await page.emulateMedia({ media: 'print' })
const hasSummary = await page.evaluate(() =>
Boolean(document.querySelector('[data-page="resumo"]')),
)
if (!hasSummary)
throw new Error('Página de resumo ausente no HTML do dossiê — renderer quebrado.')
assertPageFits(
await measurePageOverflows(page, A4_PAGE_BUDGET_PX),
'Página(s) do dossiê',
'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.',
)
let currentDossierHtml = dossierHtml
let triedFallback = false
for (;;) {
await page.setContent(currentDossierHtml, { waitUntil: 'load' })
await page.emulateMedia({ media: 'print' })
const hasSummary = await page.evaluate(() =>
Boolean(document.querySelector('[data-page="resumo"]')),
)
if (!hasSummary)
throw new Error('Página de resumo ausente no HTML do dossiê — renderer quebrado.')
const overflows = resumoOnly
? await measureSheetOverflows(page, 'resumo', A4_PAGE_BUDGET_PX)
: await measurePageOverflows(page, A4_PAGE_BUDGET_PX)
if (overflows.length === 0) break
if (!onDossierOverflow || triedFallback) {
assertPageFits(
overflows,
'Página(s) do dossiê',
'Encurte os "summary" da pesquisa ou os itens de lista — nenhuma página pode ser cortada pelo overflow.',
)
}
triedFallback = true
currentDossierHtml = await onDossierOverflow()
if (!currentDossierHtml) {
assertPageFits(
overflows,
'Página(s) do dossiê',
'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.',
)
}
}
if (!resumoOnly) {
assertPageFits(
await measurePageOverflows(page, A4_PAGE_BUDGET_PX),
'Página(s) do dossiê',
'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.',
)
}
await printA4Pdf(page, dossierPdf)
await page.setContent(bulletinHtml, { waitUntil: 'load' })

View file

@ -41,6 +41,13 @@ import {
} from './cityReportFormat.mjs'
import { researchItemById } from './cityReportResearch.mjs'
import { buildTerritoryPanorama } from './cityReportTerritory.mjs'
import {
capList,
excerptDeepDive,
moreItemsLabel,
summaryListText,
summarySurfaceText,
} from './reportText.mjs'
const REPORT_TITLE = 'Relatório de cidade — pré-viagem'
@ -55,18 +62,20 @@ const SPEECH_SUMMARY_MAX = 220
const SPEECH_MENTION_MAX = 220
/**
* Page-1 content budget (OPS120). The summary reuses the free text of the
* research checklist; without a cap, long answers (pesquisa cheia) blow the
* one-page guard. Page 1 is "uma olhada" — cap the excerpt and the joined
* lists, and let the deepening sections carry the full text. Same spirit as
* the dossier caps (E16): explicit ceiling, never squeeze the layout.
* Page-1 text budget (C188). Page 1 is a summary surface: it never cuts
* mid-sentence with "…". It prints the researcher's `summary` when present,
* else the full `answer`; when the full text does not fit the one-page guard,
* the builder re-renders with `textFallback='pointer'` and the line points to
* the deep dive (which always keeps the integral). Only the list caps below
* still apply — each one with a visible counter.
*/
const PAGE_ONE_ANSWER_MAX = 90
/** The "Quem é quem" values share a 2-column stat cell — tighter than the rest. */
const PAGE_ONE_WHO_MAX = 26
/** Oposição card: the source suffix ("fonte: pesquisa web · data") is appended after the cut. */
const PAGE_ONE_RISK_MAX = 70
const PAGE_ONE_LIST_LIMIT = 2
/**
* Character ceiling for a joined list of free text (conjuncture risks) — the
* joined `answer`s have no deep-dive copy, so a long line degrades to the
* pointer when the page does not fit, instead of blowing the guard.
*/
const PAGE_ONE_JOINED_MAX = 90
/** Joined name lists (Responsável, Dobradinhas) — one line in the strip/stat row. */
const PAGE_ONE_NAMES_MAX = 20
/**
@ -75,8 +84,6 @@ const PAGE_ONE_NAMES_MAX = 20
* the one-page guard, and the full list lives in the deep dive anyway.
*/
const PAGE_ONE_INDICATOR_LIMIT = 3
/** One indication per line on page 1 — the full purpose text stays in the deep dive. */
const PAGE_ONE_INDICATOR_DETAIL_MAX = 70
const EMENDAS_EVIDENCE_TITLE = 'Emendas — indícios web (sem atribuição oficial ao município)'
@ -87,23 +94,6 @@ const EMENDA_SPHERE_LABELS = {
polo: 'polo',
}
/** Truncates with an explicit ellipsis; short text passes through untouched. */
const excerpt = (text, max) => {
const value = String(text ?? '')
return value.length > max ? `${value.slice(0, max).trimEnd()}…` : value
}
/**
* First `limit` values joined, capped to `max` chars; the remainder is always
* made explicit (`e mais N`) so the count survives the excerpt.
*/
const joinLimited = (values, limit, max) => {
const items = values.filter(Boolean)
const shown = items.slice(0, limit)
const remaining = items.length - shown.length
return `${excerpt(shown.join(', '), max)}${remaining > 0 ? ` e mais ${remaining}` : ''}`
}
const sourceTeqo = (label, date) => ({ kind: 'teqo', label, date: date ?? null })
const sourceWeb = (label, url, date) => ({
kind: 'web',
@ -146,24 +136,23 @@ const withInlineSources = (text, sources) => {
/**
* Página 1 = resumo de uma olhada: a fonte do item entra compacta (pesquisa web
* + data). A URL completa de cada item é preservada em "Fontes e limites" —
* imprimir a URL por linha estoura a página 1 com a pesquisa real (ver guarda
* de overflow do builder). O texto também entra capado (`PAGE_ONE_ANSWER_MAX`):
* a resposta integral é do aprofundamento, não do resumo de uma página.
* + data). O texto é o `summary` do researcher quando existe; sem ele, a
* resposta integral — e, quando a página 1 não cabe (`fallback: 'pointer'`), uma
* linha aponta o aprofundamento **sem reticências**. A resposta integral é
* sempre preservada em "Pesquisa — respostas integrais" (C188).
*/
const researchAnswerItem = (research, id, max = PAGE_ONE_ANSWER_MAX) => {
const researchAnswerItem = (research, id, fallback = 'full') => {
const item = researchItemById(research, id)
if (!item) return null
return {
text: excerpt(item.answer, max),
text: summarySurfaceText(item, fallback).text,
source: sourceWeb(null, null, item.sourceDate),
}
}
const gapCallout = (gaps, title = 'Lacunas desta seção') => {
if (!gaps.length) return null
const shown = gaps.slice(0, GAP_CALLOUT_LIMIT)
const remaining = gaps.length - shown.length
const { items: shown, remaining } = capList(gaps, GAP_CALLOUT_LIMIT)
return {
kind: 'callout',
tone: 'gap',
@ -171,7 +160,9 @@ const gapCallout = (gaps, title = 'Lacunas desta seção') => {
body: [
GAP_COPY,
...shown.map((gap) => `${gap.label ? `${gap.label}: ` : ''}${gap.reason}`),
...(remaining > 0 ? [`e mais ${remaining} ponto(s) sem leitura.`] : []),
...(remaining > 0
? [`${moreItemsLabel(remaining, 'ponto sem leitura', 'pontos sem leitura')}.`]
: []),
],
}
}
@ -195,10 +186,9 @@ const buildIdentificationItems = ({ municipality, goal, conjuncture, advisors })
{
label: 'Responsável',
value: advisors.length
? joinLimited(
? summaryListText(
advisors.map((advisor) => advisor.name),
PAGE_ONE_LIST_LIMIT,
PAGE_ONE_NAMES_MAX,
{ limit: PAGE_ONE_LIST_LIMIT, max: PAGE_ONE_NAMES_MAX },
)
: 'Sem responsável',
},
@ -225,15 +215,15 @@ const buildElectoralKpis = ({ electoral, conjuncture }) => {
]
}
const buildWhoRows = ({ leaderships, advisors, conjuncture }, research, prefeito) => {
const buildWhoRows = ({ leaderships, advisors, conjuncture }, research, prefeito, fallback) => {
const rows = []
const simpleAnswers = [
{ id: 'prefeito', label: 'Prefeito(a)', item: prefeito },
{ id: 'vice', label: 'Vice', item: researchAnswerItem(research, 'vice', PAGE_ONE_WHO_MAX) },
{ id: 'vice', label: 'Vice', item: researchAnswerItem(research, 'vice', fallback) },
{
id: 'relacao_campo',
label: 'Relação com o campo',
item: researchAnswerItem(research, 'relacao_campo', PAGE_ONE_WHO_MAX),
item: researchAnswerItem(research, 'relacao_campo', fallback),
},
]
for (const { label, item } of simpleAnswers) {
@ -245,14 +235,13 @@ const buildWhoRows = ({ leaderships, advisors, conjuncture }, research, prefeito
hint: advisors.length ? null : 'sem responsável vinculado',
})
const dobradinhas = conjuncture?.stateDeputies?.length
? joinLimited(
? summaryListText(
conjuncture.stateDeputies.map((deputy) => deputy.name),
PAGE_ONE_LIST_LIMIT,
PAGE_ONE_NAMES_MAX,
{ limit: PAGE_ONE_LIST_LIMIT, max: PAGE_ONE_NAMES_MAX, fallback },
)
: null
rows.push({ label: 'Dobradinhas', value: dobradinhas ?? 'sem registro na base' })
const vereadores = researchAnswerItem(research, 'vereadores', PAGE_ONE_WHO_MAX)
const vereadores = researchAnswerItem(research, 'vereadores', fallback)
if (vereadores) {
rows.push({ label: 'Vereadores/dobradas', value: vereadores.text, source: vereadores.source })
}
@ -284,8 +273,8 @@ const buildDeliveredKpis = ({ speeches }, research, emendas) => {
]
}
const buildAnnouncePanel = (research) => {
const relacao = researchAnswerItem(research, 'relacao_campo', PAGE_ONE_WHO_MAX)
const buildAnnouncePanel = (research, fallback) => {
const relacao = researchAnswerItem(research, 'relacao_campo', fallback)
const items = []
if (relacao) {
// A fonte da relação local já aparece em "Quem é quem" na mesma página.
@ -305,10 +294,10 @@ const buildAnnouncePanel = (research) => {
}
}
const buildRiskCards = ({ leaderships, advisors, conjuncture }, research) => {
const buildRiskCards = ({ leaderships, advisors, conjuncture }, research, fallback) => {
const opposition =
researchAnswerItem(research, 'disputa_local', PAGE_ONE_RISK_MAX) ??
researchAnswerItem(research, 'quem_investe', PAGE_ONE_RISK_MAX)
researchAnswerItem(research, 'disputa_local', fallback) ??
researchAnswerItem(research, 'quem_investe', fallback)
return [
{
title: 'Oposição local',
@ -319,7 +308,11 @@ const buildRiskCards = ({ leaderships, advisors, conjuncture }, research) => {
{
title: 'Riscos na base',
body: conjuncture?.risks?.length
? joinLimited(conjuncture.risks, PAGE_ONE_LIST_LIMIT, PAGE_ONE_ANSWER_MAX)
? summaryListText(conjuncture.risks, {
limit: PAGE_ONE_LIST_LIMIT,
max: PAGE_ONE_JOINED_MAX,
fallback,
})
: 'Nenhum risco registrado',
},
{
@ -343,11 +336,11 @@ const collectPageOneGaps = ({ electoral, prefeito }) => {
return gaps
}
const buildEmendasEvidenceBlock = (research, emendas) => {
const buildEmendasEvidenceBlock = (research, emendas, fallback) => {
if (emendas?.status === 'ok') return null
const indicators = research.emendasIndicators ?? []
if (!indicators.length) {
const evidence = researchAnswerItem(research, 'emendas_web')
const evidence = researchAnswerItem(research, 'emendas_web', fallback)
if (!evidence) return null
return {
kind: 'callout',
@ -356,7 +349,7 @@ const buildEmendasEvidenceBlock = (research, emendas) => {
body: [evidence.text, 'Não somar região/polo como emenda da cidade; URLs nas fontes.'],
}
}
const shown = indicators.slice(0, PAGE_ONE_INDICATOR_LIMIT)
const { items: shown, remaining } = capList(indicators, PAGE_ONE_INDICATOR_LIMIT)
return {
kind: 'indicatorList',
title: EMENDAS_EVIDENCE_TITLE,
@ -364,22 +357,16 @@ const buildEmendasEvidenceBlock = (research, emendas) => {
author: indicator.author,
sphere: indicator.sphere,
sphereLabel: EMENDA_SPHERE_LABELS[indicator.sphere] ?? indicator.sphere,
detail: [
excerpt(indicator.purpose, PAGE_ONE_INDICATOR_DETAIL_MAX),
indicator.value,
indicator.year,
]
.filter(Boolean)
.join(' · '),
detail: [indicator.purpose, indicator.value, indicator.year].filter(Boolean).join(' · '),
source: sourceWeb(indicator.author, indicator.sourceUrl, indicator.sourceDate),
})),
remaining: indicators.length - shown.length,
remaining,
note: 'Não somar região/polo como emenda da cidade; a atribuição oficial fica com o Portal da Transparência.',
}
}
const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => {
const prefeito = researchAnswerItem(research, 'prefeito', PAGE_ONE_WHO_MAX)
const buildPageOne = ({ snapshot, research, emendas, generatedAt, textFallback }) => {
const prefeito = researchAnswerItem(research, 'prefeito', textFallback)
const gapCalloutBlock = gapCallout(
collectPageOneGaps({ electoral: snapshot.electoral, prefeito }),
'Pontos sem leitura',
@ -398,7 +385,9 @@ const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => {
},
{
title: 'Quem é quem',
blocks: [{ kind: 'stats', rows: buildWhoRows(snapshot, research, prefeito) }],
blocks: [
{ kind: 'stats', rows: buildWhoRows(snapshot, research, prefeito, textFallback) },
],
},
],
sources: [
@ -422,12 +411,12 @@ const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => {
sourceWeb('pesquisa web — imprensa', null, research.researchedAt),
],
},
buildEmendasEvidenceBlock(research, emendas),
buildAnnouncePanel(research),
buildEmendasEvidenceBlock(research, emendas, textFallback),
buildAnnouncePanel(research, textFallback),
{
kind: 'cards',
title: 'Riscos — oposição/disputa local',
items: buildRiskCards(snapshot, research),
items: buildRiskCards(snapshot, research, textFallback),
},
gapCalloutBlock,
{
@ -867,7 +856,7 @@ const buildSignalsSection = ({ snapshot }) => {
.filter(Boolean)
.join(' · '),
responsible: row.responsibleName ?? '—',
excerpt: row.body ? row.body.slice(0, SIGNAL_EXCERPT_MAX) : '—',
excerpt: row.body ? excerptDeepDive(row.body, SIGNAL_EXCERPT_MAX) : '—',
})),
note:
signals.totalCount > signals.rows.length
@ -1094,7 +1083,7 @@ const speechTopicLabel = (value) =>
* false "menção ao município".
*/
const speechMention = (row, variant) => {
if (row.mentionExcerpt) return row.mentionExcerpt.slice(0, SPEECH_MENTION_MAX)
if (row.mentionExcerpt) return excerptDeepDive(row.mentionExcerpt, SPEECH_MENTION_MAX)
const count = row.mentionedMunicipalityCount
if (variant === 'region') {
return count
@ -1110,7 +1099,7 @@ const projectSpeechTableRow = (row, variant) => {
const base = {
date: formatDateBr(row.speechAt),
phase: row.phase ?? '—',
description: row.summary ? row.summary.slice(0, SPEECH_SUMMARY_MAX) : '—',
description: row.summary ? excerptDeepDive(row.summary, SPEECH_SUMMARY_MAX) : '—',
video: row.youtubeUrl
? withYoutubeTimestamp(row.youtubeUrl, youtubeStartSeconds(row))
: (row.vodPlaybackUrl ?? '—'),
@ -1356,8 +1345,9 @@ const buildRegionSection = ({ snapshot }) => {
}
/**
* Page 1 caps the checklist answers; the full text of each item lives here, so
* the cut never loses a sourced fact (OPS120).
* Page 1 never cuts text (C188): it prints the item's `summary` or the full
* `answer`, degrading to a pointer when the page does not fit. This table keeps
* the full text of every checklist item, so the pointer always has a target.
*/
const buildResearchAnswersTable = (research) =>
research.items.length
@ -1378,7 +1368,7 @@ const buildResearchAnswersTable = (research) =>
sources,
}
}),
note: 'A página 1 resume e capa; o texto integral de cada item da pesquisa fica nesta tabela.',
note: 'A página 1 imprime o resumo (ou o texto integral) e, quando não cabe, aponta para cá; o texto integral de cada item da pesquisa fica nesta tabela.',
}
: null
@ -1514,7 +1504,13 @@ const hasSectionData = (id, snapshot) => {
return true
}
export const buildCityReport = ({ snapshot, research, emendas, generatedAt = new Date() }) => {
export const buildCityReport = ({
snapshot,
research,
emendas,
generatedAt = new Date(),
textFallback = 'full',
}) => {
assertSnapshotShape(snapshot)
const generatedAtIso = generatedAt instanceof Date ? generatedAt.toISOString() : generatedAt
@ -1600,7 +1596,7 @@ export const buildCityReport = ({ snapshot, research, emendas, generatedAt = new
actorRole: snapshot.meta?.actorRole ?? null,
researchedAt: research.researchedAt,
},
page1: buildPageOne({ snapshot, research, emendas, generatedAt: generatedAtIso }),
page1: buildPageOne({ snapshot, research, emendas, generatedAt: generatedAtIso, textFallback }),
sections,
}
}

View file

@ -130,6 +130,10 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
id,
label: checklistById.get(id).label,
answer,
// Optional short, complete, self-contained rewrite of `answer` (C188): the
// summary surfaces print it as-is; the deep dive keeps `answer` integral.
// It is not a new fact — it inherits the item's source.
summary: isNonEmptyString(entry.summary) ? entry.summary.trim() : null,
details: isNonEmptyString(entry.details) ? entry.details.trim() : null,
sourceUrl,
sourceDate,

View file

@ -21,7 +21,7 @@ import {
isInstitutionUnit,
resolveDossierUnit,
} from './dossieUnit.mjs'
import { stripInlineSources } from './reportText.mjs'
import { capList, stripInlineSources, summarySurfaceText } from './reportText.mjs'
const MAX_DELIVERIES_PAGE_ONE = 3
const MAX_HOOKS_PAGE_ONE = 2
@ -99,14 +99,6 @@ const itemNumberRows = (items) => {
return rows
}
/**
* @template T
* @param {T[]} list
* @param {number} max
* @returns {T[]}
*/
const limit = (list, max) => list.slice(0, max)
const eraRecovery = {
A: 'Biografia Câmara · DOU/acervo do Ministério da Saúde · pesquisa web datada',
B: 'DOE-BA (DOOL) · notícias SESAB · Transparência Bahia · SIOPS/DATASUS',
@ -116,7 +108,7 @@ const eraRecovery = {
const eraMethod = {
A: 'Itens ligados ao município foram conferidos em fonte oficial e separados por esfera. Cargos técnicos e de gestão têm datas e objetos distintos — sem atribuição local sem documento contemporâneo.',
B: 'A gestão estadual é lida por equipamento, política e obra; cada linha informa esfera e fonte. Região e polo não somam ao município.',
C: 'Mandato, relatorias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase.',
C: 'Mandato, relatorias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase. A coleta na API da Câmara é limitada às páginas consultadas (não é um total): o que não foi coletado não aparece.',
}
const healthContext = (health) =>
@ -146,13 +138,13 @@ const camaraItemsAsActions = (camara, era) =>
* @property {any} cover
* @property {any} page1
* @property {any[]} trajectory
* @property {Array<{ id: string, label: string, period: string, subtitle: string, empty?: boolean, method: string, recovery: string, numbers: any[], actions: any[], honors: any[] }>} eras
* @property {Array<{ id: string, label: string, period: string, subtitle: string, empty?: boolean, method: string, recovery: string, numbers: { items: any[], total: number, remaining: number }, actions: any[], camaraActionsRemaining: number, honors: any[] }>} eras
* @property {{
* ruleTitle: string,
* ruleBody: string,
* municipal: { label: string, items: any[], total: number },
* regional: { label: string, items: any[], total: number },
* items: any[],
* municipal: { label: string, items: any[], total: number, remaining: number },
* regional: { label: string, items: any[], total: number, remaining: number },
* evidence: { items: any[], total: number, remaining: number },
* context: any[],
* hook: any,
* priorityGap: any,
@ -199,6 +191,7 @@ const buildMunicipalityReport = ({
camara = null,
health = null,
generatedAt = new Date(),
textFallback = 'full',
}) => {
const municipality = snapshot.municipality ?? {}
const municipalityName = municipality.name ?? municipality.slug ?? 'Município'
@ -230,7 +223,7 @@ const buildMunicipalityReport = ({
const eraSections = DOSSIER_ERAS.map((era) => {
const ownActions = researchActionsByEra(era.id)
const extraActions = era.id === 'C' ? camaraActions : []
const numbers = limit(
const numbers = capList(
era.id === 'C'
? [...eraRows, ...itemNumberRows(items.filter((i) => i.era === 'C'))]
: itemNumberRows(items.filter((i) => i.era === era.id)),
@ -246,19 +239,24 @@ const buildMunicipalityReport = ({
sourceUrl: item.sourceUrl,
}))
: []
const actions = [...ownActions, ...limit(extraActions, MAX_ERA_CAMARA_ACTIONS)]
if (numbers.length === 0 && actions.length === 0) return null
// Curated research actions are all kept (the renderer paginates them into
// continuation sheets); only the generic Câmara list is capped.
const camaraActionsCapped = capList(extraActions, MAX_ERA_CAMARA_ACTIONS)
const actions = [...ownActions, ...camaraActionsCapped.items]
const camaraActionsRemaining = camaraActionsCapped.remaining
if (numbers.items.length === 0 && actions.length === 0) return null
return {
...era,
method: eraMethod[era.id],
recovery: eraRecovery[era.id],
numbers,
actions,
camaraActionsRemaining,
honors,
}
}).filter(Boolean)
const deliveries = limit(
const deliveries = capList(
[...municipalItems]
.sort((left, right) => {
const leftEra = left.era === 'C' ? 0 : 1
@ -268,25 +266,28 @@ const buildMunicipalityReport = ({
const rightNumber = right.numbers?.length ? 0 : 1
return leftNumber - rightNumber
})
.map((item) => ({
sphere: item.sphere,
era: item.era,
year: item.numbers?.[0]?.year ?? null,
title: item.answer,
detail: item.details,
brief: item.brief ?? null,
value: item.numbers?.[0]?.value ?? null,
phase: item.numbers?.[0]?.phase ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
})),
.map((item) => {
const { text, fromSummary } = summarySurfaceText(item, textFallback)
return {
sphere: item.sphere,
era: item.era,
year: item.numbers?.[0]?.year ?? null,
title: text,
detail: fromSummary ? item.details : textFallback === 'pointer' ? null : item.details,
brief: item.brief ?? null,
value: item.numbers?.[0]?.value ?? null,
phase: item.numbers?.[0]?.phase ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}
}),
MAX_DELIVERIES_PAGE_ONE,
)
const hooks = limit(
const hooks = capList(
[...municipalItems, ...regionalItems].map((item) => ({
topic: item.area,
angle: item.answer,
angle: summarySurfaceText(item, textFallback).text,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
})),
@ -299,13 +300,13 @@ const buildMunicipalityReport = ({
nextStep: 'Apurar em fonte primária.',
}))
const pending = limit(
const pending = capList(
(research.gaps ?? []).map((gap) => gap.label ?? gap.id),
MAX_PENDING_PAGE_ONE,
)
const context = healthContext(health)
const regionItems = limit(
const regionalEvidence = capList(
regionalItems.map((item) => ({
item: item.answer,
sphere: item.sphere,
@ -324,7 +325,7 @@ const buildMunicipalityReport = ({
era: item.era,
sphere: item.sphere,
area: item.area,
headline: stripInlineSources(item.answer),
headline: stripInlineSources(summarySurfaceText(item).text),
detail: item.details ? stripInlineSources(item.details) : null,
brief: item.brief
? {
@ -396,20 +397,12 @@ const buildMunicipalityReport = ({
ruleTitle: 'Região não é cidade. Não some os dois recortes.',
ruleBody:
'Um equipamento de referência ou uma política regional pode atender moradores do município sem constituir entrega exclusiva para ele. Atribuição só entra com alcance documentado.',
municipal: {
label: 'município',
items: limit(municipalItems, MAX_SCOPE_LIST),
total: municipalItems.length,
},
regional: {
label: 'região / polo',
items: limit(regionalItems, MAX_SCOPE_LIST),
total: regionalItems.length,
},
items: regionItems,
municipal: { label: 'município', ...capList(municipalItems, MAX_SCOPE_LIST) },
regional: { label: 'região / polo', ...capList(regionalItems, MAX_SCOPE_LIST) },
evidence: regionalEvidence,
context,
hook: hooks[0] ?? null,
priorityGap: pending[0] ?? null,
hook: hooks.items[0] ?? null,
priorityGap: pending.items[0] ?? null,
},
gaps,
news: (research.news ?? []).map((row) => ({
@ -465,13 +458,13 @@ const institutionEraRecovery = {
/**
* Wraps a capped list so the renderer can declare what stayed out ("e mais N")
* instead of dropping it silently — the C188 no-truncation rule, enforced at
* the owner for both recortes.
* the owner for both recortes. `omitted` is the C187 renderer's name for the
* same count `capList` calls `remaining`.
*/
const capped = (list, max) => ({
items: limit(list, max),
total: list.length,
omitted: Math.max(0, list.length - max),
})
const capped = (list, max) => {
const { items, total, remaining } = capList(list, max)
return { items, total, omitted: remaining }
}
const identityBadges = (identity) =>
[identity.kindLabel, identity.sphereLabel, identity.scope ? identity.scopeLabel : null].filter(
@ -530,11 +523,11 @@ const buildInstitutionReport = ({ snapshot, research, generatedAt = new Date() }
}))
const eraSections = DOSSIER_ERAS.map((era) => {
const numbers = limit(
const numbers = capped(
itemNumberRows(items.filter((item) => item.era === era.id)),
MAX_ERA_NUMBERS,
)
const actions = limit(researchActionsByEra(era.id), MAX_ERA_CAMARA_ACTIONS)
const actions = capped(researchActionsByEra(era.id), MAX_ERA_CAMARA_ACTIONS)
const honors = items
.filter((item) => item.era === era.id && item.id === 'era_c_titulos')
.map((item) => ({
@ -552,7 +545,7 @@ const buildInstitutionReport = ({ snapshot, research, generatedAt = new Date() }
numbers,
actions,
honors,
empty: numbers.length === 0 && actions.length === 0,
empty: numbers.items.length === 0 && actions.items.length === 0,
}
})

View file

@ -63,6 +63,7 @@ export const buildBulletin = ({
label: fact.brief?.title ?? fact.headline,
detail: fact.brief?.note ?? fact.detail ?? fact.value ?? fact.area,
}))
const shown = highlights.length + moreItems.length
return {
unit: resolvedUnit,
@ -81,6 +82,10 @@ export const buildBulletin = ({
},
highlights,
moreItems,
// Never a silent drop (C188): the facts beyond the two caps are counted so
// the one-page boletim states how much of the sourced set it left out.
factsTotal: ordered.length,
factsRemaining: ordered.length - shown,
timeline: BULLETIN_TIMELINE_STEPS,
}
}

View file

@ -6,7 +6,7 @@
*/
import { isInstitutionUnit } from './dossieUnit.mjs'
import { htmlEscape } from './reportText.mjs'
import { htmlEscape, moreItemsLabel } from './reportText.mjs'
const ASSET = (title, lines) =>
`<div class="asset-box"><p class="asset-title">${htmlEscape(title)}</p><p>${lines
@ -100,6 +100,7 @@ const PRINT_CSS = `
.more-grid { margin-top: 2.3mm; display: grid; grid-template-columns: 1fr 1fr; gap: 1.7mm 4mm; }
.more-item { position: relative; margin: 0; min-height: 11mm; padding: 0 0 2mm 5mm; border-bottom: .25mm solid #cbd5dc; font-size: 9pt; line-height: 1.22; }
.more-item::before { content: '✓'; position: absolute; left: 0; top: 0; color: #315c75; font-weight: 900; }
.more-counter { margin-top: 1.8mm; color: #435264; font-size: 7.6pt; font-weight: 600; }
.more-aside { display: grid; grid-template-rows: 1fr 1fr; gap: 2.3mm; }
.more-aside .asset-box { border-radius: .125rem; }
.empty { margin-top: 3mm; color: #435264; font-size: 9.5pt; }
@ -186,6 +187,11 @@ const renderMunicipalityBulletinHtml = (bulletin) => `
? `<div class="more-grid">${bulletin.moreItems.map(renderMoreItem).join('')}</div>`
: '<div class="empty-box"><p><strong class="uppercase">Sem itens adicionais com fonte além dos destaques.</strong></p><p>Conferir as lacunas explícitas no dossiê da cidade antes de ampliar o boletim.</p></div>'
}
${
bulletin.factsRemaining > 0
? `<p class="more-counter">${htmlEscape(moreItemsLabel(bulletin.factsRemaining, 'fato com fonte', 'fatos com fonte'))} no dossiê da cidade — o boletim de uma página não os exibe.</p>`
: ''
}
</div>
<aside class="more-aside" aria-label="Ativos visuais pendentes">
${ASSET('NEEDS ASSET · selo', ['marca institucional aprovada', 'arquivo vetorial'])}

View file

@ -8,6 +8,7 @@
import { getJsonWithBackoff, speechesUrl } from './camaraFetch.mjs'
import { isNonEmptyString } from './cityReportResearch.mjs'
import { SOLLA_DEPUTY_ID } from './dossieCareer.mjs'
import { excerptDeepDive } from './reportText.mjs'
const CAMARA_API = 'https://dadosabertos.camara.leg.br/api/v2'
export const CAMARA_DEFAULT_FROM = '2015-01-01'
@ -43,7 +44,8 @@ export const normalizeProposition = (row, deputyId = SOLLA_DEPUTY_ID) => {
export const normalizeSpeech = (row, deputyId) => {
const sumario = isNonEmptyString(row?.sumario) ? row.sumario.trim() : null
const transcricao = isNonEmptyString(row?.transcricao) ? row.transcricao.trim() : null
const detail = (sumario ?? transcricao ?? '').slice(0, 280) || null
// Deep-dive surface: an explicit "…" is honest; a silent cut would not be (C188).
const detail = excerptDeepDive(sumario ?? transcricao ?? '', 280) || null
if (!detail && !isNonEmptyString(row?.dataHoraInicio)) return null
const date = isNonEmptyString(row?.dataHoraInicio) ? row.dataHoraInicio.slice(0, 10) : null
const tipo = isNonEmptyString(row?.tipoDiscurso) ? row.tipoDiscurso.trim() : 'Pronunciamento'

View file

@ -8,7 +8,7 @@
import { formatDateBr, formatDateTimeBr } from './cityReportFormat.mjs'
import { dossierPhaseLabel, dossierSphereBadgeClass, dossierSphereLabel } from './dossieBlocks.mjs'
import { MUNICIPALITY_UNIT, isInstitutionUnit } from './dossieUnit.mjs'
import { htmlEscape, stripInlineSources } from './reportText.mjs'
import { htmlEscape, moreItemsLabel, showingLabel, stripInlineSources } from './reportText.mjs'
const SOURCE_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'
@ -204,14 +204,15 @@ const renderTrajectory = (report, pageNo) => `
</article>`
const renderDeliveries = (report) => {
if (report.page1.deliveries.length === 0) return ''
const deliveries = report.page1.deliveries
if (deliveries.items.length === 0) return ''
return `
<section class="block">
<div class="block-head">
<div><p class="eyebrow">02 · destaques com lastro</p><h3 class="section-title">Principais entregas localizadas</h3></div>
</div>
<div class="deliveries">
${report.page1.deliveries
${deliveries.items
.map(
(row) => `<div class="delivery-row">
<div class="delivery-scope">${scopeBadge(row.sphere)}<p class="meta">Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}</p></div>
@ -228,6 +229,11 @@ const renderDeliveries = (report) => {
)
.join('')}
</div>
${
deliveries.remaining > 0
? `<p class="muted small strong">${htmlEscape(showingLabel(deliveries.items.length, deliveries.total, 'entrega localizada', 'entregas localizadas'))} — o restante está nas páginas das eras.</p>`
: ''
}
<p class="muted small strong">Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor.</p>
</section>`
}
@ -235,27 +241,29 @@ const renderDeliveries = (report) => {
const renderHooksAndPending = (report) => {
const hooks = report.page1.hooks
const pending = report.page1.pending
if (hooks.length === 0 && pending.length === 0) return ''
if (hooks.items.length === 0 && pending.items.length === 0) return ''
return `
<section class="page1-grid">
${
hooks.length
hooks.items.length
? `<div class="panel">
<p class="eyebrow">03 · ganchos para o boletim</p>
<ul class="tight-list">${hooks
<ul class="tight-list">${hooks.items
.map(
(hook) =>
`<li><strong>Tema:</strong> ${htmlEscape(hook.topic)}. <strong>Ângulo:</strong> ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)}</li>`,
)
.join('')}</ul>
${hooks.remaining > 0 ? `<p class="muted small">${htmlEscape(moreItemsLabel(hooks.remaining, 'gancho', 'ganchos'))} nas eras.</p>` : ''}
</div>`
: '<div></div>'
}
${
pending.length
pending.items.length
? `<div class="panel panel-warning">
<p class="eyebrow warning-text">04 · o que falta</p>
<ul class="tight-list">${pending.map((item) => `<li>${htmlEscape(item)}</li>`).join('')}</ul>
<ul class="tight-list">${pending.items.map((item) => `<li>${htmlEscape(item)}</li>`).join('')}</ul>
${pending.remaining > 0 ? `<p class="warning-text small">${htmlEscape(moreItemsLabel(pending.remaining, 'ponto', 'pontos'))} sem leitura na tabela de lacunas.</p>` : ''}
<p class="warning-text small strong">Ausência é resultado: não completar por inferência.</p>
</div>`
: '<div></div>'
@ -308,7 +316,7 @@ const renderEraPage = (report, era, actions, index, pageNo) => {
</div>
${
era.numbers.length
era.numbers.items.length
? `<section class="block">
<div class="block-head">
<div><p class="eyebrow">Números com fase</p><h3 class="section-title">Recursos e entregas quantificáveis</h3></div>
@ -317,7 +325,7 @@ const renderEraPage = (report, era, actions, index, pageNo) => {
<caption class="sr-only">Valores por ano, fase, esfera e fonte</caption>
<colgroup><col style="width:26%" /><col style="width:15%" /><col style="width:10%" /><col style="width:17%" /><col style="width:12%" /><col style="width:20%" /></colgroup>
<thead><tr><th>Objeto</th><th>Valor</th><th>Ano</th><th>Fase</th><th>Esfera</th><th>Fonte</th></tr></thead>
<tbody>${era.numbers
<tbody>${era.numbers.items
.map(
(row) => `<tr>
<td>${htmlEscape(row.object)}</td>
@ -330,6 +338,8 @@ const renderEraPage = (report, era, actions, index, pageNo) => {
)
.join('')}</tbody>
</table>
${era.numbers.remaining > 0 ? `<p class="muted small">${htmlEscape(moreItemsLabel(era.numbers.remaining, 'número com fase', 'números com fase'))} — a lista completa está no arquivo de pesquisa da era.</p>` : ''}
<p class="warning-text small strong">Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.</p>
</section>`
: ''
}`
@ -342,6 +352,7 @@ const renderEraPage = (report, era, actions, index, pageNo) => {
<p class="eyebrow">Atuação registrada</p>
<h3 class="section-title">O que fez — item, alcance e lastro</h3>
${renderActionGrid(actions)}
${era.camaraActionsRemaining > 0 ? `<p class="muted small">${htmlEscape(moreItemsLabel(era.camaraActionsRemaining, 'ação da Câmara', 'ações da Câmara'))} — a coleta é limitada às páginas consultadas; o restante está no acervo da Câmara.</p>` : ''}
</section>`
: ''
}
@ -382,6 +393,7 @@ const renderScopeLists = (report) => `
.join(
'',
)}${report.region.municipal.items.length === 0 ? '<li class="muted">Nenhum item municipal com fonte.</li>' : ''}</ul>
${report.region.municipal.remaining > 0 ? `<p class="muted small">${htmlEscape(moreItemsLabel(report.region.municipal.remaining, 'item municipal com fonte', 'itens municipais com fonte'))} — ver a tabela de evidências abaixo.</p>` : ''}
</div>
<div class="compare-mid" aria-hidden="true"><span>≠</span></div>
<div class="panel panel-region">
@ -394,6 +406,7 @@ const renderScopeLists = (report) => `
.join(
'',
)}${report.region.regional.items.length === 0 ? '<li class="muted">Nenhum item regional com fonte.</li>' : ''}</ul>
${report.region.regional.remaining > 0 ? `<p class="muted small">${htmlEscape(moreItemsLabel(report.region.regional.remaining, 'item regional com fonte', 'itens regionais com fonte'))} — ver a tabela de evidências abaixo.</p>` : ''}
</div>
</div>
</section>`
@ -423,14 +436,14 @@ const renderRegion = (report, pageNo) => `
${renderScopeLists(report)}
${
report.region.items.length
report.region.evidence.items.length
? `<section class="block">
<div class="block-head"><div><p class="eyebrow">Itens regionais recuperados</p><h3 class="section-title">Evidência de alcance, sem apropriação local</h3></div>${scopeBadge('regiao')}</div>
<table class="document-table">
<caption class="sr-only">Itens do recorte regional com evidência e fonte</caption>
<colgroup><col style="width:30%" /><col style="width:15%" /><col style="width:34%" /><col style="width:21%" /></colgroup>
<thead><tr><th>Item</th><th>Esfera</th><th>Evidência de alcance</th><th>Fonte</th></tr></thead>
<tbody>${report.region.items
<tbody>${report.region.evidence.items
.map(
(item) => `<tr>
<td>${copyHtml(briefOr(item.brief, item.item))}</td>
@ -441,6 +454,7 @@ const renderRegion = (report, pageNo) => `
)
.join('')}</tbody>
</table>
${report.region.evidence.remaining > 0 ? `<p class="muted small">${htmlEscape(showingLabel(report.region.evidence.items.length, report.region.evidence.total, 'item regional recuperado', 'itens regionais recuperados'))} em evidência; o restante está no arquivo de pesquisa da era.</p>` : ''}
</section>`
: ''
}
@ -691,17 +705,23 @@ const mdEra = (era) => {
'',
`Trilha: ${era.recovery}`,
]
if (era.numbers.length) {
if (era.numbers.items.length) {
lines.push(
'',
'| Objeto | Valor | Ano | Fase | Esfera | Fonte |',
'| --- | --- | --- | --- | --- | --- |',
)
for (const row of era.numbers) {
for (const row of era.numbers.items) {
lines.push(
`| ${row.object} | ${row.value ?? '—'} | ${row.year ?? '—'} | ${dossierPhaseLabel(row.phase)} | ${row.sphere} | ${row.sourceUrl ?? '—'} |`,
)
}
if (era.numbers.remaining > 0) {
lines.push(
'',
`_${moreItemsLabel(era.numbers.remaining, 'número com fase', 'números com fase')} não exibidos — ver as fontes._`,
)
}
}
for (const action of era.actions) {
lines.push(
@ -709,6 +729,12 @@ const mdEra = (era) => {
`- **${stripInlineSources(action.title)}** (${action.sphere}${action.year ? ` · ${action.year}` : ''})${action.detail ? ` — ${stripInlineSources(action.detail)}` : ''} [fonte](${action.sourceUrl})`,
)
}
if (era.camaraActionsRemaining > 0) {
lines.push(
'',
`_${moreItemsLabel(era.camaraActionsRemaining, 'ação da Câmara', 'ações da Câmara')} — a coleta é limitada às páginas consultadas._`,
)
}
for (const honor of era.honors)
lines.push('', `- Honraria: ${stripInlineSources(honor.text)} [fonte](${honor.sourceUrl})`)
return lines.join('\n')
@ -732,13 +758,19 @@ const renderMunicipalityDossierMd = (report) => {
)
}
if (report.page1.deliveries.length) {
if (report.page1.deliveries.items.length) {
lines.push('', '## Principais entregas localizadas', '')
for (const row of report.page1.deliveries) {
for (const row of report.page1.deliveries.items) {
lines.push(
`- **${stripInlineSources(row.title)}** · _${row.sphere}_${row.value ? ` · ${row.value}` : ''}${row.phase ? ` (${dossierPhaseLabel(row.phase)})` : ''}${row.detail ? ` — ${stripInlineSources(row.detail)}` : ''} [fonte](${row.sourceUrl})`,
)
}
if (report.page1.deliveries.remaining > 0) {
lines.push(
'',
`_${showingLabel(report.page1.deliveries.items.length, report.page1.deliveries.total, 'entrega localizada', 'entregas localizadas')} — o restante está nas páginas das eras._`,
)
}
lines.push('', '_Autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor._')
}
@ -758,17 +790,33 @@ const renderMunicipalityDossierMd = (report) => {
for (const item of report.region.municipal.items) {
lines.push(`- ${stripInlineSources(item.answer)} — [fonte](${item.sourceUrl})`)
}
if (report.region.municipal.remaining > 0) {
lines.push(
`- _${moreItemsLabel(report.region.municipal.remaining, 'item municipal com fonte', 'itens municipais com fonte')} — ver a tabela de evidências._`,
)
}
lines.push('', '### Região / polo (não somável)', '')
for (const item of report.region.regional.items) {
lines.push(`- ${stripInlineSources(item.answer)} — [fonte](${item.sourceUrl})`)
}
if (report.region.items.length) {
if (report.region.regional.remaining > 0) {
lines.push(
`- _${moreItemsLabel(report.region.regional.remaining, 'item regional com fonte', 'itens regionais com fonte')} — ver a tabela de evidências._`,
)
}
if (report.region.evidence.items.length) {
lines.push('', '| Item | Esfera | Evidência | Fonte |', '| --- | --- | --- | --- |')
for (const item of report.region.items) {
for (const item of report.region.evidence.items) {
lines.push(
`| ${stripInlineSources(item.item)} | ${item.sphere} | ${stripInlineSources(item.evidence)} | ${item.sourceUrl} |`,
)
}
if (report.region.evidence.remaining > 0) {
lines.push(
'',
`_${showingLabel(report.region.evidence.items.length, report.region.evidence.total, 'item regional recuperado', 'itens regionais recuperados')} em evidência; o restante está no arquivo de pesquisa da era._`,
)
}
}
lines.push('', '## Lacunas explícitas', '')
@ -1090,7 +1138,7 @@ const renderInstitutionEra = (report, era, pageNo) => {
</div>
${
era.numbers.length
era.numbers.items.length
? `<section class="block">
<div class="block-head">
<div><p class="eyebrow">Entregas localizadas</p><h3 class="section-title">Objeto, valor, fase e alcance</h3></div>
@ -1099,7 +1147,7 @@ const renderInstitutionEra = (report, era, pageNo) => {
<caption class="sr-only">Valores por ano, fase, abrangência e fonte</caption>
<colgroup><col style="width:26%" /><col style="width:14%" /><col style="width:9%" /><col style="width:16%" /><col style="width:15%" /><col style="width:20%" /></colgroup>
<thead><tr><th>Objeto</th><th>Valor</th><th>Ano</th><th>Fase</th><th>Abrangência</th><th>Fonte</th></tr></thead>
<tbody>${era.numbers
<tbody>${era.numbers.items
.map(
(row) => `<tr>
<td>${htmlEscape(row.object)}</td>
@ -1118,12 +1166,12 @@ const renderInstitutionEra = (report, era, pageNo) => {
}
${
era.actions.length
era.actions.items.length
? `<section class="block">
<p class="eyebrow">Atuação / vínculo registrado</p>
<h3 class="section-title">Papéis com evidência visível</h3>
<div class="action-grid">
${era.actions
${era.actions.items
.map(
(action) => `<article class="panel">
<div class="action-head">${scopeBadge(action.sphere, null, report.unit)}<span class="meta">${htmlEscape(action.year ?? '—')}</span></div>
@ -1451,19 +1499,19 @@ const institutionMdEra = (era) => {
)
return lines.join('\n')
}
if (era.numbers.length) {
if (era.numbers.items.length) {
lines.push(
'',
'| Objeto | Valor | Ano | Fase | Abrangência | Fonte |',
'| --- | --- | --- | --- | --- | --- |',
)
for (const row of era.numbers) {
for (const row of era.numbers.items) {
lines.push(
`| ${row.object} | ${row.value ?? '—'} | ${row.year ?? '—'} | ${dossierPhaseLabel(row.phase)} | ${row.sphere} | ${row.sourceUrl ?? '—'} |`,
)
}
}
for (const action of era.actions) {
for (const action of era.actions.items) {
lines.push(
'',
`- **${stripInlineSources(action.title)}** (${action.sphere}${action.year ? ` · ${action.year}` : ''})${action.detail ? ` — ${stripInlineSources(action.detail)}` : ''} [fonte](${action.sourceUrl})`,

View file

@ -387,6 +387,10 @@ export const normalizeDossierResearchInput = (raw, { unit } = {}) => {
label: checklistItem.label,
area: checklistItem.area,
answer,
// Optional short, complete, self-contained rewrite of `answer` (C188): the
// resumo/boletim print it as-is; the era pages keep `answer`/`details`
// integral. Inherits the item's source — not a new fact.
summary: isNonEmptyString(entry.summary) ? entry.summary.trim() : null,
details: isNonEmptyString(entry.details) ? entry.details.trim() : null,
brief,
sphere,

View file

@ -1,9 +1,14 @@
/**
* Shared print-text contract for the Report builders (C163 city report + C186
* dossiê/boletim): escaping, bare-URL anchors and the inline-source tokens
* (`{{fonte}}` / `{{fonte:N}}`). Extracted from `cityReportRender.mjs` so the
* dossiê renderer does not twin the subtle token resolution — one owner, two
* consumers.
* dossiê/boletim): escaping, bare-URL anchors, the inline-source tokens
* (`{{fonte}}` / `{{fonte:N}}`) and the text-budget primitives (C188). Extracted
* from `cityReportRender.mjs` so the dossiê renderer does not twin the subtle
* token resolution — one owner, two consumers.
*
* Text-budget rule (C188): summary surfaces never cut mid-sentence. They use
* the researcher's `summary` when present, else the full `answer`; only the
* deep-dive pages may cut explicitly (`excerptDeepDive`). Lists are always
* capped with a visible counter — an item never disappears in silence.
*/
export const htmlEscape = (value) =>
@ -51,3 +56,96 @@ export const renderInlineSourcesMd = (value, sources) =>
const label = sources.length > 1 ? `fonte ${position + 1}` : 'fonte'
return ` [(${label})](${url})`
})
/**
* Explicit cut, allowed only on **deep-dive** surfaces (signals, speech
* mentions, Câmara actions). Summary surfaces never print "…" (C188): there the
* text is the researcher's `summary`, the full `answer`, or a pointer line.
*/
export const excerptDeepDive = (text, max) => {
const value = String(text ?? '')
return value.length > max ? `${value.slice(0, max).trimEnd()}…` : value
}
/**
* Counting cap for a list. Every list the report renders must go through this
* so a dropped tail is always visible ("e mais N" / "Mostrando X de Y") — an
* item never disappears in silence (C188).
*
* @template T
* @param {T[]} list
* @param {number} max
* @returns {{ items: T[], total: number, remaining: number }}
*/
export const capList = (list, max) => {
const source = Array.isArray(list) ? list : []
const items = source.slice(0, Math.max(0, max))
return { items, total: source.length, remaining: source.length - items.length }
}
/**
* First `limit` values joined by ", "; the remainder is always explicit
* (`e mais N`). Unlike a character cut, this never truncates a value — it caps
* by count and states how many were left out (C188).
*/
export const joinWithRemainder = (values, limit) => {
const source = (Array.isArray(values) ? values : []).filter(Boolean)
const { items, remaining } = capList(source, limit)
return `${items.join(', ')}${remaining > 0 ? ` e mais ${remaining}` : ''}`
}
/** Copy a summary line prints when neither the researcher's `summary` nor the full text fits. */
export const SUMMARY_POINTER_COPY = 'Texto integral no aprofundamento'
/**
* Summary-surface text of a research item: the researcher's `summary` when
* present, else the full `answer` — so the caller can keep the deep-dive text
* intact.
*
* @param {{ summary?: string|null, answer?: string|null }} item
*/
export const summaryText = (item) => {
const summary =
typeof item?.summary === 'string' && item.summary.trim() !== '' ? item.summary.trim() : null
if (summary) return { text: summary, fromSummary: true }
return { text: typeof item?.answer === 'string' ? item.answer : '', fromSummary: false }
}
/**
* Summary-surface text of a research item: the researcher's `summary` (authored
* to fit) when present, else the full `answer`. Returns `fromSummary` so the
* caller knows whether a fit fallback may replace it (a summary is preserved;
* only the un-summarized answer degrades to a pointer) — the single owner of
* the "summary → answer → pointer" rule (C188).
*
* @param {{ summary?: string|null, answer?: string|null }} item
* @param {'full'|'pointer'} textFallback
*/
export const summarySurfaceText = (item, textFallback = 'full') => {
const { text, fromSummary } = summaryText(item)
if (fromSummary || textFallback !== 'pointer') return { text, fromSummary }
return { text: SUMMARY_POINTER_COPY, fromSummary: false }
}
/**
* A joined list on a summary surface: caps by count with the remainder stated;
* if the joined text exceeds `max`, it degrades to the summary pointer when a
* deep-dive copy exists (`fallback='pointer'`) — never "…". The full text is
* returned otherwise, and the page-fit guard decides.
*
* @param {Array<string|null|undefined>} values
* @param {{ limit: number, max?: number, fallback?: 'full'|'pointer' }} options
*/
export const summaryListText = (values, { limit, max = 90, fallback = 'full' }) => {
const joined = joinWithRemainder(values, limit)
if (joined.length <= max) return joined
return fallback === 'pointer' ? SUMMARY_POINTER_COPY : joined
}
/** "e mais N item(ns)" — the list counter copy (one owner, both reports). */
export const moreItemsLabel = (remaining, singular = 'item', plural = 'itens') =>
`e mais ${remaining} ${remaining === 1 ? singular : plural}`
/** "Mostrando X de Y item(ns)" — the capped-list counter copy (one owner, both reports). */
export const showingLabel = (shown, total, singular = 'item', plural = 'itens') =>
`Mostrando ${shown} de ${total} ${total === 1 ? singular : plural}`

View file

@ -5,6 +5,7 @@ import {
normalizeResearchInput,
RESEARCH_CHECKLIST_IDS,
} from '../../scripts/lib/cityReportResearch.mjs'
import { SUMMARY_POINTER_COPY } from '../../scripts/lib/reportText.mjs'
const generatedAt = new Date('2026-09-15T12:00:00.000Z')
@ -749,7 +750,7 @@ describe('buildCityReport', () => {
expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/polo')).toBe(true)
})
it('caps the page-1 research excerpt and keeps the full text in the deep dive (OPS120)', () => {
it('keeps the full page-1 answer (no "…") and preserves the integral in the deep dive (C188)', () => {
const longAnswer = 'palavra '.repeat(20).trim()
const longResearch = normalizeResearchInput(
{
@ -766,8 +767,8 @@ describe('buildCityReport', () => {
const who = capped.page1.blocks.find((block) => block.kind === 'grid')!.cells![1].blocks[0]
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!
expect(prefeito.value!.length).toBeLessThan(longAnswer.length)
expect(prefeito.value!.endsWith('…')).toBe(true)
expect(prefeito.value).toBe(longAnswer)
expect(prefeito.value!.includes('…')).toBe(false)
const appendix = capped.sections
.find((section) => section.id === 'fontes')!
@ -777,6 +778,60 @@ describe('buildCityReport', () => {
expect(rows[0].answer).toContain(longAnswer)
})
it('prefers the researcher summary on page 1 when present (C188)', () => {
const longAnswer = 'palavra '.repeat(20).trim()
const summary = 'Resumo curto e completo do prefeito.'
const summarized = normalizeResearchInput(
{
municipalitySlug: 'feira-de-santana',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map((id) => validItem(id, { answer: longAnswer, summary })),
gaps: [],
},
{ now: generatedAt },
)
const capped = asReport(
buildCityReport({ snapshot, research: summarized, emendas, generatedAt }),
)
const who = capped.page1.blocks.find((block) => block.kind === 'grid')!.cells![1].blocks[0]
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!
expect(prefeito.value).toBe(summary)
})
it('falls back to a pointer line (never "…") when the full text does not fit (C188)', () => {
const longAnswer = 'palavra '.repeat(20).trim()
const longResearch = normalizeResearchInput(
{
municipalitySlug: 'feira-de-santana',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map((id) => validItem(id, { answer: longAnswer })),
gaps: [],
},
{ now: generatedAt },
)
const pointer = asReport(
buildCityReport({
snapshot,
research: longResearch,
emendas,
generatedAt,
textFallback: 'pointer',
}),
)
const who = pointer.page1.blocks.find((block) => block.kind === 'grid')!.cells![1].blocks[0]
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!
expect(prefeito.value).toBe(SUMMARY_POINTER_COPY)
expect(prefeito.value!.includes('…')).toBe(false)
const appendix = pointer.sections
.find((section) => section.id === 'fontes')!
.blocks.find((block) => block.title === 'Pesquisa — respostas integrais (texto completo)')!
const rows = appendix.rows as unknown as TableRow[]
expect(rows[0].answer).toContain(longAnswer)
})
it('keeps short research answers untouched on page 1 and shows the list remainder', () => {
const who = report.page1.blocks.find((block) => block.kind === 'grid')!.cells![1].blocks[0]
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!

View file

@ -31,6 +31,27 @@ describe('normalizeResearchInput', () => {
expect(research.gaps).toEqual([])
})
it('normalizes the optional per-item summary without changing the source contract (C188)', () => {
const research = normalizeResearchInput(
baseResearch({
items: RESEARCH_CHECKLIST_IDS.map((id) =>
id === 'prefeito'
? validItem(id, { summary: ' Prefeito X, do PSD, em primeiro mandato. ' })
: id === 'vice'
? validItem(id, { summary: ' ' })
: validItem(id),
),
}),
{ now },
)
const prefeito = research.items.find((item) => item.id === 'prefeito')!
expect(prefeito.summary).toBe('Prefeito X, do PSD, em primeiro mandato.')
expect(prefeito.answer).toBe('Resposta de prefeito')
expect(research.items.find((item) => item.id === 'vice')!.summary).toBeNull()
expect(research.items.find((item) => item.id === 'vereadores')!.summary).toBeNull()
expect(research.gaps).toEqual([])
})
it('throws when the research is not dated', () => {
expect(() => normalizeResearchInput(baseResearch({ researchedAt: null }), { now })).toThrow(
/researchedAt/,

View file

@ -6,6 +6,7 @@ import {
mergeDossierResearch,
normalizeDossierResearchInput,
} from '../../scripts/lib/dossieResearch.mjs'
import { SUMMARY_POINTER_COPY } from '../../scripts/lib/reportText.mjs'
const generatedAt = new Date('2026-09-17T12:00:00.000Z')
@ -101,13 +102,13 @@ describe('buildDossierReport', () => {
const report = build()
expect(report.eras.map((era) => era?.id)).toEqual(['A', 'B', 'C'])
expect(
report.eras.every((era) => (era?.numbers.length ?? 0) + (era?.actions.length ?? 0) > 0),
report.eras.every((era) => (era?.numbers.items.length ?? 0) + (era?.actions.length ?? 0) > 0),
).toBe(true)
})
it('turns official emenda execution into phase-labelled rows (empenho ≠ pagamento)', () => {
const eraC = build().eras.find((era) => era?.id === 'C')
expect(eraC?.numbers).toContainEqual(
expect(eraC?.numbers.items).toContainEqual(
expect.objectContaining({ object: 'Saúde', phase: 'empenhado', sphere: 'municipio' }),
)
})
@ -118,7 +119,7 @@ describe('buildDossierReport', () => {
expect.objectContaining({ topic: 'População residente (Censo 2022)' }),
)
const eraB = report.eras.find((era) => era?.id === 'B')
expect(eraB?.numbers.some((row) => row.object.includes('População'))).toBe(false)
expect(eraB?.numbers.items.some((row) => row.object.includes('População'))).toBe(false)
})
it('never sums região/polo into the município list', () => {
@ -154,11 +155,104 @@ describe('buildDossierReport', () => {
it('caps the page-1 deliveries and keeps the full timeline on its own page', () => {
const report = build()
expect(report.page1.deliveries.length).toBeLessThanOrEqual(3)
expect(report.page1.deliveries.items.length).toBeLessThanOrEqual(3)
expect(report.page1.deliveries.total).toBeGreaterThanOrEqual(
report.page1.deliveries.items.length,
)
expect(report.page1.timeline.length).toBe(6)
expect(report.trajectory.length).toBeGreaterThan(report.page1.timeline.length)
})
it('prefers the researcher summary on the resumo and keeps the integral in the era (C188)', () => {
const summarized = mergeDossierResearch([
normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista')])),
normalizeDossierResearchInput(eraFile('B', [item('era_b_sesab')])),
normalizeDossierResearchInput(
eraFile('C', [
item('era_c_emendas', {
summary: 'Emenda de R$ 1,2 mi empenhada em 2024.',
numbers: [{ label: 'Recurso', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
}),
]),
),
])
const report = build({ research: summarized })
const delivery = report.page1.deliveries.items.find((row: { era: string }) => row.era === 'C')!
expect(delivery.title).toBe('Emenda de R$ 1,2 mi empenhada em 2024.')
const eraC = report.eras.find((era) => era?.id === 'C')!
expect(
eraC.actions.some(
(action: { title: string }) => action.title === 'Resposta de era_c_emendas',
),
).toBe(true)
})
it('falls back to a pointer line (never "…") without losing the era integral (C188)', () => {
const report = build({ textFallback: 'pointer' })
const delivery = report.page1.deliveries.items[0]
expect(delivery.title).toBe(SUMMARY_POINTER_COPY)
expect(delivery.detail).toBeNull()
expect(delivery.title.includes('…')).toBe(false)
const eraC = report.eras.find((era) => era?.id === 'C')!
expect(
eraC.actions.some(
(action: { title: string }) => action.title === 'Resposta de era_c_emendas',
),
).toBe(true)
})
it('keeps the researcher summary even on the pointer pass (C188)', () => {
const summarized = mergeDossierResearch([
normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista')])),
normalizeDossierResearchInput(eraFile('B', [item('era_b_sesab')])),
normalizeDossierResearchInput(
eraFile('C', [
item('era_c_emendas', {
summary: 'Emenda de R$ 1,2 mi empenhada em 2024.',
numbers: [{ label: 'Recurso', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
}),
]),
),
])
const report = build({ research: summarized, textFallback: 'pointer' })
const summaryDelivery = report.page1.deliveries.items.find(
(row: { era: string }) => row.era === 'C',
)!
expect(summaryDelivery.title).toBe('Emenda de R$ 1,2 mi empenhada em 2024.')
const hook = report.page1.hooks.items.find(
(row: { angle: string }) => row.angle === SUMMARY_POINTER_COPY,
)
expect(Boolean(hook)).toBe(true)
})
it('counts every capped list so nothing disappears in silence (C188)', () => {
const manyRegional = mergeDossierResearch([
normalizeDossierResearchInput(
eraFile(
'A',
[
'era_a_formacao',
'era_a_sesab',
'era_a_consultor_ms',
'era_a_conquista',
'era_a_sas_ms',
].map((id) => item(id, { sphere: 'regiao' })),
),
),
normalizeDossierResearchInput(eraFile('B', [])),
normalizeDossierResearchInput(eraFile('C', [])),
])
const report = build({ research: manyRegional })
const eraA = report.eras.find((era) => era?.id === 'A')!
expect(eraA.actions).toHaveLength(5)
expect(eraA.camaraActionsRemaining).toBe(0)
expect(report.region.evidence.items).toHaveLength(3)
expect(report.region.evidence.total).toBe(5)
expect(report.region.evidence.remaining).toBe(2)
expect(report.region.regional.total).toBe(5)
expect(report.region.regional.items.length + report.region.regional.remaining).toBe(5)
})
it('carries the defeso limits and the editorial rules', () => {
const report = build()
expect(report.limits.editorial).toContain('Sem fonte, não publica.')

View file

@ -31,6 +31,20 @@ describe('buildBulletin', () => {
expect(bulletin.moreItems).toHaveLength(BULLETIN_MORE_LIMIT)
})
it('counts the facts left out of the one-page boletim (never a silent drop, C188)', () => {
const facts = Array.from({ length: 25 }, (_value, index) => fact(index))
const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt })
expect(bulletin.factsTotal).toBe(25)
expect(bulletin.highlights.length + bulletin.moreItems.length + bulletin.factsRemaining).toBe(
bulletin.factsTotal,
)
})
it('has no remainder when everything fits', () => {
const bulletin = buildBulletin({ facts: [fact(1)], municipality: 'Ilhéus', generatedAt })
expect(bulletin.factsRemaining).toBe(0)
})
it('orders município before região and a sourced number before none', () => {
const facts = [
fact(1, { sphere: 'regiao' }),

View file

@ -43,6 +43,17 @@ describe('normalizeDossierResearchInput', () => {
expect(research.items.every((item) => item.sphere === 'municipio')).toBe(true)
})
it('normalizes the optional per-item summary without changing the source contract (C188)', () => {
const items = dossierChecklistForEra('C').map((item) => validItem(item.id))
items[0] = validItem(items[0].id, { summary: ' Recurso de R$ 1 mi empenhado em 2024. ' })
items[1] = validItem(items[1].id, { summary: ' ' })
const research = normalizeDossierResearchInput(eraResearch('C', { items }))
expect(research.items[0].summary).toBe('Recurso de R$ 1 mi empenhado em 2024.')
expect(research.items[0].answer).toBe(`Resposta de ${items[0].id}`)
expect(research.items[1].summary).toBeNull()
expect(research.gaps).toEqual([])
})
it('normalizes the reformulated brief and drops it when it has no title', () => {
const research = normalizeDossierResearchInput(
eraResearch('C', {

View file

@ -1,6 +1,17 @@
import { describe, expect, it } from 'vitest'
import { stripInlineSources } from '../../scripts/lib/reportText.mjs'
import {
SUMMARY_POINTER_COPY,
capList,
excerptDeepDive,
joinWithRemainder,
moreItemsLabel,
showingLabel,
stripInlineSources,
summaryListText,
summarySurfaceText,
summaryText,
} from '../../scripts/lib/reportText.mjs'
describe('stripInlineSources', () => {
it('drops a trailing token and the stray space before punctuation', () => {
@ -19,3 +30,127 @@ describe('stripInlineSources', () => {
expect(stripInlineSources('Sem marcador de fonte.')).toBe('Sem marcador de fonte.')
})
})
describe('excerptDeepDive (C188)', () => {
it('cuts with an explicit ellipsis beyond the limit', () => {
expect(excerptDeepDive('a'.repeat(10), 4)).toBe('aaaa…')
})
it('passes short text through untouched', () => {
expect(excerptDeepDive('curto', 40)).toBe('curto')
})
it('is the only primitive that may print "…" (summary primitives never do)', () => {
expect(excerptDeepDive('palavra '.repeat(30), 20)).toContain('…')
expect(
summarySurfaceText({ answer: 'palavra '.repeat(30) }, 'pointer').text.includes('…'),
).toBe(false)
expect(joinWithRemainder(['a'.repeat(200), 'b'], 1).includes('…')).toBe(false)
expect(summaryListText(['a'.repeat(200), 'b'], { limit: 1, max: 90 }).includes('…')).toBe(false)
expect(moreItemsLabel(3, 'item', 'itens').includes('…')).toBe(false)
})
})
describe('capList (C188)', () => {
it('returns items, total and remaining — the tail is never silent', () => {
expect(capList([1, 2, 3, 4, 5], 2)).toEqual({ items: [1, 2], total: 5, remaining: 3 })
})
it('handles an empty or short list without a phantom remainder', () => {
expect(capList([], 4)).toEqual({ items: [], total: 0, remaining: 0 })
expect(capList([1, 2], 4)).toEqual({ items: [1, 2], total: 2, remaining: 0 })
})
it('tolerates a non-array input', () => {
expect(capList(null as unknown as number[], 4)).toEqual({ items: [], total: 0, remaining: 0 })
})
})
describe('joinWithRemainder (C188)', () => {
it('caps by count and states the remainder instead of cutting characters', () => {
expect(joinWithRemainder(['Ana', 'Beto', 'Caio', 'Dora'], 2)).toBe('Ana, Beto e mais 2')
})
it('never truncates an individual value', () => {
const long = 'Um nome bastante longo que não pode ser cortado no meio'
expect(joinWithRemainder([long, 'Outro'], 1)).toBe(`${long} e mais 1`)
})
it('drops empty values and does not add a remainder when everything fits', () => {
expect(joinWithRemainder(['Ana', null, 'Beto'], 2)).toBe('Ana, Beto')
})
})
describe('summarySurfaceText (C188)', () => {
it('prefers the researcher summary, even on the pointer pass', () => {
const item = { summary: 'Resumo curto.', answer: 'Resposta integral longa.' }
expect(summarySurfaceText(item, 'full')).toEqual({ text: 'Resumo curto.', fromSummary: true })
expect(summarySurfaceText(item, 'pointer')).toEqual({
text: 'Resumo curto.',
fromSummary: true,
})
})
it('degrades an un-summarized answer to the pointer only on the pointer pass', () => {
const item = { summary: null, answer: 'Resposta integral.' }
expect(summarySurfaceText(item, 'full')).toEqual({
text: 'Resposta integral.',
fromSummary: false,
})
expect(summarySurfaceText(item, 'pointer')).toEqual({
text: SUMMARY_POINTER_COPY,
fromSummary: false,
})
expect(SUMMARY_POINTER_COPY).not.toContain('…')
})
})
describe('summaryListText (C188)', () => {
it('keeps a short joined list with its counter', () => {
expect(summaryListText(['Ana', 'Beto', 'Caio'], { limit: 2, max: 90 })).toBe(
'Ana, Beto e mais 1',
)
})
it('degrades an over-budget joined list to the pointer, never cutting with "…"', () => {
const values = ['a'.repeat(120), 'b']
expect(summaryListText(values, { limit: 1, max: 90, fallback: 'pointer' })).toBe(
SUMMARY_POINTER_COPY,
)
const kept = summaryListText(values, { limit: 1, max: 90 })
expect(kept.includes('…')).toBe(false)
expect(kept.length).toBeGreaterThan(90)
})
})
describe('summaryText (C188)', () => {
it('prefers the researcher summary and flags its origin', () => {
expect(
summaryText({ summary: ' Resumo curto. ', answer: 'Resposta integral longa.' }),
).toEqual({ text: 'Resumo curto.', fromSummary: true })
})
it('falls back to the full answer when there is no summary', () => {
expect(summaryText({ summary: null, answer: 'Resposta integral.' })).toEqual({
text: 'Resposta integral.',
fromSummary: false,
})
expect(summaryText({ summary: ' ', answer: 'Resposta integral.' }).fromSummary).toBe(false)
})
it('exposes the pointer copy used when even the full answer does not fit', () => {
expect(SUMMARY_POINTER_COPY).toBe('Texto integral no aprofundamento')
expect(SUMMARY_POINTER_COPY).not.toContain('…')
})
})
describe('counter copy (C188)', () => {
it('builds "e mais N" with the right plural', () => {
expect(moreItemsLabel(1, 'item', 'itens')).toBe('e mais 1 item')
expect(moreItemsLabel(3, 'item', 'itens')).toBe('e mais 3 itens')
})
it('builds "Mostrando X de Y"', () => {
expect(showingLabel(3, 10, 'liderança', 'lideranças')).toBe('Mostrando 3 de 10 lideranças')
})
})