mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:35:20 +00:00
fix(OPS120): página 1 do relatório de cidade cabe com pesquisa cheia (Closes #1069)
This commit is contained in:
parent
04135df820
commit
7be250f463
5 changed files with 287 additions and 24 deletions
|
|
@ -293,7 +293,10 @@ cacheado em `data/relatorios-cidade/<base>.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/<base>.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/<base>.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).
|
||||
|
|
|
|||
1
docs/changelog/2026-09-16-ops120.md
Normal file
1
docs/changelog/2026-09-16-ops120.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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<br/>items[].answer] --> C[cityReportBlocks.buildPageOne<br/>caps PAGE_ONE_*]
|
||||
S[snapshot Teqo ro] --> C
|
||||
E[emendas.json] --> C
|
||||
C --> P1[Página 1 resumida<br/>excerpt + 'e mais N']
|
||||
C --> F[Seção fontes 2+<br/>tabela 'respostas integrais']
|
||||
P1 --> H[build-city-report.mjs<br/>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 `<section class="summary">`.
|
||||
- **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.
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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)!
|
||||
|
|
|
|||
Loading…
Reference in a new issue