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)!