From c51db57865363bfca6ec1d571d93018e508b6461 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 16 Sep 2026 21:06:50 -0300 Subject: [PATCH] =?UTF-8?q?feat(C163):=20p=C3=A1gina=201=20mostra=20os=20i?= =?UTF-8?q?nd=C3=ADcios=20de=20emenda=20e=20perde=20o=20"O=20que=20N=C3=83?= =?UTF-8?q?O=20anunciar"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contrato novo emendasIndicators[] (author+sphere obrigatórios, fonte obrigatória, fail-closed para lacuna) alimenta um bloco indicatorList na página 1: uma linha por indício com valor, finalidade, badge de esfera (município/região/polo) e link de fonte. O painel fixo "O que NÃO anunciar" sai da página 1 e o par vira panel único de anúncio (3 bullets invariantes viram 1), preservando a advertência de defeso. Atualiza a skill canônica e os testes de validador/blocks/render. --- .agents/skills/relatorio-cidade/SKILL.md | 50 +++++-- .../2026-09-16-c163-page-one-emendas.md | 1 + scripts/lib/cityReportBlocks.mjs | 83 ++++++++---- scripts/lib/cityReportRender.mjs | 104 +++++++++++---- scripts/lib/cityReportResearch.mjs | 43 ++++++ tests/unit/cityReportBlocks.unit.spec.ts | 125 ++++++++++++++++-- tests/unit/cityReportRender.unit.spec.ts | 37 ++++++ tests/unit/cityReportResearch.unit.spec.ts | 44 ++++++ 8 files changed, 406 insertions(+), 81 deletions(-) create mode 100644 docs/changelog/2026-09-16-c163-page-one-emendas.md diff --git a/.agents/skills/relatorio-cidade/SKILL.md b/.agents/skills/relatorio-cidade/SKILL.md index 28dcd2bb..3225d67e 100644 --- a/.agents/skills/relatorio-cidade/SKILL.md +++ b/.agents/skills/relatorio-cidade/SKILL.md @@ -207,6 +207,16 @@ de pesquisa que cruza para ele. "investments": [ // investimentos/obras de Solla, do estado e do federal (município e região) { "topic": "Obra federal — saúde", "detail": "…", "sourceUrl": "https://…", "sourceDate": "…" } ], + "emendasIndicators": [ // indícios de emenda, um por autor (página 1; esfera explícita) + { + "author": "Zé Neto", // obrigatório + "sphere": "municipio", // obrigatório: municipio | regiao | polo + "value": "R$ 1 mi", // opcional (como a fonte informa) + "purpose": "Ambulância do TFD", // opcional (o que beneficia) + "year": "2026", // opcional + "sourceUrl": "https://…", "sourceDate": "2026-08-24" + } + ], "gaps": [{ "id": "…", "label": "…", "reason": "…" }] // lacunas que o agente já sabe } ``` @@ -214,9 +224,12 @@ de pesquisa que cruza para ele. Checklist (`id`s): `prefeito`, `vice`, `relacao_campo`, `vereadores`, `disputa_local`, `quem_investe`, `noticias`, `imprensa_local`, `emendas_web`. Item sem `sourceUrl`/`sourceDate` é convertido em lacuna pelo validador; item -ausente também. `approach`, `preCandidates`, `leaders` e `leaderAgenda` são +ausente também. `approach`, `preCandidates`, `leaders`, `leaderAgenda` e +`emendasIndicators` são listas de pesquisa: cada entrada sem fonte vira lacuna (`abordagem_sem_fonte`, -`precandidato_sem_fonte`, `lideranca_sem_fonte`, `agenda_lideranca_sem_fonte`) e +`precandidato_sem_fonte`, `lideranca_sem_fonte`, `agenda_lideranca_sem_fonte`, +`emenda_indicio_sem_fonte`; `emendasIndicators` ainda exige `author` e `sphere` +— sem eles, `emenda_indicio_incompleto`) e não entra no PDF. Campos de texto livre (`detail`, `hook`, `suggestion`, `support`, `answer`) aceitam os **tokens de fonte inline** `{{fonte}}` / `{{fonte:N}}`, resolvidos para `(fonte)` no ponto exato da citação (ver "Links @@ -237,10 +250,15 @@ de rede **sem rastro público** não vira item: fica como tarefa de ativação fonte oficial não atribui emenda ao município, pesquise artigos, falas e indicações de emenda para (a) o município, (b) a região/Território de Identidade e (c) a maior cidade próxima (polo regional — cidades pequenas usam -serviços do polo). Registre cada indício com URL+data; use `extraSources` para -mais de uma fonte. O PDF mostra o item como "Emendas — indícios web" na página -1 e as URLs na seção de fontes. **Nunca somar** indício de região/polo como -emenda da cidade. +serviços do polo). Registre a síntese em `emendas_web` (answer/details + +`extraSources`) e, principalmente, **cada indício como uma linha de +`emendasIndicators`** — `author`, `sphere` (`municipio`/`regiao`/`polo`), valor, +finalidade, ano e fonte+data. É essa lista estruturada que a página 1 imprime +(autor — o que beneficia · valor · esfera, com link); o texto corrido só entra +quando não há lista (fallback). Sem uma das linhas com fonte, o indício vira +lacuna (`emenda_indicio_sem_fonte`) — nunca some em silêncio. O PDF mostra o +bloco como "Emendas — indícios web" na página 1 e as URLs na seção de fontes. +**Nunca somar** indício de região/polo como emenda da cidade. **Demografia, economia e transporte (pesquisa):** `demography`, `economy` e `transport` são listas de `{topic, detail, sourceUrl, sourceDate}` (sem fonte → @@ -291,12 +309,17 @@ cacheado em `data/relatorios-cidade/.emendas.json` para replay. (cenário central com pessimista/otimista)** — a meta de cadeira e a cobertura de pledges **saíram** da página 1; quem é quem (prefeito, vice, relação com o 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. 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`). + oficiais com fase, acervo de falas, notícias ≤90 dias), **emendas — indícios + web** quando a fonte oficial não atribui ao município (uma linha por autor: + o que beneficia · valor · **esfera explícita** `município`/`região`/`polo`, com + link), **O que anunciar agora** (a relação local com fonte + os compromissos + 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 + 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 @@ -362,7 +385,8 @@ cacheado em `data/relatorios-cidade/.emendas.json` para replay. - **Sem fonte, não publica**: afirmação não trivial sem data+URL não entra. - **Empenho ≠ pagamento** (defeso/ano eleitoral): o bloco de emendas mostra a - fase (empenhada, liquidada, paga, restos) e o "anunciar × não anunciar" é fixo. + fase (empenhada, liquidada, paga, restos) e a advertência de defeso acompanha o + painel "O que anunciar agora". - **Leitura relativa**: % do próprio voto, rank, LQ — nunca % estadual absoluto. - **PII mínima**: contatos completos (telefone/e-mail) nunca entram; nomes de lideranças entram porque o produto pede "quem é quem". diff --git a/docs/changelog/2026-09-16-c163-page-one-emendas.md b/docs/changelog/2026-09-16-c163-page-one-emendas.md new file mode 100644 index 00000000..72f26014 --- /dev/null +++ b/docs/changelog/2026-09-16-c163-page-one-emendas.md @@ -0,0 +1 @@ +**C163 — página 1 do relatório de cidade deixa de cortar os indícios de emenda e perde o "O que NÃO anunciar" fixo (2026-09-16):** o bloco verde "Emendas — indícios web" era um callout com o `answer` do item `emendas_web` capado em 90 chars — na prática, sempre a mesma frase de abertura ("Sem emenda oficial atribuída…; indícios web apontam Sol…"), com os indícios que de fato beneficiam a cidade/região (autor, valor, esfera, link) presos no `details`, que a página 1 nunca lia. E o par "O que anunciar agora × O que NÃO anunciar" gastava ~167px com 7 de 8 linhas invariantes (o lado "NÃO anunciar" era 100% copy fixa). Correção: (1) **contrato novo** `emendasIndicators[]` no `research.json` — `author` + `sphere` (`municipio`/`regiao`/`polo`) + `value`/`purpose`/`year` opcionais + `sourceUrl`/`sourceDate` obrigatórios; o validador descarta linha sem fonte (`emenda_indicio_sem_fonte`) ou sem autor/esfera válida (`emenda_indicio_incompleto`) como lacuna explícita, nunca em silêncio, e mantém o fallback de legado (callout com o `answer` quando não há lista); (2) **bloco `indicatorList`** novo na página 1 — uma linha por indício (`Autor — finalidade capada · valor · ano`, badge de esfera com tom de alerta para região/polo, link `(fonte)` + data), teto de 4 linhas com `e mais N` e o guardrail "não somar região/polo como emenda da cidade; a atribuição oficial fica com o Portal da Transparência"; (3) o bloco `pair` virou `panel` único ("O que anunciar agora"), o painel "O que NÃO anunciar" **saiu** da página 1, os três bullets fixos de anúncio viraram uma linha e a advertência de defeso (empenho ≠ pagamento) permanece como nota do painel; (4) o renderer não duplica mais o título do painel (`panel` não emite `block-title` além do próprio `h4`) e `PAGE_ONE_WHO_MAX` caiu 30→26 (os nomes completos seguem na seção 3), mantendo a página 1 com folga no gate: Amélia Rodrigues 983px e Santa Bárbara 977px (orçamento 1001px). Atualiza a skill canônica (`SKILL.md`: contrato, `emendas_web`, conteúdo da página 1 e guardrail de defeso) e os testes unitários (validador, blocks, render HTML+MD e o pin da skill). Sem schema, sem migration e sem mudança de URL pública — scripts de relatório + doc apenas; PDFs/MDs são gitignored e foram regenerados. Issue: sem Issue (PR direto a pedido). diff --git a/scripts/lib/cityReportBlocks.mjs b/scripts/lib/cityReportBlocks.mjs index 995adf5c..f7498c33 100644 --- a/scripts/lib/cityReportBlocks.mjs +++ b/scripts/lib/cityReportBlocks.mjs @@ -63,12 +63,25 @@ const SPEECH_MENTION_MAX = 220 */ 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 +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 /** Joined name lists (Responsável, Dobradinhas) — one line in the strip/stat row. */ const PAGE_ONE_NAMES_MAX = 20 +/** Page-1 emenda indications: the author/what/esfera rows shown before "e mais N". */ +const PAGE_ONE_INDICATOR_LIMIT = 4 +/** 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)' + +/** Where each indication points to; região/polo are never summed as the city's own emenda. */ +const EMENDA_SPHERE_LABELS = { + municipio: 'município', + regiao: 'região', + polo: 'polo', +} /** Truncates with an explicit ellipsis; short text passes through untouched. */ const excerpt = (text, max) => { @@ -267,30 +280,23 @@ const buildDeliveredKpis = ({ speeches }, research, emendas) => { ] } -const buildAnnouncePair = (research) => { +const buildAnnouncePanel = (research) => { 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 }) + // A fonte da relação local já aparece em "Quem é quem" na mesma página. + items.push({ text: `Relação local confirmada: ${relacao.text}` }) } - items.push( - { text: 'Pauta ou entrega com fonte e data ao lado da frase.' }, - { text: 'Agradecimento institucional com papel confirmado.' }, - { text: 'Compromisso novo com escopo que a campanha cumpre.' }, - ) + // Uma linha invariante em vez de três: a página 1 é "uma olhada" e o + // conteúdo fixo repetido não pode competir com os indícios da cidade. + items.push({ + text: 'Pauta/entrega, agradecimento e compromisso novo — sempre com papel confirmado, fonte e data.', + }) return { - kind: 'pair', - left: { title: 'O que anunciar agora', tone: 'decision', items }, - right: { - title: 'O que NÃO anunciar', - tone: 'risk', - items: [ - { text: 'Empenho tratado como pagamento (defeso).' }, - { text: 'Nada sem fonte ("sem fonte, não publica").' }, - { text: 'Dado staff-only fora da coordenação.' }, - { text: 'PII além do necessário.' }, - ], - }, + kind: 'panel', + tone: 'decision', + title: 'O que anunciar agora', + items, note: ADVERTENCIA_DEFESO, } } @@ -335,13 +341,36 @@ const collectPageOneGaps = ({ electoral, prefeito }) => { const buildEmendasEvidenceBlock = (research, emendas) => { if (emendas?.status === 'ok') return null - const evidence = researchAnswerItem(research, 'emendas_web') - if (!evidence) return null + const indicators = research.emendasIndicators ?? [] + if (!indicators.length) { + const evidence = researchAnswerItem(research, 'emendas_web') + if (!evidence) return null + return { + kind: 'callout', + tone: 'decision', + title: EMENDAS_EVIDENCE_TITLE, + 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) return { - kind: 'callout', - tone: 'decision', - title: 'Emendas — indícios web (sem atribuição oficial ao município)', - body: [evidence.text, 'Não somar região/polo como emenda da cidade; URLs nas fontes.'], + kind: 'indicatorList', + title: EMENDAS_EVIDENCE_TITLE, + items: shown.map((indicator) => ({ + 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(' · '), + source: sourceWeb(indicator.author, indicator.sourceUrl, indicator.sourceDate), + })), + remaining: indicators.length - shown.length, + note: 'Não somar região/polo como emenda da cidade; a atribuição oficial fica com o Portal da Transparência.', } } @@ -390,7 +419,7 @@ const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => { ], }, buildEmendasEvidenceBlock(research, emendas), - buildAnnouncePair(research), + buildAnnouncePanel(research), { kind: 'cards', title: 'Riscos — oposição/disputa local', diff --git a/scripts/lib/cityReportRender.mjs b/scripts/lib/cityReportRender.mjs index 5073400d..30cfaf96 100644 --- a/scripts/lib/cityReportRender.mjs +++ b/scripts/lib/cityReportRender.mjs @@ -5,7 +5,7 @@ * companion cannot drift from the PDF. */ -import { formatDateBr, formatDateTimeBr } from './cityReportFormat.mjs' +import { formatDateBr, formatDateTimeBr, formatInteger } from './cityReportFormat.mjs' const htmlEscape = (value) => String(value ?? '') @@ -79,7 +79,7 @@ const PRINT_CSS = ` .summary .block + .block { margin-top: 2mm; } .summary .stats .stat { padding: .7mm 0; } .summary .callout { padding: 1.8mm 2.4mm; } - .summary .pair .panel { padding: 1.8mm 2.2mm; } + .summary .panel { padding: 1.8mm 2.2mm; } header.report-head { display: flex; justify-content: space-between; @@ -145,14 +145,18 @@ const PRINT_CSS = ` .callout.risk { border-left-color: #b45309; background: #fffbeb; } .callout h4 { margin: 0 0 1mm; font-size: 8.6pt; } .callout p { margin: 0 0 .8mm; } - .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; } - .pair .panel { border: .8px solid #d4d4d8; border-radius: 1.6mm; padding: 2.4mm; } - .pair .panel.decision { border-color: #15803d; } - .pair .panel.risk { border-color: #b45309; background: #fffbeb; } - .pair .panel h4 { margin: 0 0 1.2mm; font-size: 8.4pt; } - .pair .panel ul, .bullets { margin: 0; padding-left: 4mm; } - .pair .panel li, .bullets li { margin-bottom: .8mm; } - .pair-note { color: #92400e; font-size: 7.6pt; margin: 1.2mm 0 0; } + .panel { border: .8px solid #d4d4d8; border-radius: 1.6mm; padding: 2.4mm; } + .panel.decision { border-color: #15803d; } + .panel.risk { border-color: #b45309; background: #fffbeb; } + .panel h4 { margin: 0 0 1.2mm; font-size: 8.4pt; } + .panel ul, .bullets { margin: 0; padding-left: 4mm; } + .panel li, .bullets li { margin-bottom: .8mm; } + .panel-note { color: #92400e; font-size: 7.6pt; margin: 1.2mm 0 0; } + .indicators { margin: 0; padding-left: 4mm; } + .indicators li { margin-bottom: .8mm; } + .indicators .sphere { font-size: 6.6pt; text-transform: uppercase; letter-spacing: .06em; border: .6px solid #a1a1aa; border-radius: 1.2mm; padding: 0 1mm; color: #52525b; } + .indicators .sphere-regiao, .indicators .sphere-polo { border-color: #b45309; color: #92400e; background: #fffbeb; } + .indicators .indicator-source { color: #71717a; font-size: 7.2pt; } table { width: 100%; border-collapse: collapse; font-size: 8pt; } table.table-fixed { table-layout: fixed; } table.table-fixed td, table.table-fixed th { overflow-wrap: anywhere; } @@ -237,14 +241,43 @@ const renderBullets = (block) => { return `` } -const renderPair = (block) => { - const panel = (side) => `
-

${htmlEscape(side.title)}

- -
` - return `
${panel(block.left)}${panel(block.right)}
${ - block.note ? `

${htmlEscape(block.note)}

` : '' - }
` +const renderPanel = (block) => { + const items = block.items + .map((item) => { + const source = item.source + ? ` fonte: ${htmlWithLinks(sourceLabel(item.source))}` + : '' + return `
  • ${htmlEscape(item.text)}${source}
  • ` + }) + .join('') + return `

    ${htmlEscape(block.title)}

    ${ + block.note ? `

    ${htmlEscape(block.note)}

    ` : '' + }` +} + +/** + * Emenda indications (C163 page 1): one row per author/what/esfera, each with + * its own source link and a date. The esfera badge is explicit — região/polo are + * never summed as the município's own emenda. + */ +const renderIndicatorList = (block) => { + const items = block.items + .map((item) => { + const detail = item.detail ? ` — ${htmlEscape(item.detail)}` : '' + const link = item.source?.url + ? `(fonte)` + : '' + const date = item.source?.date ? ` · ${htmlEscape(formatDateBr(item.source.date))}` : '' + return `
  • ${htmlEscape(item.author)}${detail} ${htmlEscape(item.sphereLabel)} ${link}${date}
  • ` + }) + .join('') + const more = + block.remaining > 0 + ? `
  • e mais ${formatInteger(block.remaining)} indício(s) — texto integral nas fontes.
  • ` + : '' + return `${ + block.note ? `

    ${htmlEscape(block.note)}

    ` : '' + }` } const renderTable = (block) => { @@ -297,13 +330,14 @@ const normalizeHeading = (value) => .toLowerCase() const renderBlockHtml = (block, sectionTitle = null) => { - // Tables carry their title in the caption; a block title equal to the section - // heading is duplication (the section h3 right above already says it). + // Tables carry their title in the caption and the panel in its own heading; a + // block title equal to the section heading is also duplication (the section h3 + // right above already says it). const duplicatesSection = Boolean(block.title && sectionTitle) && normalizeHeading(block.title) === normalizeHeading(sectionTitle) const title = - block.title && block.kind !== 'table' && !duplicatesSection + block.title && block.kind !== 'table' && block.kind !== 'panel' && !duplicatesSection ? `

    ${htmlEscape(block.title)}

    ` : '' let body @@ -326,8 +360,11 @@ const renderBlockHtml = (block, sectionTitle = null) => { case 'bullets': body = renderBullets(block) break - case 'pair': - body = renderPair(block) + case 'panel': + body = renderPanel(block) + break + case 'indicatorList': + body = renderIndicatorList(block) break case 'table': body = renderTable(block) @@ -447,11 +484,22 @@ const mdBlock = (block) => { for (const inner of cell.blocks) lines.push(mdBlock(inner)) } break - case 'pair': - lines.push(`### ${block.left.title}\n`) - for (const item of block.left.items) lines.push(`- ${item.text}`) - lines.push(`\n### ${block.right.title}\n`) - for (const item of block.right.items) lines.push(`- ${item.text}`) + case 'panel': + lines.push(`### ${block.title}\n`) + for (const item of block.items) { + lines.push(`- ${item.text}${item.source ? ` _(fonte: ${sourceLabel(item.source)})_` : ''}`) + } + if (block.note) lines.push(`\n> ${block.note}`) + break + case 'indicatorList': + lines.push(title) + for (const item of block.items) { + const detail = item.detail ? ` — ${item.detail}` : '' + const src = item.source?.url ? ` [(fonte)](${item.source.url})` : '' + const date = item.source?.date ? ` · ${formatDateBr(item.source.date)}` : '' + lines.push(`- **${item.author}**${detail} · _${item.sphereLabel}_${src}${date}`) + } + if (block.remaining > 0) lines.push(`- e mais ${block.remaining} indício(s).`) if (block.note) lines.push(`\n> ${block.note}`) break case 'table': { diff --git a/scripts/lib/cityReportResearch.mjs b/scripts/lib/cityReportResearch.mjs index b541540b..8e0a0f22 100644 --- a/scripts/lib/cityReportResearch.mjs +++ b/scripts/lib/cityReportResearch.mjs @@ -30,6 +30,9 @@ export const RESEARCH_CHECKLIST_IDS = RESEARCH_CHECKLIST.map((item) => item.id) const checklistById = new Map(RESEARCH_CHECKLIST.map((item) => [item.id, item])) +/** Where an emenda indication points to: the município itself, its region or the regional pole. */ +const EMENDA_SPHERES = ['municipio', 'regiao', 'polo'] + const isNonEmptyString = (value) => typeof value === 'string' && value.trim() !== '' const isValidDate = (value) => isNonEmptyString(value) && !Number.isNaN(Date.parse(value)) @@ -365,6 +368,45 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => { } } + /** + * Structured emenda indications (município/região/polo). The page-1 block is + * "quem indica o quê, para onde e com que fonte" — the free-text `emendas_web` + * synthesis alone cannot show the value/esfera per author. Every row needs a + * source; without it the row is dropped into an explicit gap. + */ + const emendasIndicators = [] + for (const entry of Array.isArray(raw.emendasIndicators) ? raw.emendasIndicators : []) { + const author = isNonEmptyString(entry?.author) ? entry.author.trim() : null + const sphere = isNonEmptyString(entry?.sphere) ? entry.sphere.trim() : null + const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null + const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null + if (!author || !sphere || !EMENDA_SPHERES.includes(sphere)) { + gaps.push({ + id: 'emenda_indicio_incompleto', + label: author, + reason: `Indício de emenda sem autor ou esfera (${EMENDA_SPHERES.join('/')}) — descartado.`, + }) + continue + } + if (!sourceUrl || !sourceDate) { + gaps.push({ + id: 'emenda_indicio_sem_fonte', + label: author, + reason: 'Indício de emenda sem fonte: URL e data são obrigatórias.', + }) + continue + } + emendasIndicators.push({ + author, + sphere, + value: isNonEmptyString(entry.value) ? entry.value.trim() : null, + purpose: isNonEmptyString(entry.purpose) ? entry.purpose.trim() : null, + year: isNonEmptyString(entry.year) ? entry.year.trim() : null, + sourceUrl, + sourceDate, + }) + } + return { municipalitySlug: raw.municipalitySlug.trim(), researchedAt: researchedAt.toISOString(), @@ -374,6 +416,7 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => { preCandidates, leaders, leaderAgenda, + emendasIndicators, demography: sourcedLists.demography, economy: sourcedLists.economy, transport: sourcedLists.transport, diff --git a/tests/unit/cityReportBlocks.unit.spec.ts b/tests/unit/cityReportBlocks.unit.spec.ts index 86fc5582..9b3373b0 100644 --- a/tests/unit/cityReportBlocks.unit.spec.ts +++ b/tests/unit/cityReportBlocks.unit.spec.ts @@ -118,6 +118,24 @@ const research = normalizeResearchInput( sourceDate: '2026-09-10', }, ], + emendasIndicators: [ + { + author: 'Zé Neto', + sphere: 'municipio', + value: 'R$ 1 mi', + purpose: 'Ambulância do TFD', + year: '2026', + sourceUrl: 'https://exemplo.test/ze-neto', + sourceDate: '2026-09-01', + }, + { + author: 'Deputado do Polo', + sphere: 'polo', + value: 'R$ 500 mil', + sourceUrl: 'https://exemplo.test/polo-emenda', + sourceDate: '2026-09-01', + }, + ], gaps: [], }, { now: generatedAt }, @@ -347,6 +365,13 @@ type ReportItem = { value?: string hint?: string | null url?: string | null + author?: string + detail?: string + sphere?: string + sphereLabel?: string + title?: string + body?: string + source?: { url?: string | null; date?: string | null } } type ReportRow = { label: string @@ -357,14 +382,15 @@ type TableRow = Record type ReportBlock = { kind: string title?: string + tone?: string items?: ReportItem[] rows?: ReportRow[] columns?: Array<{ key: string; label: string; width?: number }> cells?: Array<{ title: string; blocks: ReportBlock[] }> body?: string[] + note?: string + remaining?: number sources?: Array<{ kind: string; url?: string | null }> - left?: { title: string; items: ReportItem[] } - right?: { title: string; items: ReportItem[] } } type ReportShape = { page1: { blocks: ReportBlock[] } @@ -546,20 +572,19 @@ describe('buildCityReport', () => { ) }) - it('keeps page 1 in the contract order (six blocks + footer)', () => { - const kinds = report.page1.blocks - .map((block) => block.kind) - .filter((kind) => kind !== 'callout') - expect(kinds).toEqual(['strip', 'grid', 'kpis', 'pair', 'cards', 'footerNote']) + it('keeps page 1 in the contract order (blocks + footer)', () => { + const kinds = report.page1.blocks.map((block) => block.kind) + expect(kinds).toEqual(['strip', 'grid', 'kpis', 'panel', 'cards', 'footerNote']) const titles = report.page1.blocks.map((block) => block.title).filter(Boolean) expect(titles).toEqual( expect.arrayContaining(['O que Solla entregou', 'Riscos — oposição/disputa local']), ) const grid = report.page1.blocks.find((block) => block.kind === 'grid')! expect(grid.cells!.map((cell) => cell.title)).toEqual(['Conta eleitoral 2022', 'Quem é quem']) - const pair = report.page1.blocks.find((block) => block.kind === 'pair')! - expect(pair.left!.title).toBe('O que anunciar agora') - expect(pair.right!.items.some((item) => item.text?.includes('Empenho'))).toBe(true) + const announce = report.page1.blocks.find((block) => block.kind === 'panel')! + expect(announce.title).toBe('O que anunciar agora') + expect(announce.tone).toBe('decision') + expect(announce.note).toContain('empenho não é pagamento') }) it('reads the relative lens (share/rank), never the statewide absolute', () => { @@ -606,7 +631,7 @@ describe('buildCityReport', () => { expect(delivered.items![0].hint).toContain('sem emenda atribuível') }) - it('shows the web evidence for emendas when the official source is a gap', () => { + it('shows the structured emenda indications when the official source is a gap', () => { const gapReport = asReport( buildCityReport({ snapshot, @@ -623,11 +648,16 @@ describe('buildCityReport', () => { ) const evidence = gapReport.page1.blocks.find( (block) => - block.kind === 'callout' && + block.kind === 'indicatorList' && block.title === 'Emendas — indícios web (sem atribuição oficial ao município)', ) expect(evidence).toBeTruthy() - expect(evidence!.body!.join(' ')).toContain('Resposta de emendas_web') + expect(evidence!.items!.map((item) => item.author)).toEqual(['Zé Neto', 'Deputado do Polo']) + expect(evidence!.items![0].sphereLabel).toBe('município') + expect(evidence!.items![0].detail).toBe('Ambulância do TFD · R$ 1 mi · 2026') + expect(evidence!.items![0].source!.url).toBe('https://exemplo.test/ze-neto') + expect(evidence!.items!.map((item) => item.sphereLabel)).toContain('polo') + expect(evidence!.note).toContain('Não somar região/polo') const evidenceOk = asReport(buildCityReport({ snapshot, research, emendas, generatedAt })) expect( evidenceOk.page1.blocks.some( @@ -636,6 +666,75 @@ describe('buildCityReport', () => { ).toBe(false) }) + it('falls back to the emendas_web answer when the research has no structured indications', () => { + const legacyResearch = normalizeResearchInput( + { + municipalitySlug: 'feira-de-santana', + researchedAt: '2026-09-14T10:00:00.000Z', + items: RESEARCH_CHECKLIST_IDS.map((id) => validItem(id)), + gaps: [], + }, + { now: generatedAt }, + ) + const legacy = asReport( + buildCityReport({ + snapshot, + research: legacyResearch, + emendas: { + status: 'gap', + reason: 'Sem emenda do autor com localidade Feira de Santana na janela.', + detail: null, + sourceUrl: null, + consultedAt: null, + }, + generatedAt, + }), + ) + const evidence = legacy.page1.blocks.find( + (block) => + block.kind === 'callout' && + block.title === 'Emendas — indícios web (sem atribuição oficial ao município)', + ) + expect(evidence).toBeTruthy() + expect(evidence!.body!.join(' ')).toContain('Resposta de emendas_web') + }) + + it('caps the emenda indications and shows the remainder', () => { + const manyIndicators = normalizeResearchInput( + { + municipalitySlug: 'feira-de-santana', + researchedAt: '2026-09-14T10:00:00.000Z', + items: RESEARCH_CHECKLIST_IDS.map((id) => validItem(id)), + emendasIndicators: Array.from({ length: 6 }, (_, index) => ({ + author: `Autor ${index + 1}`, + sphere: 'municipio', + value: `R$ ${index + 1} mi`, + sourceUrl: `https://exemplo.test/indicio-${index + 1}`, + sourceDate: '2026-09-01', + })), + gaps: [], + }, + { now: generatedAt }, + ) + const capped = asReport( + buildCityReport({ + snapshot, + research: manyIndicators, + emendas: { + status: 'gap', + reason: 'Sem emenda do autor com localidade Feira de Santana na janela.', + detail: null, + sourceUrl: null, + consultedAt: null, + }, + generatedAt, + }), + ) + const evidence = capped.page1.blocks.find((block) => block.kind === 'indicatorList')! + expect(evidence.items).toHaveLength(4) + expect(evidence.remaining).toBe(2) + }) + 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] diff --git a/tests/unit/cityReportRender.unit.spec.ts b/tests/unit/cityReportRender.unit.spec.ts index a86e3785..db214fa3 100644 --- a/tests/unit/cityReportRender.unit.spec.ts +++ b/tests/unit/cityReportRender.unit.spec.ts @@ -20,6 +20,23 @@ const research = normalizeResearchInput( sourceDate: '2026-09-10', })), news: [], + emendasIndicators: [ + { + author: 'Zé Neto', + sphere: 'municipio', + value: 'R$ 1 mi', + purpose: 'Ambulância do TFD', + sourceUrl: 'https://exemplo.test/indicio-municipio', + sourceDate: '2026-09-01', + }, + { + author: 'Deputado do Polo', + sphere: 'polo', + value: 'R$ 500 mil', + sourceUrl: 'https://exemplo.test/indicio-polo', + sourceDate: '2026-09-01', + }, + ], gaps: [], }, { now: generatedAt }, @@ -106,6 +123,20 @@ describe('renderReportHtml', () => { expect(html).toContain('Lacuna explícita') expect(html).toContain('Limites') }) + + it('renders the emenda indications with the esfera badge and per-row source link', () => { + expect(html).toContain('