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)}
-
${side.items.map((item) => `- ${htmlEscape(item.text)}
`).join('')}
-
`
- 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('')
+ 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('')
+ 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')
+ })
})
diff --git a/tests/unit/cityReportResearch.unit.spec.ts b/tests/unit/cityReportResearch.unit.spec.ts
index d5f0c2cf..391e64d7 100644
--- a/tests/unit/cityReportResearch.unit.spec.ts
+++ b/tests/unit/cityReportResearch.unit.spec.ts
@@ -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({