mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
d5c6c285b7
commit
c51db57865
8 changed files with 406 additions and 81 deletions
|
|
@ -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".
|
||||
|
|
|
|||
1
docs/changelog/2026-09-16-c163-page-one-emendas.md
Normal file
1
docs/changelog/2026-09-16-c163-page-one-emendas.md
Normal 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).
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue