mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
cb5854d17e
commit
df3f3160a9
23 changed files with 1085 additions and 238 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
docs/changelog/2026-09-17-c188.md
Normal file
1
docs/changelog/2026-09-17-c188.md
Normal 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.
|
||||
73
docs/plans/escala-dry-pos-c188.md
Normal file
73
docs/plans/escala-dry-pos-c188.md
Normal 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.
|
||||
159
docs/plans/relatorios-sem-truncamento-impl.md
Normal file
159
docs/plans/relatorios-sem-truncamento-impl.md
Normal 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).
|
||||
|
|
@ -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'} ` +
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'])}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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})`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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)')!
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue