feat(C163): concorrentes, falas descritivas, indícios de emenda, abordagem e links clicáveis no relatório (Related #1025)

This commit is contained in:
Francisco Solla 2026-09-15 08:56:43 -03:00
parent 85de335cc3
commit 63b60ca7a6
9 changed files with 819 additions and 92 deletions

View file

@ -73,6 +73,9 @@ URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Págin
"details": "opcional",
"sourceUrl": "https://…", // obrigatório em item publicado
"sourceDate": "2026-09-10", // obrigatório
"extraSources": [ // opcional; cada uma exige url+date
{ "label": "Polo regional", "url": "https://…", "date": "2026-09-01" }
],
"consultedAt": "2026-09-15T09:00:00.000Z" // opcional
}
],
@ -83,6 +86,27 @@ URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Págin
"url": "https://…", "summary": "…"
}
],
"approach": [ // sugestões das personas; tema+texto+fonte
{
"persona": "Ciência política", // ou "Coordenação de campanha"
"topic": "Saúde regional (polo)",
"suggestion": "…",
"sourceUrl": "https://…", "sourceDate": "2026-04-07"
}
],
"preCandidates": [ // prováveis candidatos do campo do prefeito em 2026
{
"name": "…", "office": "Deputado federal", "party": "PSDB",
"support": "Campo do prefeito …",
"sourceUrl": "https://…", "sourceDate": "2026-04-18"
}
],
"leaders": [ // últimos prefeitos/vices, vereadores mais votados
{
"name": "…", "role": "Ex-prefeito", "period": "2017–2024",
"sourceUrl": "https://…", "sourceDate": "2024-08-14"
}
],
"gaps": [{ "id": "…", "label": "…", "reason": "…" }] // lacunas que o agente já sabe
}
```
@ -90,11 +114,48 @@ URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Págin
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.
ausente também. `approach`, `preCandidates` e `leaders` são listas de pesquisa:
cada entrada sem fonte vira lacuna (`abordagem_sem_fonte`,
`precandidato_sem_fonte`, `lideranca_sem_fonte`) e não entra no PDF.
Emendas: sem `--emendas`, o builder consulta o Portal da Transparência
(`PORTAL_TRANSPARENCIA_API_KEY` no ambiente; sem chave → lacuna). O resultado
fica cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
**`emendas_web` — indícios de emenda (município, região ou polo):** quando a
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.
**Emendas oficiais:** sem `--emendas`, o builder consulta o Portal da
Transparência (`PORTAL_TRANSPARENCIA_API_KEY` no ambiente; sem chave → lacuna).
A API oficial **não filtra por município** (só UF/Nacional/Múltiplo na
`localidadeDoGasto`); o builder casa pela localidade com o nome do município e,
sem linha atribuível, degrada para lacuna (Issue #1025). O resultado fica
cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
## Conteúdo do relatório
- **Página 1 (uma olhada):** identificação/prioridade/classe/nível, conta
eleitoral 2022 (votos, % do próprio voto, rank/435) **+ expectativa de votos
(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.
- **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) · `3. Rede e
lideranças` — inclui as lideranças locais pesquisadas (ex-prefeitos/vices,
vereadores mais votados) · `4. Conjuntura` · `5. Sinais` · `6. Demandas e
visitas` · `7. Demografia` · `8. Acervo de falas` — cada fala com "O que é"
(sumário oficial) e "Menção ao município" (passagem que cita a cidade ou, se
o nome não aparece nos trechos, a marcação do acervo com o nº de municípios)
· `9. Notícias e imprensa` · `10. Panorama regional` · `11. Abordagem
sugerida (personas)` · `12. Fontes e limites`.
- **Links clicáveis:** todo URL no PDF é um link (`<a href>`): células de
tabela, fontes por linha e a seção de fontes.
## Guardrails de produto (não negociáveis)
@ -106,6 +167,12 @@ fica cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
lideranças entram porque o produto pede "quem é quem".
- **Completo para o candidato**: estimativas e nível N0–N4 entram no PDF, sem
marca de restrição; o que não sai é para o palanque.
- **Indício regional não é emenda da cidade**: emendas do município, da região
ou do polo entram rotuladas na evidência web e **nunca somadas**; a fonte
oficial ausente continua lacuna explícita.
- **Abordagem é análise ancorada**: as sugestões das personas (governo do PT na
região, projetos futuros, prioridades locais) saem do `approach` com fonte por
item — sem fonte, não entra; o PDF deixa claro que é análise, não fato novo.
- **Artefato gitignored**: o repo é público; o PDF/MD com dado interno nunca é
commitado (só a skill/scripts/changelog).
@ -116,6 +183,9 @@ fica cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
- **`tsx` ausente no homeserver**: `pnpm install --prod=false`.
- **API de emendas 429/erro**: o builder degrada para lacuna com URL+motivo;
reexecute depois (o cache só é escrito com resultado utilizável).
- **Emenda sem atribuição oficial**: é o comportamento esperado (a API não
expõe o município). Pesquise os indícios web (município/região/polo) no item
`emendas_web`; sem indício, a lacuna fica explícita — nunca zero silencioso.
- **Chromium**: vem do `@playwright/test`; se faltar binário,
`pnpm exec playwright install chromium`.
- **Snapshot de outro município**: o builder recusa (`municipalitySlug` ≠

View file

@ -0,0 +1 @@
**C163 — relatório de cidade v2: concorrentes, falas descritivas, indícios de emenda, abordagem e links (2026-09-15):** evolução do PDF pré-viagem pedida pelo CG. **(1) Página 1:** saem "Meta 2026 (central)" e "Cobertura de pledges" (não usados); entra **Expectativa de votos** (cenário central com pessimista/otimista de `conjuncture.expectedVotes`). **(2) Acervo de falas:** cada fala ganha "O que é" (sumário oficial da Câmara) e "Menção ao município" — a passagem que cita a cidade ou, quando o nome não aparece nos segmentos (caso de Itamaraju: 0 de 17 trechos; fala marcada com 25 municípios via gazetteer), a marcação do acervo com a contagem; o composer projeta `summary`, `mentionExcerpt` e `mentionedMunicipalityCount`. **(3) Emendas:** quando a fonte oficial não atribui emenda ao município (a API não expõe município — Issue #1025), a página 1 mostra **indícios web** para município, região ou polo (cidades pequenas usam serviços do polo), com `extraSources` por item e regra explícita de **não somar** região/polo como emenda da cidade; o gap de emendas saiu do callout "Pontos sem leitura" (fica no KPI e no bloco de indícios). **(4) Concorrentes:** nova seção "2. Concorrentes no município (federal e estadual)" — top 5 por votos de 2022 no município da base TSE (`electionCandidateVote`, via `municipalityElectionGeography`) com série 2014/2018/2022, mais "Prováveis candidatos do campo do prefeito em 2026" (pesquisa com fonte). **(5) Lideranças:** pesquisa web de ex-prefeitos/vices e vereadores mais votados na seção de rede (`leaders` com fonte por item). **(6) Abordagem:** nova seção "11. Abordagem sugerida (pesquisa)" com sugestões das personas de ciência política e coordenação (governo do PT na região, projetos futuros, prioridades locais), cada uma com URL+data (`approach`); sem fonte vira lacuna. **(7) Links:** todo URL no PDF vira âncora clicável (células de tabela, fontes por linha e seção de fontes); índice sem numeração duplicada. Validador ganhou `extraSources`, `approach`, `preCandidates` e `leaders` (entradas sem fonte viram lacuna, nunca inferência). Seções renumeradas (12 no total). Snapshot regerado em produção read-only com o composer novo. Verificação: unit 3117/3117, `tsc --noEmit` e eslint verdes; PDF de Itamaraju em 7 páginas com página 1 fechando.

View file

@ -10,9 +10,16 @@
* is exercised by `tests/int/cityReportSnapshot.int.spec.ts`.
*/
import { FEDERAL_DEPUTY_OFFICE, HISTORICAL_SERIES_YEARS } from '../src/lib/electionResults.ts'
import { getMunicipalityCatalogEntry } from '../src/lib/municipalityCatalog.ts'
import { getMunicipalityVoteRank } from '../src/lib/municipalityVoteRank.ts'
import { normalizeForSearch } from '../src/lib/speechSearch.ts'
import { campaignRoleLabels } from '../src/utilities/campaignUserProfile.ts'
import { loadMunicipalityDossierData } from '../src/utilities/municipality/municipalityDossierData.ts'
import {
municipalityElectionGeography,
municipalityGeographyWhere,
} from '../src/utilities/municipality/municipalityElectionGeography.ts'
import {
getMunicipalityDetailViewModel,
resolveAccessibleMunicipalityContext,
@ -23,6 +30,10 @@ import { loadSegmentsForSpeeches } from '../src/utilities/speech/speechPageData.
const SPEECH_LIMIT = 10
const DEMAND_LIMIT = 20
const COMPETITOR_LIMIT = 5
/** Data value of the office enum (only the federal constant is exported today). */
const STATE_DEPUTY_OFFICE = 'deputado_estadual'
const REFERENCE_YEAR = 2022
const projectActivity = (activity) => ({
id: activity.id,
@ -32,7 +43,14 @@ const projectActivity = (activity) => ({
locality: activity.locality,
})
const loadSpeeches = async (payload, actor, municipalityID) => {
/**
* Speeches mentioning the município, projected for the report: the official
* summary says what the speech is; `mentionExcerpt` is the passage that
* literally names the city (null when the acervo tag has no literal mention in
* the ASR segments — e.g. a gazetteer tagging among dozens of municípios);
* `mentionedMunicipalityCount` gives that context.
*/
const loadSpeeches = async (payload, actor, municipalityID, municipalityName) => {
const result = await payload.find({
collection: 'speech',
where: buildSpeechListWhere({ page: 1, municipalities: [municipalityID] }),
@ -41,9 +59,11 @@ const loadSpeeches = async (payload, actor, municipalityID) => {
sort: '-speechAt',
select: {
speechAt: true,
type: true,
phase: true,
summary: true,
officialTextUrl: true,
mentionedMunicipalities: true,
},
user: actor,
overrideAccess: false,
@ -53,16 +73,25 @@ const loadSpeeches = async (payload, actor, municipalityID) => {
actor,
result.docs.map((doc) => doc.id),
)
const normalizedCity = municipalityName ? normalizeForSearch(municipalityName) : ''
return {
totalCount: result.totalDocs,
rows: result.docs.map((doc) => {
const first = (segmentsBySpeech.get(doc.id) ?? [])[0]
const segments = segmentsBySpeech.get(doc.id) ?? []
const mentionSegment = normalizedCity
? segments.find((segment) => normalizeForSearch(segment.text).includes(normalizedCity))
: undefined
return {
id: doc.id,
speechAt: doc.speechAt ?? null,
type: doc.type ?? null,
phase: doc.phase ?? null,
summary: doc.summary ?? null,
officialTextUrl: doc.officialTextUrl ?? null,
excerpt: first?.text ?? doc.summary ?? null,
mentionExcerpt: mentionSegment?.text ?? null,
mentionedMunicipalityCount: Array.isArray(doc.mentionedMunicipalities)
? doc.mentionedMunicipalities.length
: 0,
}
}),
}
@ -91,6 +120,57 @@ const loadDemands = async (payload, actor, municipalityID) => {
}
}
/**
* Top federal/state deputy candidates by votes inside the município (reference
* year), with the 2014/2018/2022 series — the report's "main competitors"
* table. Same TSE collection the app's comparison card reads.
*/
const loadCompetitors = async (payload, actor, slug, office) => {
const entry = getMunicipalityCatalogEntry(slug)
if (!entry) return { office, rows: [] }
const result = await payload.find({
collection: 'electionCandidateVote',
where: {
and: [
{ year: { in: [...HISTORICAL_SERIES_YEARS] } },
{ office: { equals: office } },
{ turn: { equals: '1' } },
{ voteType: { equals: 'nominal' } },
municipalityGeographyWhere(municipalityElectionGeography(entry)),
],
},
depth: 0,
limit: 0,
pagination: false,
select: { year: true, candidateNumber: true, candidateName: true, party: true, votes: true },
user: actor,
overrideAccess: false,
})
const byCandidate = new Map()
for (const row of result.docs) {
const current = byCandidate.get(row.candidateNumber) ?? {
candidateNumber: row.candidateNumber,
name: row.candidateName,
party: row.party ?? null,
votesByYear: {},
}
current.votesByYear[String(row.year)] =
(current.votesByYear[String(row.year)] ?? 0) + (row.votes ?? 0)
byCandidate.set(row.candidateNumber, current)
}
const rows = [...byCandidate.values()]
.sort(
(left, right) =>
(right.votesByYear[String(REFERENCE_YEAR)] ?? 0) -
(left.votesByYear[String(REFERENCE_YEAR)] ?? 0) ||
left.candidateNumber - right.candidateNumber,
)
.slice(0, COMPETITOR_LIMIT)
return { office, year: REFERENCE_YEAR, rows }
}
/**
* Composes the snapshot. `actor` must be a real `campaignUser` doc (the CLI
* resolves and fails closed); loaders run with `overrideAccess: false` so the
@ -115,10 +195,12 @@ export const composeCityReportSnapshot = async ({
const context = await resolveAccessibleMunicipalityContext(payload, actor, slug)
const detail = await getMunicipalityDetailViewModel(payload, context, actor)
const dossier = await loadMunicipalityDossierData(payload, actor, detail)
const [advisors, speeches, demands] = await Promise.all([
const [advisors, speeches, demands, federalCompetitors, stateCompetitors] = await Promise.all([
loadAdvisorSummaries(payload, actor, detail.advisorIDs),
loadSpeeches(payload, actor, context.id),
loadSpeeches(payload, actor, context.id, detail.city),
loadDemands(payload, actor, context.id),
loadCompetitors(payload, actor, slug, FEDERAL_DEPUTY_OFFICE),
loadCompetitors(payload, actor, slug, STATE_DEPUTY_OFFICE),
])
const strategy = detail.strategy
@ -208,6 +290,11 @@ export const composeCityReportSnapshot = async ({
: null,
speeches,
demands,
competitors: {
referenceYear: REFERENCE_YEAR,
federal: federalCompetitors.rows,
state: stateCompetitors.rows,
},
demographics: dossier.demographics,
}
}

View file

@ -18,7 +18,10 @@ import {
campaignDemandStatusLabels,
} from '../../src/lib/schemas/campaignDemand.ts'
import { municipalityUpdatePolarityLabels } from '../../src/lib/schemas/municipalityUpdate.ts'
import { voteEstimateScenarioLabels } from '../../src/lib/voteEstimate.ts'
import {
formatVoteEstimateEndpointsLabel,
voteEstimateScenarioLabels,
} from '../../src/lib/voteEstimate.ts'
import { supportStatusLabels } from '../../src/utilities/leadership/leadershipLabels.ts'
import {
municipalityPriorityLabels,
@ -47,7 +50,8 @@ const ADVERTENCIA_DEFESO =
const GAP_CALLOUT_LIMIT = 4
const SIGNAL_EXCERPT_MAX = 180
const SPEECH_EXCERPT_MAX = 220
const SPEECH_SUMMARY_MAX = 220
const SPEECH_MENTION_MAX = 220
const sourceTeqo = (label, date) => ({ kind: 'teqo', label, date: date ?? null })
const sourceWeb = (label, url, date) => ({
@ -94,15 +98,6 @@ const gapCallout = (gaps, title = 'Lacunas desta seção') => {
}
}
const emendasGap = (emendas) =>
emendas?.status === 'gap'
? {
id: 'emendas',
label: 'Emendas',
reason: emendas.detail ? `${emendas.reason} (${emendas.detail})` : emendas.reason,
}
: null
const buildIdentificationItems = ({ municipality, goal, conjuncture, advisors }) => [
{ label: 'Território', value: municipality.region },
{
@ -125,21 +120,26 @@ const buildIdentificationItems = ({ municipality, goal, conjuncture, advisors })
},
]
const buildElectoralKpis = ({ electoral, goal }) => [
{
label: 'Votos em 2022',
value: formatInteger(electoral.rank2022?.votes ?? null),
hint: electoral.rank2022
? `% do próprio voto: ${formatPercent(electoral.rank2022.share)}`
: null,
},
{ label: 'Rank no estado', value: formatRank(electoral.rank2022) },
{
label: 'Meta 2026 (central)',
value: goal ? formatInteger(goal.suggestedGoal) : '—',
hint: goal ? 'Cenário central' : null,
},
]
const buildElectoralKpis = ({ electoral, conjuncture }) => {
const expectedVotes = conjuncture?.expectedVotes ?? null
return [
{
label: 'Votos em 2022',
value: formatInteger(electoral.rank2022?.votes ?? null),
hint: electoral.rank2022
? `% do próprio voto: ${formatPercent(electoral.rank2022.share)}`
: null,
},
{ label: 'Rank no estado', value: formatRank(electoral.rank2022) },
{
label: 'Expectativa de votos',
value: formatInteger(expectedVotes?.central ?? null),
hint: expectedVotes
? (formatVoteEstimateEndpointsLabel(expectedVotes) ?? 'Cenários da base')
: 'sem registro na base',
},
]
}
const buildWhoRows = ({ leaderships, advisors, conjuncture }, research, prefeito) => {
const rows = []
@ -231,7 +231,7 @@ const buildRiskCards = ({ leaderships, advisors, conjuncture }, research) => {
{
title: 'Oposição local',
body: opposition
? `${opposition.text} (fonte: pesquisa web · ${formatDateBr(opposition.source.date)}; URLs na seção 10)`
? `${opposition.text} (fonte: pesquisa web · ${formatDateBr(opposition.source.date)})`
: 'Sem leitura suficiente — lacuna',
},
{
@ -247,20 +247,34 @@ const buildRiskCards = ({ leaderships, advisors, conjuncture }, research) => {
]
}
const collectPageOneGaps = ({ electoral, goal, prefeito }, emendas) => {
/**
* Page-1 gaps: the emendas gap is not repeated here — it is carried by the
* "O que Solla entregou" KPI and, when the agent researched it, by the web
* evidence block ("nunca zero silencioso" stays explicit on those surfaces).
*/
const collectPageOneGaps = ({ electoral, prefeito }) => {
const gaps = []
if (!prefeito) gaps.push({ id: 'prefeito', label: 'Prefeito(a)', reason: 'Não pesquisado.' })
if (!goal) gaps.push({ id: 'meta', label: 'Meta', reason: 'Sem conta da cadeira na base.' })
if (!electoral.rank2022) gaps.push({ id: 'rank', label: 'Rank', reason: 'Fora do artefato TSE.' })
const deliveriesGap = emendasGap(emendas)
if (deliveriesGap) gaps.push(deliveriesGap)
return gaps
}
const buildEmendasEvidenceBlock = (research, emendas) => {
if (emendas?.status === 'ok') return null
const evidence = researchAnswerItem(research, 'emendas_web')
if (!evidence) return null
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.'],
}
}
const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => {
const prefeito = researchAnswerItem(research, 'prefeito')
const gapCalloutBlock = gapCallout(
collectPageOneGaps({ electoral: snapshot.electoral, goal: snapshot.goal, prefeito }, emendas),
collectPageOneGaps({ electoral: snapshot.electoral, prefeito }),
'Pontos sem leitura',
)
@ -273,23 +287,7 @@ const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => {
cells: [
{
title: 'Conta eleitoral 2022',
blocks: [
{ kind: 'kpis', items: buildElectoralKpis(snapshot) },
{
kind: 'stats',
rows: [
{
label: 'Cobertura de pledges',
value: snapshot.goal
? formatPercent(snapshot.goal.goalCoverage.coverageRatio)
: '—',
hint: snapshot.goal
? `${formatInteger(snapshot.goal.goalCoverage.committed)} de ${formatInteger(snapshot.goal.goalCoverage.goal)}`
: null,
},
],
},
],
blocks: [{ kind: 'kpis', items: buildElectoralKpis(snapshot) }],
},
{
title: 'Quem é quem',
@ -297,7 +295,10 @@ const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => {
},
],
sources: [
sourceTeqo('base Teqo — TSE 2022, conta da cadeira e lideranças', snapshot.meta?.readAt),
sourceTeqo(
'base Teqo — TSE 2022, expectativa de votos e lideranças',
snapshot.meta?.readAt,
),
],
},
{
@ -314,6 +315,7 @@ const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => {
sourceWeb('pesquisa web — imprensa', null, research.researchedAt),
],
},
buildEmendasEvidenceBlock(research, emendas),
buildAnnouncePair(research),
{
kind: 'cards',
@ -373,7 +375,93 @@ const buildElectoralSection = ({ snapshot }) => {
].filter(Boolean)
}
const buildNetworkSection = ({ snapshot }) => {
const COMPETITOR_SERIES_YEARS = ['2014', '2018', '2022']
const buildCompetitorsTable = ({ title, rows, referenceYear, readAt }) => ({
kind: 'table',
title,
columns: [
{ key: 'name', label: 'Candidato' },
{ key: 'party', label: 'Partido' },
...COMPETITOR_SERIES_YEARS.map((year) => ({ key: `y${year}`, label: year, numeric: true })),
],
rows: rows.map((row) => ({
name: row.name,
party: row.party ?? '—',
...Object.fromEntries(
COMPETITOR_SERIES_YEARS.map((year) => [
`y${year}`,
year in (row.votesByYear ?? {}) ? formatInteger(row.votesByYear[year]) : '—',
]),
),
})),
note: `Top 5 por votos de ${referenceYear} no município (TSE, 1º turno, votos nominais); “—” = sem votos na série.`,
sources: [sourceTeqo('base Teqo — TSE 2014/2018/2022', readAt)],
})
const buildCompetitorsSection = ({ snapshot, research }) => {
const competitors = snapshot.competitors ?? { referenceYear: 2022, federal: [], state: [] }
const offices = [
{ key: 'federal', label: 'Deputado federal' },
{ key: 'state', label: 'Deputado estadual' },
]
const blocks = offices.map((office) => {
const rows = competitors[office.key] ?? []
return rows.length
? buildCompetitorsTable({
title: `Concorrentes — ${office.label}`,
rows,
referenceYear: competitors.referenceYear ?? 2022,
readAt: snapshot.meta?.readAt,
})
: {
kind: 'callout',
tone: 'gap',
title: `Concorrentes — ${office.label}`,
body: ['Sem série de concorrentes na base para este município.', GAP_COPY],
}
})
const preCandidates = research.preCandidates ?? []
blocks.push(
preCandidates.length
? {
kind: 'table',
title: 'Prováveis candidatos do campo do prefeito em 2026 (pesquisa)',
columns: [
{ key: 'name', label: 'Nome' },
{ key: 'office', label: 'Cargo' },
{ key: 'party', label: 'Partido' },
{ key: 'support', label: 'Apoio' },
{ key: 'source', label: 'Fonte' },
],
rows: preCandidates.map((item) => ({
name: item.name,
office: item.office,
party: item.party ?? '—',
support: item.support ?? '—',
source: formatDateBr(item.sourceDate),
})),
note: 'Nomes declarados em fontes datadas; o histórico de votos vem das tabelas acima quando o nome aparece na base.',
sources: preCandidates.map((item) => ({
kind: 'web',
label: `${item.name} — ${item.office}`,
url: item.sourceUrl,
date: item.sourceDate,
})),
}
: {
kind: 'callout',
tone: 'gap',
title: 'Prováveis candidatos do campo do prefeito em 2026',
body: ['Nenhum pré-candidato pesquisado com fonte.', GAP_COPY],
},
)
return blocks
}
const buildNetworkSection = ({ snapshot, research }) => {
const { leaderships, advisors } = snapshot
const rows = leaderships.rows.map((row) => ({
name: row.name,
@ -432,6 +520,40 @@ const buildNetworkSection = ({ snapshot }) => {
sources: [sourceTeqo('base Teqo — pledges', snapshot.meta?.readAt)],
})
const cityLeaders = research?.leaders ?? []
if (cityLeaders.length) {
blocks.push({
kind: 'table',
title: 'Lideranças da cidade (pesquisa)',
columns: [
{ key: 'name', label: 'Nome' },
{ key: 'role', label: 'Papel' },
{ key: 'period', label: 'Período' },
{ key: 'source', label: 'Fonte' },
],
rows: cityLeaders.map((leader) => ({
name: leader.name,
role: leader.role,
period: leader.period ?? '—',
source: formatDateBr(leader.sourceDate),
})),
note: 'Últimos prefeitos/vices, vereadores mais votados e lideranças locais — pesquisa web com URL por item.',
sources: cityLeaders.map((leader) => ({
kind: 'web',
label: `${leader.name} — ${leader.role}`,
url: leader.sourceUrl,
date: leader.sourceDate,
})),
})
} else {
blocks.push({
kind: 'callout',
tone: 'gap',
title: 'Lideranças da cidade (pesquisa)',
body: ['Nenhuma liderança local pesquisada com fonte.', GAP_COPY],
})
}
return blocks
}
@ -658,7 +780,7 @@ const buildDemographicsSection = ({ snapshot }) => {
}
const buildSpeechesSection = ({ snapshot }) => {
const { speeches } = snapshot
const { speeches, municipality } = snapshot
if (!speeches.rows.length) {
return [
{
@ -672,6 +794,7 @@ const buildSpeechesSection = ({ snapshot }) => {
},
]
}
const cityLabel = municipality?.name ?? 'o município'
return [
{
kind: 'table',
@ -679,24 +802,71 @@ const buildSpeechesSection = ({ snapshot }) => {
columns: [
{ key: 'date', label: 'Data' },
{ key: 'phase', label: 'Fase' },
{ key: 'excerpt', label: 'Trecho' },
{ key: 'description', label: 'O que é' },
{ key: 'mention', label: `Menção a ${cityLabel}` },
{ key: 'link', label: 'Link' },
],
rows: speeches.rows.map((row) => ({
date: formatDateBr(row.speechAt),
phase: row.phase ?? '—',
excerpt: row.excerpt ? row.excerpt.slice(0, SPEECH_EXCERPT_MAX) : '—',
description: row.summary ? row.summary.slice(0, SPEECH_SUMMARY_MAX) : '—',
mention: row.mentionExcerpt
? row.mentionExcerpt.slice(0, SPEECH_MENTION_MAX)
: row.mentionedMunicipalityCount
? `Marcada no acervo — nome não localizado nos trechos (a fala cita ${formatInteger(row.mentionedMunicipalityCount)} municípios).`
: 'Marcada no acervo — trecho não localizado.',
link: row.officialTextUrl ?? '—',
})),
note:
note: [
'“O que é” = sumário oficial da Câmara; “Menção” = passagem que cita o município ou, quando o nome não aparece nos trechos, a marcação do acervo (C153/C155) com o nº de municípios da fala.',
speeches.totalCount > speeches.rows.length
? `Mostrando ${speeches.rows.length} de ${speeches.totalCount} falas.`
: null,
]
.filter(Boolean)
.join(' '),
sources: [sourceTeqo('base Teqo — acervo C153/C155', snapshot.meta?.readAt)],
},
]
}
const buildApproachSection = ({ research }) => {
const approach = research.approach ?? []
if (!approach.length) {
return [
{
kind: 'callout',
tone: 'gap',
title: 'Abordagem sugerida (pesquisa)',
body: ['Nenhuma sugestão pesquisada para este município.', GAP_COPY],
},
]
}
return [
{
kind: 'table',
title: 'Abordagem sugerida (pesquisa)',
columns: [
{ key: 'topic', label: 'Frente' },
{ key: 'suggestion', label: 'Sugestão' },
{ key: 'source', label: 'Fonte' },
],
rows: approach.map((item) => ({
topic: item.persona ? `${item.persona} · ${item.topic}` : item.topic,
suggestion: item.suggestion,
source: formatDateBr(item.sourceDate),
})),
note: 'Sugestões das personas de ciência política e coordenação de campanha, ancoradas nas fontes listadas (URL por item); sem fonte, não entra.',
sources: approach.map((item) => ({
kind: 'web',
label: `${item.topic}${item.persona ? ` — ${item.persona}` : ''}`,
url: item.sourceUrl,
date: item.sourceDate,
})),
},
]
}
const buildNewsSection = ({ research }) => {
const blocks = []
if (research.news.length) {
@ -789,6 +959,14 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => {
url: item.sourceUrl,
date: item.sourceDate,
})
for (const extra of item.extraSources ?? []) {
items.push({
kind: 'web',
label: extra.label ?? `${item.label} — fonte adicional`,
url: extra.url,
date: extra.date,
})
}
}
if (emendas?.status === 'ok') {
items.push({
@ -808,6 +986,14 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => {
for (const item of research.news) {
items.push({ kind: 'web', label: item.title, url: item.url, date: item.publishedAt })
}
for (const item of research.approach ?? []) {
items.push({
kind: 'web',
label: `Abordagem — ${item.topic}${item.persona ? ` (${item.persona})` : ''}`,
url: item.sourceUrl,
date: item.sourceDate,
})
}
return [
{
@ -820,6 +1006,8 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => {
'Não existe voto por bairro/seção — o relatório não inventa granularidade.',
'Efeito eleitoral de emenda é condicional ao crédito local — o PDF não promete voto.',
'Emendas são lidas da fonte oficial em tempo de geração e não são persistidas na base.',
'Quando a fonte oficial não atribui emenda ao município, indícios web (município, região ou polo) entram como evidência datada — nunca somados como emenda da cidade.',
'Abordagem sugerida é análise das personas ancorada nas fontes listadas item a item, não fato verificado além delas.',
],
},
]
@ -862,29 +1050,43 @@ export const buildCityReport = ({ snapshot, research, emendas, generatedAt = new
title: '1. Conta eleitoral completa (2014/2018/2022)',
blocks: buildElectoralSection({ snapshot }),
},
{ id: 'rede', title: '2. Rede e lideranças', blocks: buildNetworkSection({ snapshot }) },
{ id: 'conjuntura', title: '3. Conjuntura', blocks: buildConjunctureSection({ snapshot }) },
{ id: 'sinais', title: '4. Sinais recentes', blocks: buildSignalsSection({ snapshot }) },
{
id: 'concorrentes',
title: '2. Concorrentes no município (federal e estadual)',
blocks: buildCompetitorsSection({ snapshot, research }),
},
{
id: 'rede',
title: '3. Rede e lideranças',
blocks: buildNetworkSection({ snapshot, research }),
},
{ id: 'conjuntura', title: '4. Conjuntura', blocks: buildConjunctureSection({ snapshot }) },
{ id: 'sinais', title: '5. Sinais recentes', blocks: buildSignalsSection({ snapshot }) },
{
id: 'demandas',
title: '5. Demandas e visitas',
title: '6. Demandas e visitas',
blocks: buildDemandsVisitsSection({ snapshot }),
},
{ id: 'demografia', title: '6. Demografia', blocks: buildDemographicsSection({ snapshot }) },
{ id: 'falas', title: '7. Acervo de falas', blocks: buildSpeechesSection({ snapshot }) },
{ id: 'demografia', title: '7. Demografia', blocks: buildDemographicsSection({ snapshot }) },
{ id: 'falas', title: '8. Acervo de falas', blocks: buildSpeechesSection({ snapshot }) },
{
id: 'noticias',
title: '8. Notícias internas e imprensa local',
title: '9. Notícias internas e imprensa local',
blocks: buildNewsSection({ research }),
},
{
id: 'regiao',
title: '9. Panorama regional (Território de Identidade)',
title: '10. Panorama regional (Território de Identidade)',
blocks: buildRegionSection({ snapshot }),
},
{
id: 'abordagem',
title: '11. Abordagem sugerida (pesquisa)',
blocks: buildApproachSection({ research }),
},
{
id: 'fontes',
title: '10. Fontes e limites',
title: '12. Fontes e limites',
blocks: buildSourcesSection({ snapshot, research, emendas }),
},
]

View file

@ -14,6 +14,12 @@ const htmlEscape = (value) =>
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
const URL_PATTERN = /(https?:\/\/[^\s<>"')]+)/g
/** Escapes the text and turns every bare URL into a clickable anchor (PDF links). */
const htmlWithLinks = (value) =>
htmlEscape(value).replace(URL_PATTERN, (url) => `<a href="${url}">${url}</a>`)
const sourceKindLabels = {
teqo: 'base Teqo',
web: 'pesquisa web',
@ -128,7 +134,7 @@ const PRINT_CSS = `
.table-note { color: #71717a; font-size: 7.2pt; margin: 1mm 0 0; }
.prose p { margin: 0 0 1.4mm; }
.page-break { break-before: page; }
.index ol { columns: 2; margin: 0; padding-left: 5mm; color: #3f3f46; font-size: 8.6pt; }
.index ol { columns: 2; margin: 0; padding-left: 0; list-style: none; color: #3f3f46; font-size: 8.6pt; }
.report-section { margin-bottom: 6mm; }
.report-section > h3 { font-size: 11pt; margin: 0 0 2mm; break-after: avoid; }
.block + .block { margin-top: 3mm; }
@ -181,7 +187,7 @@ const renderStats = (block) => {
.map(
(row) => `<div class="stat">
<span class="stat-label">${htmlEscape(row.label)}</span>
<span class="stat-value">${htmlEscape(row.value)}${row.hint ? `<span class="stat-hint">${htmlEscape(row.hint)}</span>` : ''}${row.source ? `<span class="row-source">fonte: ${htmlEscape(sourceLabel(row.source))}</span>` : ''}</span>
<span class="stat-value">${htmlEscape(row.value)}${row.hint ? `<span class="stat-hint">${htmlEscape(row.hint)}</span>` : ''}${row.source ? `<span class="row-source">fonte: ${htmlWithLinks(sourceLabel(row.source))}</span>` : ''}</span>
</div>`,
)
.join('')
@ -192,7 +198,7 @@ const renderBullets = (block) => {
const items = block.items
.map((item) => {
const source = item.source
? ` <span class="row-source">fonte: ${htmlEscape(sourceLabel(item.source))}</span>`
? ` <span class="row-source">fonte: ${htmlWithLinks(sourceLabel(item.source))}</span>`
: ''
return `<li>${htmlEscape(item.text)}${source}</li>`
})
@ -220,7 +226,7 @@ const renderTable = (block) => {
`<tr>${block.columns
.map(
(column) =>
`<td${column.numeric ? ' class="num"' : ''}>${htmlEscape(row[column.key])}</td>`,
`<td${column.numeric ? ' class="num"' : ''}>${htmlWithLinks(row[column.key])}</td>`,
)
.join('')}</tr>`,
)
@ -291,7 +297,7 @@ const renderBlockHtml = (block) => {
throw new Error(`Bloco desconhecido no render HTML: ${block.kind}`)
}
const sources = block.sources?.length
? `<p class="source">Fontes: ${block.sources.map((source) => htmlEscape(sourceLabel(source))).join(' · ')}</p>`
? `<p class="source">Fontes: ${block.sources.map((source) => htmlWithLinks(sourceLabel(source))).join(' · ')}</p>`
: ''
const className = block.kind === 'table' ? 'block table-block' : 'block'
return `<div class="${className}">${title}${body}${sources}</div>`

View file

@ -20,7 +20,10 @@ const RESEARCH_CHECKLIST = [
{ id: 'quem_investe', label: 'Quem mais investe na cidade (bancada e adversários)' },
{ id: 'noticias', label: 'Notícias da cidade/região (janela ≤90 dias)' },
{ id: 'imprensa_local', label: 'Imprensa e rádio local' },
{ id: 'emendas_web', label: 'Emendas na web (quando a fonte oficial não responder)' },
{
id: 'emendas_web',
label: 'Emendas — indícios web (município, região ou polo)',
},
]
export const RESEARCH_CHECKLIST_IDS = RESEARCH_CHECKLIST.map((item) => item.id)
@ -87,6 +90,24 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
})
continue
}
const extraSources = []
for (const extra of Array.isArray(entry.extraSources) ? entry.extraSources : []) {
const extraUrl = isNonEmptyString(extra?.url) ? extra.url.trim() : null
const extraDate = isValidDate(extra?.date) ? extra.date : null
if (!extraUrl || !extraDate) {
gaps.push({
id: 'fonte_extra',
label: checklistById.get(id).label,
reason: 'Fonte adicional sem URL ou data — descartada.',
})
continue
}
extraSources.push({
label: isNonEmptyString(extra.label) ? extra.label.trim() : null,
url: extraUrl,
date: extraDate,
})
}
items.push({
id,
label: checklistById.get(id).label,
@ -94,6 +115,7 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
details: isNonEmptyString(entry.details) ? entry.details.trim() : null,
sourceUrl,
sourceDate,
extraSources,
consultedAt: isValidDate(entry.consultedAt) ? entry.consultedAt : null,
})
}
@ -147,11 +169,111 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
})
}
const approach = []
for (const entry of Array.isArray(raw.approach) ? raw.approach : []) {
const topic = isNonEmptyString(entry?.topic) ? entry.topic.trim() : null
const suggestion = isNonEmptyString(entry?.suggestion) ? entry.suggestion.trim() : null
const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null
const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null
if (!topic || !suggestion) {
gaps.push({
id: 'abordagem_incompleta',
label: topic,
reason: 'Sugestão sem tema ou texto — descartada.',
})
continue
}
if (!sourceUrl || !sourceDate) {
gaps.push({
id: 'abordagem_sem_fonte',
label: topic,
reason: 'Sugestão sem fonte: URL e data são obrigatórias.',
})
continue
}
approach.push({
persona: isNonEmptyString(entry.persona) ? entry.persona.trim() : null,
topic,
suggestion,
sourceUrl,
sourceDate,
consultedAt: isValidDate(entry.consultedAt) ? entry.consultedAt : null,
})
}
const preCandidates = []
for (const entry of Array.isArray(raw.preCandidates) ? raw.preCandidates : []) {
const name = isNonEmptyString(entry?.name) ? entry.name.trim() : null
const office = isNonEmptyString(entry?.office) ? entry.office.trim() : null
const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null
const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null
if (!name || !office) {
gaps.push({
id: 'precandidato_incompleto',
label: name,
reason: 'Pré-candidato sem nome ou cargo — descartado.',
})
continue
}
if (!sourceUrl || !sourceDate) {
gaps.push({
id: 'precandidato_sem_fonte',
label: name,
reason: 'Pré-candidato sem fonte: URL e data são obrigatórias.',
})
continue
}
preCandidates.push({
name,
office,
party: isNonEmptyString(entry.party) ? entry.party.trim() : null,
support: isNonEmptyString(entry.support) ? entry.support.trim() : null,
note: isNonEmptyString(entry.note) ? entry.note.trim() : null,
sourceUrl,
sourceDate,
})
}
const leaders = []
for (const entry of Array.isArray(raw.leaders) ? raw.leaders : []) {
const name = isNonEmptyString(entry?.name) ? entry.name.trim() : null
const role = isNonEmptyString(entry?.role) ? entry.role.trim() : null
const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null
const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null
if (!name || !role) {
gaps.push({
id: 'lideranca_incompleta',
label: name,
reason: 'Liderança sem nome ou papel — descartada.',
})
continue
}
if (!sourceUrl || !sourceDate) {
gaps.push({
id: 'lideranca_sem_fonte',
label: name,
reason: 'Liderança sem fonte: URL e data são obrigatórias.',
})
continue
}
leaders.push({
name,
role,
period: isNonEmptyString(entry.period) ? entry.period.trim() : null,
note: isNonEmptyString(entry.note) ? entry.note.trim() : null,
sourceUrl,
sourceDate,
})
}
return {
municipalitySlug: raw.municipalitySlug.trim(),
researchedAt: researchedAt.toISOString(),
items,
news,
approach,
preCandidates,
leaders,
gaps,
}
}

View file

@ -8,18 +8,31 @@ import {
const generatedAt = new Date('2026-09-15T12:00:00.000Z')
const validItem = (id: string) => ({
const validItem = (id: string, overrides: Record<string, unknown> = {}) => ({
id,
answer: `Resposta de ${id}`,
sourceUrl: `https://exemplo.test/${id}`,
sourceDate: '2026-09-10',
...overrides,
})
const research = normalizeResearchInput(
{
municipalitySlug: 'feira-de-santana',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map(validItem),
items: RESEARCH_CHECKLIST_IDS.map((id) =>
id === 'emendas_web'
? validItem(id, {
extraSources: [
{
label: 'Polo regional — Teixeira de Freitas',
url: 'https://exemplo.test/polo',
date: '2026-09-01',
},
],
})
: validItem(id),
),
news: [
{
title: 'Matéria local',
@ -28,6 +41,34 @@ const research = normalizeResearchInput(
outlet: 'Jornal Local',
},
],
approach: [
{
persona: 'Ciência política',
topic: 'Saúde',
suggestion: 'Ancorar a agenda na defesa do SUS e no SAMU.',
sourceUrl: 'https://exemplo.test/abordagem',
sourceDate: '2026-09-10',
},
],
preCandidates: [
{
name: 'Deputado do Prefeito',
office: 'Deputado federal',
party: 'PSDB',
support: 'Prefeito Jorge Almeida',
sourceUrl: 'https://exemplo.test/pre-candidato',
sourceDate: '2026-04-18',
},
],
leaders: [
{
name: 'Ex-Prefeito',
role: 'Prefeito',
period: '2017–2024',
sourceUrl: 'https://exemplo.test/lideranca',
sourceDate: '2026-03-05',
},
],
gaps: [],
},
{ now: generatedAt },
@ -182,8 +223,26 @@ const snapshot = {
budgetNotes: 'Emenda de custeio aportada em 2025',
stateDeputies: [{ id: 5, name: 'Deputada Estadual', party: 'PT' }],
},
competitors: {
referenceYear: 2022,
federal: [
{
candidateNumber: 1313,
name: 'Solla',
party: 'PT',
votesByYear: { '2018': 40_000, '2022': 50_000 },
},
{
candidateNumber: 4545,
name: 'Concorrente A',
party: 'PSDB',
votesByYear: { '2018': 20_000, '2022': 30_000 },
},
],
state: [],
},
speeches: {
totalCount: 1,
totalCount: 2,
rows: [
{
id: 30,
@ -192,8 +251,18 @@ const snapshot = {
phase: 'Discurso em plenário',
summary: 'Resumo da fala',
officialTextUrl: 'https://camara.test/discurso',
watchHref: '/campanha/comunicacao/acervo/30?t=120',
excerpt: 'Trecho da fala sobre Feira de Santana',
mentionExcerpt: 'Trecho da fala que cita Feira de Santana',
mentionedMunicipalityCount: 3,
},
{
id: 31,
speechAt: '2026-06-01T10:00:00.000Z',
year: 2026,
phase: 'Pequeno Expediente',
summary: 'Saúde indígena na Bahia',
officialTextUrl: 'https://camara.test/discurso-2',
mentionExcerpt: null,
mentionedMunicipalityCount: 25,
},
],
},
@ -230,11 +299,13 @@ type ReportRow = {
value: string
source?: { url?: string | null; date?: string | null }
}
type TableRow = Record<string, string>
type ReportBlock = {
kind: string
title?: string
items?: ReportItem[]
rows?: ReportRow[]
columns?: Array<{ key: string; label: string }>
cells?: Array<{ title: string; blocks: ReportBlock[] }>
body?: string[]
sources?: Array<{ kind: string; url?: string | null }>
@ -251,10 +322,86 @@ const asReport = (value: unknown) => value as ReportShape
describe('buildCityReport', () => {
const report = asReport(buildCityReport({ snapshot, research, emendas, generatedAt }))
it('builds the 10 deepening sections in the product order', () => {
expect(report.sections).toHaveLength(10)
it('builds the 12 deepening sections in the product order', () => {
expect(report.sections).toHaveLength(12)
expect(report.sections[0].title).toMatch(/Conta eleitoral completa/)
expect(report.sections[9].title).toMatch(/Fontes e limites/)
expect(report.sections[1].title).toMatch(/Concorrentes no município/)
expect(report.sections[10].title).toMatch(/Abordagem sugerida/)
expect(report.sections[11].title).toMatch(/Fontes e limites/)
})
it('lists the main competitors with their vote series and the pre-candidates', () => {
const concorrentes = report.sections.find((section) => section.id === 'concorrentes')!
const federal = concorrentes.blocks[0]
const rows = federal.rows as unknown as TableRow[]
expect(federal.title).toContain('Deputado federal')
expect(rows.map((row) => row.name)).toEqual(['Solla', 'Concorrente A'])
expect(rows[1].y2022).toBe('30.000')
expect(rows[1].y2014).toBe('—')
const preCandidates = concorrentes.blocks[2]
const preRows = preCandidates.rows as unknown as TableRow[]
expect(preRows[0].name).toBe('Deputado do Prefeito')
expect(preCandidates.sources![0]).toEqual(
expect.objectContaining({ url: 'https://exemplo.test/pre-candidato' }),
)
})
it('lists the researched city leaders in the network section', () => {
const rede = report.sections.find((section) => section.id === 'rede')!
const leadersBlock = rede.blocks.find(
(block) => block.title === 'Lideranças da cidade (pesquisa)',
)!
const rows = leadersBlock.rows as unknown as TableRow[]
expect(rows[0].name).toBe('Ex-Prefeito')
expect(rows[0].period).toBe('2017–2024')
expect(leadersBlock.sources![0]).toEqual(
expect.objectContaining({ url: 'https://exemplo.test/lideranca' }),
)
})
it('shows expected votes on page 1 instead of the goal/pledge coverage', () => {
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
const kpis = grid.cells![0].blocks.find((block) => block.kind === 'kpis')!
expect(kpis.items!.map((item) => item.label)).toEqual([
'Votos em 2022',
'Rank no estado',
'Expectativa de votos',
])
expect(kpis.items![2].value).toBe('50.000')
expect(kpis.items![2].hint).toContain('Pessimista 40.000')
expect(kpis.items![2].hint).toContain('Otimista 70.000')
const leftLabels = grid.cells![0].blocks.flatMap((block) =>
(block.rows ?? []).map((row) => row.label),
)
expect(leftLabels).not.toContain('Cobertura de pledges')
expect(JSON.stringify(report.page1)).not.toContain('Meta 2026')
})
it('describes what each speech is and how the município is mentioned', () => {
const falas = report.sections.find((section) => section.id === 'falas')!.blocks[0]
const rows = falas.rows as unknown as TableRow[]
expect(falas.columns!.map((column) => column.label)).toEqual([
'Data',
'Fase',
'O que é',
`Menção a ${snapshot.municipality.name}`,
'Link',
])
expect(rows[0].description).toBe('Resumo da fala')
expect(rows[0].mention).toContain('cita Feira de Santana')
expect(rows[1].description).toBe('Saúde indígena na Bahia')
expect(rows[1].mention).toContain('nome não localizado nos trechos')
expect(rows[1].mention).toContain('25 municípios')
})
it('renders the persona approach section and its sources', () => {
const abordagem = report.sections.find((section) => section.id === 'abordagem')!.blocks[0]
const rows = abordagem.rows as unknown as TableRow[]
expect(rows[0].topic).toContain('Ciência política')
expect(rows[0].suggestion).toContain('SAMU')
expect(abordagem.sources![0]).toEqual(
expect.objectContaining({ url: 'https://exemplo.test/abordagem' }),
)
})
it('keeps page 1 in the contract order (six blocks + footer)', () => {
@ -314,10 +461,37 @@ describe('buildCityReport', () => {
(block) => block.kind === 'kpis' && block.title === 'O que Solla entregou',
)!
expect(delivered.items![0].value).toBe('Sem fonte oficial')
const gaps = gapReport.page1.blocks.find(
(block) => block.kind === 'callout' && block.title === 'Pontos sem leitura',
)!
expect(gaps.body!.join(' ')).toMatch(/Emendas/)
expect(delivered.items![0].hint).toContain('sem emenda atribuível')
})
it('shows the web evidence for emendas when the official source is a gap', () => {
const gapReport = asReport(
buildCityReport({
snapshot,
research,
emendas: {
status: 'gap',
reason: 'Sem emenda do autor com localidade Feira de Santana na janela.',
detail: 'API não expõe o município da emenda.',
sourceUrl: 'https://api.portaldatransparencia.gov.br/api-de-dados/emendas',
consultedAt: '2026-09-15T11:00:00.000Z',
},
generatedAt,
}),
)
const evidence = gapReport.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')
const evidenceOk = asReport(buildCityReport({ snapshot, research, emendas, generatedAt }))
expect(
evidenceOk.page1.blocks.some(
(block) => block.title === 'Emendas — indícios web (sem atribuição oficial ao município)',
),
).toBe(false)
})
it('puts research answers in Quem é quem with compact sources and URLs in section 10', () => {
@ -329,6 +503,7 @@ describe('buildCityReport', () => {
expect(prefeito.source!.date).toBe('2026-09-10')
const fontes = report.sections.find((section) => section.id === 'fontes')!.blocks[0]
expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/prefeito')).toBe(true)
expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/polo')).toBe(true)
})
it('builds the region panorama from the committed artifact', () => {

View file

@ -95,6 +95,13 @@ describe('renderReportHtml', () => {
expect(html).toContain('&lt;script&gt;')
})
it('turns every URL into a clickable anchor', () => {
expect(html).toContain(
'<a href="https://exemplo.test/prefeito">https://exemplo.test/prefeito</a>',
)
expect(html).toContain('href="https://exemplo.test/emendas_web"')
})
it('prints the gaps as callouts and the limits list', () => {
expect(html).toContain('Lacuna explícita')
expect(html).toContain('Limites')
@ -106,7 +113,9 @@ describe('renderReportMd', () => {
it('carries the same sections as tables and headings', () => {
expect(md).toContain('## 1. Conta eleitoral completa')
expect(md).toContain('## 10. Fontes e limites')
expect(md).toContain('## 2. Concorrentes no município (federal e estadual)')
expect(md).toContain('## 11. Abordagem sugerida (pesquisa)')
expect(md).toContain('## 12. Fontes e limites')
expect(md).toContain('| Ano | Votos |')
})

View file

@ -84,6 +84,61 @@ describe('normalizeResearchInput', () => {
expect(research.gaps.filter((gap) => gap.reason === 'Não pesquisado.')).toHaveLength(0)
})
it('keeps item extra sources with URL + date and drops ones without', () => {
const research = normalizeResearchInput(
baseResearch({
items: [
validItem('emendas_web', {
extraSources: [
{ label: 'Polo regional', url: 'https://exemplo.test/polo', date: '2026-09-01' },
{ label: 'Sem data', url: 'https://exemplo.test/sem-data' },
],
}),
...RESEARCH_CHECKLIST_IDS.filter((id) => id !== 'emendas_web').map((id) => validItem(id)),
],
}),
{ now },
)
const item = research.items.find((entry) => entry.id === 'emendas_web')!
expect(item.extraSources).toHaveLength(1)
expect(item.extraSources[0].url).toBe('https://exemplo.test/polo')
expect(research.gaps).toContainEqual(
expect.objectContaining({ id: 'fonte_extra', reason: expect.stringMatching(/URL ou data/) }),
)
})
it('keeps sourced approach suggestions and gaps ones without source', () => {
const research = normalizeResearchInput(
baseResearch({
approach: [
{
persona: 'Coordenação',
topic: 'Saúde',
suggestion: 'Agenda no hospital regional.',
sourceUrl: 'https://exemplo.test/saude',
sourceDate: '2026-09-01',
},
{
persona: 'Ciência política',
topic: 'Sem fonte',
suggestion: 'Não entra.',
sourceUrl: null,
sourceDate: null,
},
],
}),
{ now },
)
expect(research.approach).toHaveLength(1)
expect(research.approach[0].persona).toBe('Coordenação')
expect(research.gaps).toContainEqual(
expect.objectContaining({
id: 'abordagem_sem_fonte',
reason: expect.stringMatching(/URL e data/),
}),
)
})
it('drops news outside the 90-day window and without source', () => {
const research = normalizeResearchInput(
baseResearch({