diff --git a/.agents/skills/relatorio-cidade/SKILL.md b/.agents/skills/relatorio-cidade/SKILL.md
index 4a1cdedf..2726727e 100644
--- a/.agents/skills/relatorio-cidade/SKILL.md
+++ b/.agents/skills/relatorio-cidade/SKILL.md
@@ -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/.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/.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 (``): 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/.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/.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` ≠
diff --git a/docs/changelog/2026-09-15-c163-relatorio-v2.md b/docs/changelog/2026-09-15-c163-relatorio-v2.md
new file mode 100644
index 00000000..5757c5f4
--- /dev/null
+++ b/docs/changelog/2026-09-15-c163-relatorio-v2.md
@@ -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.
diff --git a/scripts/cityReportSnapshot.mjs b/scripts/cityReportSnapshot.mjs
index 8a855acc..853634b0 100644
--- a/scripts/cityReportSnapshot.mjs
+++ b/scripts/cityReportSnapshot.mjs
@@ -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,
}
}
diff --git a/scripts/lib/cityReportBlocks.mjs b/scripts/lib/cityReportBlocks.mjs
index ea5c2003..e2acda33 100644
--- a/scripts/lib/cityReportBlocks.mjs
+++ b/scripts/lib/cityReportBlocks.mjs
@@ -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 }),
},
]
diff --git a/scripts/lib/cityReportRender.mjs b/scripts/lib/cityReportRender.mjs
index a21f7456..f48689c3 100644
--- a/scripts/lib/cityReportRender.mjs
+++ b/scripts/lib/cityReportRender.mjs
@@ -14,6 +14,12 @@ const htmlEscape = (value) =>
.replaceAll('>', '>')
.replaceAll('"', '"')
+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) => `${url}`)
+
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) => `
${htmlEscape(row.label)}
- ${htmlEscape(row.value)}${row.hint ? `${htmlEscape(row.hint)}` : ''}${row.source ? `fonte: ${htmlEscape(sourceLabel(row.source))}` : ''}
+ ${htmlEscape(row.value)}${row.hint ? `${htmlEscape(row.hint)}` : ''}${row.source ? `fonte: ${htmlWithLinks(sourceLabel(row.source))}` : ''}
`,
)
.join('')
@@ -192,7 +198,7 @@ const renderBullets = (block) => {
const items = block.items
.map((item) => {
const source = item.source
- ? ` fonte: ${htmlEscape(sourceLabel(item.source))}`
+ ? ` fonte: ${htmlWithLinks(sourceLabel(item.source))}`
: ''
return `${htmlEscape(item.text)}${source}`
})
@@ -220,7 +226,7 @@ const renderTable = (block) => {
`${block.columns
.map(
(column) =>
- `| ${htmlEscape(row[column.key])} | `,
+ `${htmlWithLinks(row[column.key])} | `,
)
.join('')}
`,
)
@@ -291,7 +297,7 @@ const renderBlockHtml = (block) => {
throw new Error(`Bloco desconhecido no render HTML: ${block.kind}`)
}
const sources = block.sources?.length
- ? `Fontes: ${block.sources.map((source) => htmlEscape(sourceLabel(source))).join(' · ')}
`
+ ? `Fontes: ${block.sources.map((source) => htmlWithLinks(sourceLabel(source))).join(' · ')}
`
: ''
const className = block.kind === 'table' ? 'block table-block' : 'block'
return `${title}${body}${sources}
`
diff --git a/scripts/lib/cityReportResearch.mjs b/scripts/lib/cityReportResearch.mjs
index 5778971d..316ac816 100644
--- a/scripts/lib/cityReportResearch.mjs
+++ b/scripts/lib/cityReportResearch.mjs
@@ -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,
}
}
diff --git a/tests/unit/cityReportBlocks.unit.spec.ts b/tests/unit/cityReportBlocks.unit.spec.ts
index 1777c5a6..672f3294 100644
--- a/tests/unit/cityReportBlocks.unit.spec.ts
+++ b/tests/unit/cityReportBlocks.unit.spec.ts
@@ -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 = {}) => ({
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
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', () => {
diff --git a/tests/unit/cityReportRender.unit.spec.ts b/tests/unit/cityReportRender.unit.spec.ts
index 89b0a51a..0edeafec 100644
--- a/tests/unit/cityReportRender.unit.spec.ts
+++ b/tests/unit/cityReportRender.unit.spec.ts
@@ -95,6 +95,13 @@ describe('renderReportHtml', () => {
expect(html).toContain('<script>')
})
+ it('turns every URL into a clickable anchor', () => {
+ expect(html).toContain(
+ 'https://exemplo.test/prefeito',
+ )
+ 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 |')
})
diff --git a/tests/unit/cityReportResearch.unit.spec.ts b/tests/unit/cityReportResearch.unit.spec.ts
index 60a8170a..1bd8cdfd 100644
--- a/tests/unit/cityReportResearch.unit.spec.ts
+++ b/tests/unit/cityReportResearch.unit.spec.ts
@@ -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({