From 4b00a8778a1ef6d2b04978ab15ef11ea8a833b09 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 17 Sep 2026 18:57:03 -0300 Subject: [PATCH] =?UTF-8?q?feat(C188):=20relat=C3=B3rios=20e=20dossi=C3=AA?= =?UTF-8?q?s=20sem=20truncamento=20mec=C3=A2nico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .agents/skills/dossie-solla-cidade/SKILL.md | 14 ++ .agents/skills/relatorio-cidade/SKILL.md | 13 +- docs/changelog/2026-09-17-c188.md | 1 + docs/plans/escala-dry-pos-c188.md | 73 ++++++++ docs/plans/relatorios-sem-truncamento-impl.md | 159 ++++++++++++++++++ scripts/build-city-report.mjs | 70 +++++--- scripts/build-dossie-solla-cidade.mjs | 80 ++++++--- scripts/lib/buildPdf.mjs | 77 +++++++-- scripts/lib/cityReportBlocks.mjs | 154 +++++++++-------- scripts/lib/cityReportResearch.mjs | 4 + scripts/lib/dossieBlocks.mjs | 107 ++++++------ scripts/lib/dossieBulletin.mjs | 5 + scripts/lib/dossieBulletinRender.mjs | 8 +- scripts/lib/dossieCamara.mjs | 4 +- scripts/lib/dossieRender.mjs | 98 ++++++++--- scripts/lib/dossieResearch.mjs | 4 + scripts/lib/reportText.mjs | 106 +++++++++++- tests/unit/cityReportBlocks.unit.spec.ts | 61 ++++++- tests/unit/cityReportResearch.unit.spec.ts | 21 +++ tests/unit/dossieBlocks.unit.spec.ts | 102 ++++++++++- tests/unit/dossieBulletin.unit.spec.ts | 14 ++ tests/unit/dossieResearch.unit.spec.ts | 11 ++ tests/unit/reportText.unit.spec.ts | 137 ++++++++++++++- 23 files changed, 1085 insertions(+), 238 deletions(-) create mode 100644 docs/changelog/2026-09-17-c188.md create mode 100644 docs/plans/escala-dry-pos-c188.md create mode 100644 docs/plans/relatorios-sem-truncamento-impl.md diff --git a/.agents/skills/dossie-solla-cidade/SKILL.md b/.agents/skills/dossie-solla-cidade/SKILL.md index 9678a417..87b2eeb9 100644 --- a/.agents/skills/dossie-solla-cidade/SKILL.md +++ b/.agents/skills/dossie-solla-cidade/SKILL.md @@ -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 diff --git a/.agents/skills/relatorio-cidade/SKILL.md b/.agents/skills/relatorio-cidade/SKILL.md index 3225d67e..fe20f2ea 100644 --- a/.agents/skills/relatorio-cidade/SKILL.md +++ b/.agents/skills/relatorio-cidade/SKILL.md @@ -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/.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 diff --git a/docs/changelog/2026-09-17-c188.md b/docs/changelog/2026-09-17-c188.md new file mode 100644 index 00000000..87fead96 --- /dev/null +++ b/docs/changelog/2026-09-17-c188.md @@ -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. diff --git a/docs/plans/escala-dry-pos-c188.md b/docs/plans/escala-dry-pos-c188.md new file mode 100644 index 00000000..57bd4689 --- /dev/null +++ b/docs/plans/escala-dry-pos-c188.md @@ -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. diff --git a/docs/plans/relatorios-sem-truncamento-impl.md b/docs/plans/relatorios-sem-truncamento-impl.md new file mode 100644 index 00000000..cc409990 --- /dev/null +++ b/docs/plans/relatorios-sem-truncamento-impl.md @@ -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:
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 — ") | 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). diff --git a/scripts/build-city-report.mjs b/scripts/build-city-report.mjs index 03f43685..87520cf3 100644 --- a/scripts/build-city-report.mjs +++ b/scripts/build-city-report.mjs @@ -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: '
', footerTemplate: `
- ${report.meta.title} · ${snapshot.municipality.name} · documento interno de campanha + ${artifacts.report.meta.title} · ${snapshot.municipality.name} · documento interno de campanha pág. /
`, 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'} ` + diff --git a/scripts/build-dossie-solla-cidade.mjs b/scripts/build-dossie-solla-cidade.mjs index 8b8cb349..229153ad 100644 --- a/scripts/build-dossie-solla-cidade.mjs +++ b/scripts/build-dossie-solla-cidade.mjs @@ -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)}`, ) diff --git a/scripts/lib/buildPdf.mjs b/scripts/lib/buildPdf.mjs index c2b81719..4c8a8882 100644 --- a/scripts/lib/buildPdf.mjs +++ b/scripts/lib/buildPdf.mjs @@ -34,6 +34,19 @@ const measurePageOverflows = (page, budgetPx = A4_PAGE_BUDGET_PX) => budgetPx, ) +/** Overflows of a single `[data-page=""]` 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' }) diff --git a/scripts/lib/cityReportBlocks.mjs b/scripts/lib/cityReportBlocks.mjs index 6a2f1f41..d79eac75 100644 --- a/scripts/lib/cityReportBlocks.mjs +++ b/scripts/lib/cityReportBlocks.mjs @@ -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, } } diff --git a/scripts/lib/cityReportResearch.mjs b/scripts/lib/cityReportResearch.mjs index a35a99a4..d41b3b60 100644 --- a/scripts/lib/cityReportResearch.mjs +++ b/scripts/lib/cityReportResearch.mjs @@ -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, diff --git a/scripts/lib/dossieBlocks.mjs b/scripts/lib/dossieBlocks.mjs index 4acf0994..7855591a 100644 --- a/scripts/lib/dossieBlocks.mjs +++ b/scripts/lib/dossieBlocks.mjs @@ -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, } }) diff --git a/scripts/lib/dossieBulletin.mjs b/scripts/lib/dossieBulletin.mjs index 49540c38..f5fa0e26 100644 --- a/scripts/lib/dossieBulletin.mjs +++ b/scripts/lib/dossieBulletin.mjs @@ -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, } } diff --git a/scripts/lib/dossieBulletinRender.mjs b/scripts/lib/dossieBulletinRender.mjs index 78fbab51..7623aecd 100644 --- a/scripts/lib/dossieBulletinRender.mjs +++ b/scripts/lib/dossieBulletinRender.mjs @@ -6,7 +6,7 @@ */ import { isInstitutionUnit } from './dossieUnit.mjs' -import { htmlEscape } from './reportText.mjs' +import { htmlEscape, moreItemsLabel } from './reportText.mjs' const ASSET = (title, lines) => `

${htmlEscape(title)}

${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) => ` ? `

${bulletin.moreItems.map(renderMoreItem).join('')}
` : '

Sem itens adicionais com fonte além dos destaques.

Conferir as lacunas explícitas no dossiê da cidade antes de ampliar o boletim.

' } + ${ + bulletin.factsRemaining > 0 + ? `

${htmlEscape(moreItemsLabel(bulletin.factsRemaining, 'fato com fonte', 'fatos com fonte'))} no dossiê da cidade — o boletim de uma página não os exibe.

` + : '' + }