From b0582d501176024b2cbbd276eeb02b2fe1f33293 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 16 Sep 2026 01:20:58 -0300 Subject: [PATCH] =?UTF-8?q?fix(OPS120):=20p=C3=A1gina=201=20do=20relat?= =?UTF-8?q?=C3=B3rio=20de=20cidade=20cabe=20com=20pesquisa=20cheia=20(Clos?= =?UTF-8?q?es=20#1069)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/relatorio-cidade/SKILL.md | 16 ++- docs/changelog/2026-09-16-ops120.md | 1 + ...rio-cidade-pagina-1-pesquisa-cheia-impl.md | 119 ++++++++++++++++++ scripts/lib/cityReportBlocks.mjs | 111 +++++++++++++--- tests/unit/cityReportBlocks.unit.spec.ts | 64 +++++++++- 5 files changed, 287 insertions(+), 24 deletions(-) create mode 100644 docs/changelog/2026-09-16-ops120.md create mode 100644 docs/plans/ops120-relatorio-cidade-pagina-1-pesquisa-cheia-impl.md diff --git a/.agents/skills/relatorio-cidade/SKILL.md b/.agents/skills/relatorio-cidade/SKILL.md index d771d7f8..28dcd2bb 100644 --- a/.agents/skills/relatorio-cidade/SKILL.md +++ b/.agents/skills/relatorio-cidade/SKILL.md @@ -293,7 +293,10 @@ cacheado em `data/relatorios-cidade/.emendas.json` para replay. campo, lideranças, dobradinhas, vereadores), o que Solla entregou (emendas oficiais com fase, acervo de falas, notícias ≤90 dias), **indícios web de emendas** quando a fonte oficial não atribui ao município, anunciar × não - anunciar, riscos e pontos sem leitura. + anunciar, riscos e pontos sem leitura. 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 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 e os prováveis candidatos do campo do prefeito (pesquisa), além da **frente de @@ -315,7 +318,9 @@ cacheado em `data/relatorios-cidade/.emendas.json` para replay. VOD da Câmara, que é o clipe do próprio trecho) e **Transcrição** (PDF do Diário) quando existirem · `11. Notícias e imprensa` · `12. Panorama regional` · `13. Abordagem sugerida (personas)` · `14. Fontes e - limites`. + limites` — lista as fontes e, antes delas, a tabela **"Pesquisa — respostas + integrais"** com o texto completo de cada item do checklist (o que a página 1 + capou). - **Acervo por região e tema:** como cidade pequena quase nunca tem fala própria, a seção 10 mostra três recortes **não sobrepostos**: falas do município (quando existem), **menções à região** (demais municípios do mesmo @@ -383,8 +388,11 @@ cacheado em `data/relatorios-cidade/.emendas.json` para replay. ## Troubleshooting -- **Página 1 estourou**: o builder aborta com `scrollHeight > útil`; corte copy - do resumo/`caps`, não mexa no layout para "espremer". +- **Página 1 estourou**: o builder aborta com `scrollHeight > útil` e o erro é + açãoável. A página 1 já aplica tetos de conteúdo aos textos da pesquisa + (`PAGE_ONE_*_MAX` em `scripts/lib/cityReportBlocks.mjs`) e preserva o texto + integral no aprofundamento; se ainda estourar, **aperte o teto da pesquisa ou + corte copy do resumo — nunca mexa no layout para "espremer"**. - **`tsx` ausente no homeserver**: `pnpm install --prod=false`. - **API de emendas 429/erro**: o builder degrada para lacuna com URL+motivo; reexecute depois (o cache só é escrito com resultado utilizável). diff --git a/docs/changelog/2026-09-16-ops120.md b/docs/changelog/2026-09-16-ops120.md new file mode 100644 index 00000000..25c03931 --- /dev/null +++ b/docs/changelog/2026-09-16-ops120.md @@ -0,0 +1 @@ +**OPS120 — relatório de cidade: página 1 sob pesquisa cheia (2026-09-16):** corrige o defeito em que o builder abortava a página 1 (`A página 1 estourou (…px > 1001px úteis)`) com o checklist de pesquisa cheio (9 itens) + notícias ≤90 dias — o `itacaré` do lote OPS118 estourou em 1110px. A causa não eram as notícias (na página 1 há só a contagem no KPI), e sim os textos livres dos `items[].answer` reusados na página 1 por `researchAnswerItem` ("Quem é quem", par anunciar×não, card de oposição e callout de emendas). O corte agora é por **teto/orçamento de conteúdo** em `scripts/lib/cityReportBlocks.mjs` — helpers `excerpt` (com `…` explícito) e `joinLimited` (`e mais N`), constantes `PAGE_ONE_ANSWER_MAX=90`, `PAGE_ONE_WHO_MAX=30`, `PAGE_ONE_RISK_MAX=70`, `PAGE_ONE_LIST_LIMIT=2` e `PAGE_ONE_NAMES_MAX=20`, no espírito dos caps do E16 — sem mexer em layout/CSS nem no `PAGE_ONE_BUDGET_PX` da guarda fail-closed. Para o corte nunca perder fato com fonte, a seção `fontes` ganha a tabela **"Pesquisa — respostas integrais (texto completo)"** (Item | Resposta integral | Data, com link inline), emitida só quando há itens de pesquisa. Prova local (Chromium, mesma viewport/print do builder): fixture que reproduz o itacaré (pré-fix 1142px) e fixture adversarial (respostas de 400 chars, 5 assessores, 6 dobradinhas, 6 riscos, 30 notícias) passam a guarda, e as 9 cidades reais do lote OPS118 seguem gerando PDF+MD com margem ≥ ~23px. Testes unit atualizados/criados em `tests/unit/cityReportBlocks.unit.spec.ts` (cap da página 1 + preservação integral no aprofundamento, texto curto intacto, `e mais N` sobrevive ao cap). Skill `.agents/skills/relatorio-cidade/SKILL.md` atualizada (página 1 capada, tabela em `14. Fontes e limites`, troubleshooting). Sem schema/migração/access/Consent/PII. Issue #1069. diff --git a/docs/plans/ops120-relatorio-cidade-pagina-1-pesquisa-cheia-impl.md b/docs/plans/ops120-relatorio-cidade-pagina-1-pesquisa-cheia-impl.md new file mode 100644 index 00000000..02fc00e8 --- /dev/null +++ b/docs/plans/ops120-relatorio-cidade-pagina-1-pesquisa-cheia-impl.md @@ -0,0 +1,119 @@ +# Impl: OPS120 — relatório de cidade: página 1 sob pesquisa cheia (C163) + +Status: aprovado +Atualizado em: 2026-09-16 +Issue: #1069 +Intenção: docs/plans/ops120-relatorio-cidade-pagina-1-pesquisa-cheia.md +Appetite restante: herdado (~1 dia eng) + +> Aprovado pelo próprio agente via `--auto` (modo autônomo do `work-issue`); a +> pausa do gate 3c virou apresentação-no-chat-sem-espera. + +## Leitura da intenção + +- **Outcome:** cidade com checklist de pesquisa cheio (9 itens) + notícias ≤90 dias gera PDF+MD com a página 1 dentro do guard (`PAGE_ONE_BUDGET_PX = 1001`), cortando por **teto/orçamento de conteúdo** (cap explícito + `totalCount`/`e mais N`, padrão E16) — sem espremer layout. O texto integral preservado segue acessível no aprofundamento (seções 2+). Cidade pequena/sem pesquisa não regride. Se ainda estourar, o erro continua fail-closed e acionável. +- **O que NÃO negociar:** não mexer em layout/CSS nem no orçamento do guard; não criar seção nova; não reaproveitar o corte para "esconder" fato com fonte (integral do checklist precisa sobreviver); atribuição de emenda a município (#1025) fica de fora; preservar proveniência/fonte na página 1; zero regressão para cidade sem pesquisa. +- **O que reavaliar:** a intenção lista "notícias e quem é quem com cap". A verificação no código mostra que **notícias não têm lista na página 1** (só `research.news.length` no KPI), então capar notícia na página 1 é inócuo — o que de fato infla são os `items[].answer` da pesquisa reusados na página 1 por `researchAnswerItem` (`buildWhoRows`, `buildAnnouncePair`, `buildRiskCards`, `buildEmendasEvidenceBlock`). O teto correto é sobre esses textos livres, não sobre notícias. + +## Abordagem recomendada + +```mermaid +flowchart LR + R[research.json
items[].answer] --> C[cityReportBlocks.buildPageOne
caps PAGE_ONE_*] + S[snapshot Teqo ro] --> C + E[emendas.json] --> C + C --> P1[Página 1 resumida
excerpt + 'e mais N'] + C --> F[Seção fontes 2+
tabela 'respostas integrais'] + P1 --> H[build-city-report.mjs
scrollHeight > 1001?] + F --> H + H -->|não| PDF[PDF + MD] + H -->|sim| D[die fail-closed acionável] +``` + +**Opções consideradas:** A — capar os textos livres da página 1 (excerpt + `e mais N`) e preservar o texto integral numa tabela dentro da seção `fontes` existente | B — remover a linha de fonte por item da página 1 (~52px) | C — capar/omitir linhas inteiras do "Quem é quem" (row-cap E16). + +**Recomendação:** A. É o único caminho que ataca a causa real (textos livres da pesquisa) sem tirar proveniência nem empobrecer silenciosamente: os caps de excerpt reduzem a altura de forma previsível (cada linha do "Quem é quem" custa ~17px), o `e mais N` mantém a contagem visível e o integral continua publicado no aprofundamento. Validado localmente em Chromium na mesma viewport/print do builder: fixture "itacaré" repro 1142px→978px e fixture "worst" (respostas 400 chars, 5 assessores, 6 dobradinhas, 6 riscos, 30 notícias) passa a guarda; as 9 cidades reais do lote OPS118 continuam passando com margem ≥ ~23px. + +**Rejeitadas:** + +- **B** — ganha altura mas remove a proveniência da página 1, contrariando "sem fonte, não publica"; o mecanismo prescrito pela intenção é teto de conteúdo, não restruturar layout de fonte. +- **C** — cortar linhas inteiras do "Quem é quem" mudaria o conteúdo de página 1 fixado na intenção (prefeito/vice/relação/lideranças/dobradinhas/vereadores) e não há "full tab" equivalente para recuperar o restante, ao contrário do texto do checklist. + +Self-score decision-quality: **4/5** — decisão ancorada em medição real (Chromium + fixtures + cidades reais) e causa-raiz confirmada no código; perde 1 ponto porque o teto de 90/30 chars é empírico, calibrado pela margem observada, e pode pedir reajuste se o layout mudar. + +### Componentes / mudanças + +- **`buildPageOne`** (`scripts/lib/cityReportBlocks.mjs`): compõe a página 1 (strip, grid "Conta eleitoral 2022" + "Quem é quem", KPIs, callout de emendas, par anunciar×não, cards de risco, gap callout, footer). É o ponto onde os tetos entram. +- **`researchAnswerItem(research, id, max = PAGE_ONE_ANSWER_MAX)`** (`scripts/lib/cityReportBlocks.mjs`): hoje devolve o texto livre cru; passa a capar com `excerpt`. Consumidores: `buildWhoRows` (prefeito/vice/relação/vereadores), `buildAnnouncePair` (relação de novo), `buildRiskCards` (disputa_local ?? quem_investe), `buildEmendasEvidenceBlock` (emendas_web). +- **`excerpt(text, max)`** e **`joinLimited(values, limit, max)`** (`scripts/lib/cityReportBlocks.mjs`): helpers puros de corte. `excerpt` corta com `…` explícito e deixa texto curto intacto; `joinLimited` mostra os `limit` primeiros, capa a `max` e **sempre** anexa `e mais N` para o total sobreviver. `Responsável`, `Dobradinhas` e `Riscos na base` usam `joinLimited`. +- **Constantes de orçamento** (`scripts/lib/cityReportBlocks.mjs`): `PAGE_ONE_ANSWER_MAX=90` (texto livre de largura total), `PAGE_ONE_WHO_MAX=30` (célula estreita de 2 colunas), `PAGE_ONE_RISK_MAX=70` (card de oposição, que ainda concatena o sufixo de fonte), `PAGE_ONE_LIST_LIMIT=2`, `PAGE_ONE_NAMES_MAX=20`. Bloco documentado como "content budget da página 1", no mesmo espírito dos caps do E16 (`src/utilities/municipality/municipalityDossierData.ts:45-48`) e dos caps de excerpt já existentes (`SIGNAL_EXCERPT_MAX`, `SPEECH_SUMMARY_MAX`, `SPEECH_MENTION_MAX`). +- **Tabela "Pesquisa — respostas integrais (texto completo)"** (`scripts/lib/cityReportBlocks.mjs`, dentro de `buildSourcesSection`): bloco `table` (Item | Resposta (texto integral) | Fonte) dentro da **seção existente** `fontes` ("Fontes e limites"), usando `sourcesOf`/`withInlineSources` para os links inline. Só é emitido quando `research.items.length > 0` — cidade sem pesquisa não ganha bloco. +- **Guarda fail-closed** (`scripts/build-city-report.mjs`): intocada. Continua medindo `[data-page="summary"].scrollHeight` e abortando com `die()` se > `PAGE_ONE_BUDGET_PX`. +- **Render** (`scripts/lib/cityReportRender.mjs`): nenhuma mudança — os blocos já renderizam `table` e o `data-page="summary"` continua no `
`. +- **SKILL.md** (`.agents/skills/relatorio-cidade/SKILL.md`): descreve a página 1 capada com `e mais N`, adiciona a tabela de respostas integrais em `14. Fontes e limites` e reescreve o troubleshooting "Página 1 estourou" para apontar os tetos `PAGE_ONE_*` e o texto integral no aprofundamento. + +### Dados → forma + +- `research.items[].answer` (texto livre, potencialmente longo) → página 1: `excerpt(answer, PAGE_ONE_ANSWER_MAX | PAGE_ONE_WHO_MAX)` → cabe no orçamento. +- Listas de nomes/textos (`conjuncture.risks`, responsáveis/dobradinhas) → `joinLimited(values, 2, max)` → `"A, B e mais N"`. +- `research.items` → seção `fontes`: tabela com o `answer` integral + fonte inline (fato com fonte nunca é perdido pelo corte). +- Sem pesquisa (`research.items` vazio ou ausente) → nenhuma tabela nova, nenhum cap aplicado a conteúdo inexistente → saída idêntica à atual. + +## Fases verificáveis + +1. **Medir e fixar tetos** — reproduzir a fixture do itacaré (base ubaitaba + respostas longas / mais assessores / riscos) e medir `scrollHeight` por bloco no Chromium com a mesma viewport/print do builder; confirmar que o callout de emendas e o card de oposição são os que mais crescem e que o grid é dominado pelo "Quem é quem"; calibrar `PAGE_ONE_ANSWER_MAX`/`PAGE_ONE_WHO_MAX`/`PAGE_ONE_LIST_LIMIT`/`PAGE_ONE_NAMES_MAX` até a fixture caber com folga. _(concluído: caps definidos em 90/30/2/20.)_ +2. **Aplicar os tetos na composição** — introduzir `excerpt`/`joinLimited`, parametrizar `researchAnswerItem`, aplicar os caps em quem-é-quem/riscos/evidência/listas e adicionar a tabela de respostas integrais em `fontes` (condicionada a `research.items.length > 0`); guarda intacta. +3. **Prova local + gates** — fixtures plausível/itacaré e worst + as 9 cidades reais do lote OPS118 (margem ≥ ~23px); cidade sem pesquisa sem mudança; rodar unit (`tests/unit/cityReportBlocks.unit.spec.ts`, `cityReportRender.unit.spec.ts`, `cityReportResearch.unit.spec.ts`, `cityReportBatchSkill.unit.spec.ts`), o snapshot int (`tests/int/cityReportSnapshot.int.spec.ts`) e os gates do PR (lint/format/typecheck/knip/cycles/unit/int/build). + +## Rabbit holes / Não escopo (engenharia) + +- **Não** mexer em layout/CSS nem no `PAGE_ONE_BUDGET_PX`: proibido pela intenção; o problema é orçamento de conteúdo, não espaço. +- **Não** criar seção nova: a tabela de integrais vive dentro da seção `fontes` existente. +- **Não** capar notícias na página 1: não existe lista de notícias ali (só a contagem no KPI) — seria código morto. +- **Não** cortar linhas inteiras do "Quem é quem": quebraria o contrato de conteúdo da página 1 sem caminho de recuperação. +- **Não** tocar em atribuição de emenda a município (#1025), nem agregar lote (OPS118), nem em Schema/Access/Consent/PII. +- **Não** transformar os tetos em configuração externa/lote: manter constantes nomeadas no módulo até haver evidência de que variam por cidade. + +## Riscos e mitigação + +- **Cortar demais empobrece a página 1** → caps altos o bastante para o caso normal (texto curto passa intacto) e `e mais N` explícito; integral preservado na seção `fontes`. +- **Perder fato com fonte no corte** → todo `answer` reaparece integral na tabela de respostas integrais. +- **Teto empírico desalinhar com o layout futuro** → constantes nomeadas/documentadas (`PAGE_ONE_*`) e troubleshooting no SKILL.md apontando onde ajustar; guarda fail-closed continua sendo a rede. +- **Regressão em cidade pequena/sem pesquisa** → tabela condicionada a `research.items.length > 0`; manter os testes existentes verdes e um caso de "texto curto intacto". +- **Prova frágil por medição manual** → fixar fixtures + as 9 cidades reais como baseline de margem no aceite; unit cobre a saída. + +## Aceite de engenharia + +- [x] Cidade com checklist cheio (9 itens) + notícias ≤90 dias gera PDF+MD com `[data-page="summary"].scrollHeight ≤ 1001` (fixture plausível/itacaré e worst passam). +- [x] Caps aplicados aos textos da pesquisa na página 1 (`PAGE_ONE_ANSWER_MAX`, `PAGE_ONE_WHO_MAX`) com `excerpt` + `…`; listas com `PAGE_ONE_LIST_LIMIT` e `e mais N`. +- [x] Texto integral de cada item do checklist publicado na tabela "Pesquisa — respostas integrais" dentro da seção `fontes`, com fonte inline, só quando `research.items.length > 0`. +- [x] Cidade pequena/sem pesquisa: sem bloco novo e sem regressão. +- [x] Guarda fail-closed e `PAGE_ONE_BUDGET_PX` inalterados. +- [x] Unit atualizados: capa excerpt da página 1 + preserva integral no aprofundamento; texto curto intacto; `e mais N` sobrevive ao cap da lista. +- [x] `SKILL.md` atualizado. +- [x] Scripts puros do relatório + SKILL.md + testes apenas; nenhum schema/migração/access/Consent/PII. +- [x] Self-score decision-quality ≥ 4 registrado (4/5). + +## Simplificação e débitos (triage 2026-09-16) + +Aplicados na sessão (2 revisores + fixes): `joinLimited` sem default/branch +morto (`max` obrigatório); cap de oposição com constante nomeada +(`PAGE_ONE_RISK_MAX`) e sufixo de fonte preservado (não cortado); comentários de +seção stale removidos; coluna "Data" (não "Fonte"); `buildResearchAnswersTable` +extraída para o dono ser honesto; teste do cap sem acoplar ao literal; +formatação Prettier verde. + +**Diferido com gatilho** (triage: todos score 2 → nenhuma Issue nova): + +- **Estilos de truncamento divergentes no módulo** (`excerpt` com `…` × + `SIGNAL_EXCERPT_MAX`/`SPEECH_*` com `.slice()` sem reticências). Gatilho: + próximo toque nos caps/excerpt do `cityReportBlocks.mjs`. +- **Tetos `PAGE_ONE_*` empíricos** (calibrados por medição Chromium). Gatilho: + mudança de CSS/layout do PDF A4. +- **Corte de 30 chars no "Quem é quem"** é decisão de produto (sem perda de + dado: integral em `fontes`). Gatilho: nova evidência de produto. +- **Tabela "respostas integrais" na seção `fontes`** (dono "fala de fontes, não + de conteúdo"). Gatilho: quando seção própria for permitida. + +**Já resolvido / descartado:** os achados médios/altos dos revisores entraram +como fixes acima; nada virou ruído puro fora do defer. diff --git a/scripts/lib/cityReportBlocks.mjs b/scripts/lib/cityReportBlocks.mjs index 0c79aef1..995adf5c 100644 --- a/scripts/lib/cityReportBlocks.mjs +++ b/scripts/lib/cityReportBlocks.mjs @@ -4,9 +4,9 @@ * the block tree both the PDF and the companion `.md` render. * * The order of the blocks is the product contract (intention plan, "Estrutura - * do relatório"): page 1 is the one-look summary; sections 1–10 are the deep - * dive. "Sem fonte, não publica": every claim carries its source; what is - * missing becomes an explicit gap. + * do relatório"): page 1 is the one-look summary; the numbered sections after it + * are the deep dive. "Sem fonte, não publica": every claim carries its source; + * what is missing becomes an explicit gap. */ import { getMunicipalityFederalBaseline } from '../../src/lib/bahiaElectionAggregates.ts' @@ -54,6 +54,39 @@ const SIGNAL_EXCERPT_MAX = 180 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. + */ +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 = 30 +/** 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 +/** Joined name lists (Responsável, Dobradinhas) — one line in the strip/stat row. */ +const PAGE_ONE_NAMES_MAX = 20 + +/** 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', @@ -96,15 +129,16 @@ 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" - * (seção 10) — imprimir a URL por linha estoura a página 1 com a pesquisa real - * (ver guarda de overflow do builder). + * + 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. */ -const researchAnswerItem = (research, id) => { +const researchAnswerItem = (research, id, max = PAGE_ONE_ANSWER_MAX) => { const item = researchItemById(research, id) if (!item) return null return { - text: item.answer, + text: excerpt(item.answer, max), source: sourceWeb(null, null, item.sourceDate), } } @@ -143,7 +177,13 @@ const buildIdentificationItems = ({ municipality, goal, conjuncture, advisors }) }, { label: 'Responsável', - value: advisors.length ? advisors.map((advisor) => advisor.name).join(', ') : 'Sem responsável', + value: advisors.length + ? joinLimited( + advisors.map((advisor) => advisor.name), + PAGE_ONE_LIST_LIMIT, + PAGE_ONE_NAMES_MAX, + ) + : 'Sem responsável', }, ] @@ -172,11 +212,11 @@ const buildWhoRows = ({ leaderships, advisors, conjuncture }, research, prefeito const rows = [] const simpleAnswers = [ { id: 'prefeito', label: 'Prefeito(a)', item: prefeito }, - { id: 'vice', label: 'Vice', item: researchAnswerItem(research, 'vice') }, + { id: 'vice', label: 'Vice', item: researchAnswerItem(research, 'vice', PAGE_ONE_WHO_MAX) }, { id: 'relacao_campo', label: 'Relação com o campo', - item: researchAnswerItem(research, 'relacao_campo'), + item: researchAnswerItem(research, 'relacao_campo', PAGE_ONE_WHO_MAX), }, ] for (const { label, item } of simpleAnswers) { @@ -188,10 +228,14 @@ const buildWhoRows = ({ leaderships, advisors, conjuncture }, research, prefeito hint: advisors.length ? null : 'sem responsável vinculado', }) const dobradinhas = conjuncture?.stateDeputies?.length - ? conjuncture.stateDeputies.map((deputy) => deputy.name).join(', ') + ? joinLimited( + conjuncture.stateDeputies.map((deputy) => deputy.name), + PAGE_ONE_LIST_LIMIT, + PAGE_ONE_NAMES_MAX, + ) : null rows.push({ label: 'Dobradinhas', value: dobradinhas ?? 'sem registro na base' }) - const vereadores = researchAnswerItem(research, 'vereadores') + const vereadores = researchAnswerItem(research, 'vereadores', PAGE_ONE_WHO_MAX) if (vereadores) { rows.push({ label: 'Vereadores/dobradas', value: vereadores.text, source: vereadores.source }) } @@ -224,7 +268,7 @@ const buildDeliveredKpis = ({ speeches }, research, emendas) => { } const buildAnnouncePair = (research) => { - const relacao = researchAnswerItem(research, 'relacao_campo') + const relacao = researchAnswerItem(research, 'relacao_campo', PAGE_ONE_WHO_MAX) const items = [] if (relacao) { items.push({ text: `Relação local confirmada: ${relacao.text}`, source: relacao.source }) @@ -253,7 +297,8 @@ const buildAnnouncePair = (research) => { const buildRiskCards = ({ leaderships, advisors, conjuncture }, research) => { const opposition = - researchAnswerItem(research, 'disputa_local') ?? researchAnswerItem(research, 'quem_investe') + researchAnswerItem(research, 'disputa_local', PAGE_ONE_RISK_MAX) ?? + researchAnswerItem(research, 'quem_investe', PAGE_ONE_RISK_MAX) return [ { title: 'Oposição local', @@ -263,7 +308,9 @@ const buildRiskCards = ({ leaderships, advisors, conjuncture }, research) => { }, { title: 'Riscos na base', - body: conjuncture?.risks?.length ? conjuncture.risks.join(' · ') : 'Nenhum risco registrado', + body: conjuncture?.risks?.length + ? joinLimited(conjuncture.risks, PAGE_ONE_LIST_LIMIT, PAGE_ONE_ANSWER_MAX) + : 'Nenhum risco registrado', }, { title: 'Rede', @@ -299,7 +346,7 @@ const buildEmendasEvidenceBlock = (research, emendas) => { } const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => { - const prefeito = researchAnswerItem(research, 'prefeito') + const prefeito = researchAnswerItem(research, 'prefeito', PAGE_ONE_WHO_MAX) const gapCalloutBlock = gapCallout( collectPageOneGaps({ electoral: snapshot.electoral, prefeito }), 'Pontos sem leitura', @@ -1275,6 +1322,33 @@ 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). + */ +const buildResearchAnswersTable = (research) => + research.items.length + ? { + kind: 'table', + title: 'Pesquisa — respostas integrais (texto completo)', + columns: [ + { key: 'item', label: 'Item', width: 22 }, + { key: 'answer', label: 'Resposta (texto integral)', width: 62 }, + { key: 'date', label: 'Data', width: 16 }, + ], + rows: research.items.map((item) => { + const sources = sourcesOf(item) + return { + item: item.label, + answer: withInlineSources(item.answer, sources), + date: formatDateBr(item.sourceDate), + sources, + } + }), + note: 'A página 1 resume e capa; o texto integral de cada item da pesquisa fica nesta tabela.', + } + : null + const buildSourcesSection = ({ snapshot, research, emendas }) => { const items = [] items.push({ @@ -1344,6 +1418,7 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => { } return [ + buildResearchAnswersTable(research), { kind: 'sources', title: 'Fontes e limites', @@ -1359,7 +1434,7 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => { 'Oposição: cada linha é fato publicado com URL + data; o PDF não afirma culpa nem orienta ataque pessoal — use como contraste ancorado.', ], }, - ] + ].filter(Boolean) } const SNAPSHOT_REQUIRED_GROUPS = [ diff --git a/tests/unit/cityReportBlocks.unit.spec.ts b/tests/unit/cityReportBlocks.unit.spec.ts index f0ff8024..86fc5582 100644 --- a/tests/unit/cityReportBlocks.unit.spec.ts +++ b/tests/unit/cityReportBlocks.unit.spec.ts @@ -636,18 +636,78 @@ describe('buildCityReport', () => { ).toBe(false) }) - it('puts research answers in Quem é quem with compact sources and URLs in section 10', () => { + it('puts research answers in Quem é quem with compact sources and URLs in the sources section', () => { const grid = report.page1.blocks.find((block) => block.kind === 'grid')! const who = grid.cells![1].blocks[0] const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')! expect(prefeito.value).toBe('Resposta de prefeito') expect(prefeito.source!.url).toBeNull() expect(prefeito.source!.date).toBe('2026-09-10') - const fontes = report.sections.find((section) => section.id === 'fontes')!.blocks[0] + const fontes = report.sections + .find((section) => section.id === 'fontes')! + .blocks.find((block) => block.kind === 'sources')! expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/prefeito')).toBe(true) 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)', () => { + 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 capped = asReport( + buildCityReport({ snapshot, research: longResearch, 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!.length).toBeLessThan(longAnswer.length) + expect(prefeito.value!.endsWith('…')).toBe(true) + + const appendix = capped.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).toHaveLength(RESEARCH_CHECKLIST_IDS.length) + 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)')! + expect(prefeito.value).toBe('Resposta de prefeito') + + const manyDeputies = asReport( + buildCityReport({ + snapshot: { + ...snapshot, + conjuncture: { + ...snapshot.conjuncture, + stateDeputies: [ + { id: 5, name: 'Deputada Estadual', party: 'PT' }, + { id: 6, name: 'Deputado Dois', party: 'PCdoB' }, + { id: 7, name: 'Deputado Três', party: 'PT' }, + ], + }, + }, + research, + emendas, + generatedAt, + }), + ) + const many = manyDeputies.page1.blocks.find((block) => block.kind === 'grid')!.cells![1] + .blocks[0] + const dobradinhas = many.rows!.find((row) => row.label === 'Dobradinhas')! + expect(dobradinhas.value).toContain('e mais 1') + expect(dobradinhas.value).not.toContain('Deputado Três') + }) + it('builds the region panorama from the committed artifact', () => { const region = report.sections.find((section) => section.id === 'regiao')!.blocks[0] const rowByLabel = (label: string) => region.rows!.find((row) => row.label === label)!