feat(C163): página 1 mostra os indícios de emenda e perde o "O que NÃO anunciar"

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.
This commit is contained in:
Francisco Solla 2026-09-16 21:06:50 -03:00
parent d5c6c285b7
commit c51db57865
8 changed files with 406 additions and 81 deletions

View file

@ -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/<base>.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/<base>.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".

View file

@ -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).

View file

@ -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',

View file

@ -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 `<ul class="bullets">${items}</ul>`
}
const renderPair = (block) => {
const panel = (side) => `<div class="panel ${side.tone}">
<h4>${htmlEscape(side.title)}</h4>
<ul>${side.items.map((item) => `<li>${htmlEscape(item.text)}</li>`).join('')}</ul>
</div>`
return `<div class="pair-wrap"><div class="pair">${panel(block.left)}${panel(block.right)}</div>${
block.note ? `<p class="pair-note">${htmlEscape(block.note)}</p>` : ''
}</div>`
const renderPanel = (block) => {
const items = block.items
.map((item) => {
const source = item.source
? ` <span class="row-source">fonte: ${htmlWithLinks(sourceLabel(item.source))}</span>`
: ''
return `<li>${htmlEscape(item.text)}${source}</li>`
})
.join('')
return `<div class="panel ${htmlEscape(block.tone)}"><h4>${htmlEscape(block.title)}</h4><ul>${items}</ul></div>${
block.note ? `<p class="panel-note">${htmlEscape(block.note)}</p>` : ''
}`
}
/**
* 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
? `<a class="inline-source" href="${htmlEscape(item.source.url)}">(fonte)</a>`
: ''
const date = item.source?.date ? ` · ${htmlEscape(formatDateBr(item.source.date))}` : ''
return `<li><strong>${htmlEscape(item.author)}</strong>${detail} <span class="sphere sphere-${htmlEscape(item.sphere)}">${htmlEscape(item.sphereLabel)}</span> <span class="indicator-source">${link}${date}</span></li>`
})
.join('')
const more =
block.remaining > 0
? `<li>e mais ${formatInteger(block.remaining)} indício(s) — texto integral nas fontes.</li>`
: ''
return `<ul class="indicators">${items}${more}</ul>${
block.note ? `<p class="table-note">${htmlEscape(block.note)}</p>` : ''
}`
}
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
? `<h4 class="block-title">${htmlEscape(block.title)}</h4>`
: ''
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': {

View file

@ -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,

View file

@ -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<string, string>
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]

View file

@ -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('<ul class="indicators">')
expect(html).toContain('sphere sphere-municipio')
expect(html).toContain('sphere sphere-polo')
expect(html).toContain('href="https://exemplo.test/indicio-municipio"')
expect(html).toContain('Não somar região/polo')
})
it('renders the announce panel without the fixed "O que NÃO anunciar" copy', () => {
expect(html).toContain('<div class="panel decision">')
expect(html).toContain('O que anunciar agora')
expect(html).not.toContain('O que NÃO anunciar')
})
})
describe('renderReportMd', () => {
@ -131,4 +162,10 @@ describe('renderReportMd', () => {
it('states the base read date (dated snapshot)', () => {
expect(md).toContain('Base Teqo (read-only) lida em')
})
it('renders the emenda indications in the companion with the (fonte) link', () => {
expect(md).toContain('**Zé Neto** — Ambulância do TFD · R$ 1 mi · _município_')
expect(md).toContain('[(fonte)](https://exemplo.test/indicio-municipio)')
expect(md).not.toContain('O que NÃO anunciar')
})
})

View file

@ -107,6 +107,50 @@ describe('normalizeResearchInput', () => {
)
})
it('keeps sourced emenda indications and gaps those without author, esfera or source', () => {
const research = normalizeResearchInput(
baseResearch({
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: 'Sem esfera',
sphere: 'capital',
sourceUrl: 'https://exemplo.test/capital',
sourceDate: '2026-09-01',
},
{ author: 'Sem fonte', sphere: 'regiao' },
],
}),
{ now },
)
expect(research.emendasIndicators).toHaveLength(1)
expect(research.emendasIndicators[0]).toEqual(
expect.objectContaining({
author: 'Zé Neto',
sphere: 'municipio',
value: 'R$ 1 mi',
purpose: 'Ambulância do TFD',
}),
)
expect(research.gaps).toContainEqual(
expect.objectContaining({ id: 'emenda_indicio_incompleto' }),
)
expect(research.gaps).toContainEqual(
expect.objectContaining({
id: 'emenda_indicio_sem_fonte',
reason: expect.stringMatching(/URL e data/),
}),
)
})
it('keeps sourced approach suggestions and gaps ones without source', () => {
const research = normalizeResearchInput(
baseResearch({