teqo-backup/scripts/lib/cityReportBlocks.mjs
Francisco Solla 4b00a8778a feat(C188): relatórios e dossiês sem truncamento mecânico
Dono único de orçamento de texto em reportText.mjs (capList,
excerptDeepDive, joinWithRemainder, summaryText/summarySurfaceText,
summaryListText), reusado por C163 e C186. Contrato summary por item nos
dois arquivos de pesquisa; superfície de resumo nunca corta com "…" e
aponta o aprofundamento; toda lista capada mostra contador; builders com
2 passadas guard-gated (integral → ponteiro → die fail-closed).

Closes #1136
2026-09-17 19:59:02 -03:00

1602 lines
54 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Content model of the city report (C163). Pure: takes the snapshot (base Teqo,
* read-only), the validated research JSON and the emendas result, and returns
* the block tree both the PDF and the companion `.md` render.
*
* The order of the blocks is the product contract (intention plan, "Estrutura
* do relatório"): page 1 is the one-look summary; the numbered sections after it
* are the deep dive. "Sem fonte, não publica": every claim carries its source;
* what is missing becomes an explicit gap.
*/
import { getMunicipalityFederalBaseline } from '../../src/lib/bahiaElectionAggregates.ts'
import { formatEngagementLevelLabel } from '../../src/lib/engagementLevel.ts'
import { getMunicipalityVoteRank } from '../../src/lib/municipalityVoteRank.ts'
import { activityStatusLabels } from '../../src/lib/schemas/activity.ts'
import {
campaignDemandKindLabels,
campaignDemandStatusLabels,
} from '../../src/lib/schemas/campaignDemand.ts'
import { municipalityUpdatePolarityLabels } from '../../src/lib/schemas/municipalityUpdate.ts'
import { SPEECH_TOPICS } from '../../src/lib/speechFacets.ts'
import {
formatVoteEstimateEndpointsLabel,
voteEstimateScenarioLabels,
} from '../../src/lib/voteEstimate.ts'
import { supportStatusLabels } from '../../src/utilities/leadership/leadershipLabels.ts'
import {
municipalityPriorityLabels,
politicalTrendLabels,
territorialClassLabels,
} from '../../src/utilities/municipality/municipalityLabels.ts'
import {
formatDateBr,
formatDateTimeBr,
formatFreshness,
formatInteger,
formatMoneyCompact,
formatPercent,
formatRank,
} from './cityReportFormat.mjs'
import { researchItemById } from './cityReportResearch.mjs'
import { buildTerritoryPanorama } from './cityReportTerritory.mjs'
import {
capList,
excerptDeepDive,
moreItemsLabel,
summaryListText,
summarySurfaceText,
} from './reportText.mjs'
const REPORT_TITLE = 'Relatório de cidade — pré-viagem'
const GAP_COPY = 'Lacuna explícita — não preencher por inferência.'
const ADVERTENCIA_DEFESO =
'Ano eleitoral: empenho não é pagamento. O bloco de emendas mostra a fase (empenhada, liquidada, paga, restos).'
const GAP_CALLOUT_LIMIT = 4
const SIGNAL_EXCERPT_MAX = 180
const SPEECH_SUMMARY_MAX = 220
const SPEECH_MENTION_MAX = 220
/**
* Page-1 text budget (C188). Page 1 is a summary surface: it never cuts
* mid-sentence with "…". It prints the researcher's `summary` when present,
* else the full `answer`; when the full text does not fit the one-page guard,
* the builder re-renders with `textFallback='pointer'` and the line points to
* the deep dive (which always keeps the integral). Only the list caps below
* still apply — each one with a visible counter.
*/
const PAGE_ONE_LIST_LIMIT = 2
/**
* Character ceiling for a joined list of free text (conjuncture risks) — the
* joined `answer`s have no deep-dive copy, so a long line degrades to the
* pointer when the page does not fit, instead of blowing the guard.
*/
const PAGE_ONE_JOINED_MAX = 90
/** Joined name lists (Responsável, Dobradinhas) — one line in the strip/stat row. */
const PAGE_ONE_NAMES_MAX = 20
/**
* Page-1 emenda indications: the author/what/esfera rows shown before "e mais N".
* Three rows (not four): a city-polo with many attributed indications overflowed
* the one-page guard, and the full list lives in the deep dive anyway.
*/
const PAGE_ONE_INDICATOR_LIMIT = 3
const EMENDAS_EVIDENCE_TITLE = 'Emendas — indícios web (sem atribuição oficial ao município)'
/** Where each indication points to; região/polo are never summed as the city's own emenda. */
const EMENDA_SPHERE_LABELS = {
municipio: 'município',
regiao: 'região',
polo: 'polo',
}
const sourceTeqo = (label, date) => ({ kind: 'teqo', label, date: date ?? null })
const sourceWeb = (label, url, date) => ({
kind: 'web',
label,
url: url ?? null,
date: date ?? null,
})
const sourceOfficial = (label, url, date) => ({
kind: 'official',
label,
url: url ?? null,
date: date ?? null,
})
/**
* Inline-source tokens: a free-text field (detail/hook/suggestion/value/support)
* can carry `{{fonte}}` for the item's primary URL and `{{fonte:2}}`,
* `{{fonte:3}}`… for its `extraSources`. The renderers turn them into a
* `(fonte)` link exactly where the text cites the verified fact, so one excerpt
* can point to more than one outlet. When the text has no token, the primary
* (and any extra) URL is appended at the end — "inline com o trecho", never a
* bare column.
*/
const INLINE_SOURCE_TOKEN = /\{\{fonte(?::\d+)?\}\}/
const inlineSourceTokens = (count) =>
Array.from({ length: count }, (_, index) =>
index === 0 ? '{{fonte}}' : `{{fonte:${index + 1}}}`,
).join(' ')
const sourcesOf = (item) =>
[item?.sourceUrl, ...((item?.extraSources ?? []).map((extra) => extra?.url) ?? [])].filter(
(url) => typeof url === 'string' && url.trim() !== '',
)
const withInlineSources = (text, sources) => {
if (!text || !sources.length) return text
if (INLINE_SOURCE_TOKEN.test(text)) return text
return `${text} ${inlineSourceTokens(sources.length)}`
}
/**
* Página 1 = resumo de uma olhada: a fonte do item entra compacta (pesquisa web
* + data). O texto é o `summary` do researcher quando existe; sem ele, a
* resposta integral — e, quando a página 1 não cabe (`fallback: 'pointer'`), uma
* linha aponta o aprofundamento **sem reticências**. A resposta integral é
* sempre preservada em "Pesquisa — respostas integrais" (C188).
*/
const researchAnswerItem = (research, id, fallback = 'full') => {
const item = researchItemById(research, id)
if (!item) return null
return {
text: summarySurfaceText(item, fallback).text,
source: sourceWeb(null, null, item.sourceDate),
}
}
const gapCallout = (gaps, title = 'Lacunas desta seção') => {
if (!gaps.length) return null
const { items: shown, remaining } = capList(gaps, GAP_CALLOUT_LIMIT)
return {
kind: 'callout',
tone: 'gap',
title,
body: [
GAP_COPY,
...shown.map((gap) => `${gap.label ? `${gap.label}: ` : ''}${gap.reason}`),
...(remaining > 0
? [`${moreItemsLabel(remaining, 'ponto sem leitura', 'pontos sem leitura')}.`]
: []),
],
}
}
const buildIdentificationItems = ({ municipality, goal, conjuncture, advisors }) => [
{ label: 'Território', value: municipality.region },
{
label: 'Prioridade',
value: conjuncture ? (municipalityPriorityLabels[conjuncture.priority] ?? '—') : '—',
},
{
label: 'Classe',
value: goal ? (territorialClassLabels[goal.territorialClass.class] ?? '—') : '—',
},
{
label: 'Nível',
value: conjuncture?.engagementLevel
? formatEngagementLevelLabel(conjuncture.engagementLevel)
: 'Sem nível',
},
{
label: 'Responsável',
value: advisors.length
? summaryListText(
advisors.map((advisor) => advisor.name),
{ limit: PAGE_ONE_LIST_LIMIT, max: PAGE_ONE_NAMES_MAX },
)
: 'Sem responsável',
},
]
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, fallback) => {
const rows = []
const simpleAnswers = [
{ id: 'prefeito', label: 'Prefeito(a)', item: prefeito },
{ id: 'vice', label: 'Vice', item: researchAnswerItem(research, 'vice', fallback) },
{
id: 'relacao_campo',
label: 'Relação com o campo',
item: researchAnswerItem(research, 'relacao_campo', fallback),
},
]
for (const { label, item } of simpleAnswers) {
if (item) rows.push({ label, value: item.text, source: item.source })
}
rows.push({
label: 'Lideranças',
value: `${formatInteger(leaderships.totalCount)} na base`,
hint: advisors.length ? null : 'sem responsável vinculado',
})
const dobradinhas = conjuncture?.stateDeputies?.length
? summaryListText(
conjuncture.stateDeputies.map((deputy) => deputy.name),
{ limit: PAGE_ONE_LIST_LIMIT, max: PAGE_ONE_NAMES_MAX, fallback },
)
: null
rows.push({ label: 'Dobradinhas', value: dobradinhas ?? 'sem registro na base' })
const vereadores = researchAnswerItem(research, 'vereadores', fallback)
if (vereadores) {
rows.push({ label: 'Vereadores/dobradas', value: vereadores.text, source: vereadores.source })
}
return rows
}
const buildDeliveredKpis = ({ speeches }, research, emendas) => {
const emendasOk = emendas?.status === 'ok'
return [
{
label: 'Emendas aportadas',
value: emendasOk ? formatMoneyCompact(emendas.totals.empenhado) : 'Sem fonte oficial',
hint: emendasOk
? `Pago: ${formatMoneyCompact(emendas.totals.pago)} · ${emendas.rows.length} emenda(s)`
: emendas
? 'sem emenda atribuível ao município'
: null,
},
{
label: 'Falas no acervo',
value: `${formatInteger(speeches.totalCount)} trecho(s)`,
hint: 'Busca por município no acervo (C153/C155)',
},
{
label: 'Notícias (≤90 dias)',
value: `${formatInteger(research.news.length)} matéria(s)`,
hint: 'Pesquisa web datada',
},
]
}
const buildAnnouncePanel = (research, fallback) => {
const relacao = researchAnswerItem(research, 'relacao_campo', fallback)
const items = []
if (relacao) {
// A fonte da relação local já aparece em "Quem é quem" na mesma página.
items.push({ text: `Relação local confirmada: ${relacao.text}` })
}
// Uma linha invariante em vez de três: a página 1 é "uma olhada" e o
// conteúdo fixo repetido não pode competir com os indícios da cidade.
items.push({
text: 'Pauta/entrega, agradecimento e compromisso novo — sempre com papel confirmado, fonte e data.',
})
return {
kind: 'panel',
tone: 'decision',
title: 'O que anunciar agora',
items,
note: ADVERTENCIA_DEFESO,
}
}
const buildRiskCards = ({ leaderships, advisors, conjuncture }, research, fallback) => {
const opposition =
researchAnswerItem(research, 'disputa_local', fallback) ??
researchAnswerItem(research, 'quem_investe', fallback)
return [
{
title: 'Oposição local',
body: opposition
? `${opposition.text} (fonte: pesquisa web · ${formatDateBr(opposition.source.date)})`
: 'Sem leitura suficiente — lacuna',
},
{
title: 'Riscos na base',
body: conjuncture?.risks?.length
? summaryListText(conjuncture.risks, {
limit: PAGE_ONE_LIST_LIMIT,
max: PAGE_ONE_JOINED_MAX,
fallback,
})
: 'Nenhum risco registrado',
},
{
title: 'Rede',
body: leaderships.totalCount
? `${formatInteger(leaderships.totalCount)} liderança(s)${advisors.length ? '' : ' · sem responsável'}`
: 'Nenhuma liderança na base',
},
]
}
/**
* 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 (!electoral.rank2022) gaps.push({ id: 'rank', label: 'Rank', reason: 'Fora do artefato TSE.' })
return gaps
}
const buildEmendasEvidenceBlock = (research, emendas, fallback) => {
if (emendas?.status === 'ok') return null
const indicators = research.emendasIndicators ?? []
if (!indicators.length) {
const evidence = researchAnswerItem(research, 'emendas_web', fallback)
if (!evidence) return null
return {
kind: 'callout',
tone: 'decision',
title: EMENDAS_EVIDENCE_TITLE,
body: [evidence.text, 'Não somar região/polo como emenda da cidade; URLs nas fontes.'],
}
}
const { items: shown, remaining } = capList(indicators, PAGE_ONE_INDICATOR_LIMIT)
return {
kind: 'indicatorList',
title: EMENDAS_EVIDENCE_TITLE,
items: shown.map((indicator) => ({
author: indicator.author,
sphere: indicator.sphere,
sphereLabel: EMENDA_SPHERE_LABELS[indicator.sphere] ?? indicator.sphere,
detail: [indicator.purpose, indicator.value, indicator.year].filter(Boolean).join(' · '),
source: sourceWeb(indicator.author, indicator.sourceUrl, indicator.sourceDate),
})),
remaining,
note: 'Não somar região/polo como emenda da cidade; a atribuição oficial fica com o Portal da Transparência.',
}
}
const buildPageOne = ({ snapshot, research, emendas, generatedAt, textFallback }) => {
const prefeito = researchAnswerItem(research, 'prefeito', textFallback)
const gapCalloutBlock = gapCallout(
collectPageOneGaps({ electoral: snapshot.electoral, prefeito }),
'Pontos sem leitura',
)
return {
blocks: [
{ kind: 'strip', items: buildIdentificationItems(snapshot) },
{
kind: 'grid',
columns: 2,
cells: [
{
title: 'Conta eleitoral 2022',
blocks: [{ kind: 'kpis', items: buildElectoralKpis(snapshot) }],
},
{
title: 'Quem é quem',
blocks: [
{ kind: 'stats', rows: buildWhoRows(snapshot, research, prefeito, textFallback) },
],
},
],
sources: [
sourceTeqo(
'base Teqo — TSE 2022, expectativa de votos e lideranças',
snapshot.meta?.readAt,
),
],
},
{
kind: 'kpis',
title: 'O que Solla entregou',
items: buildDeliveredKpis(snapshot, research, emendas),
sources: [
sourceOfficial(
'Portal da Transparência — emendas',
emendas?.sourceUrl,
emendas?.consultedAt,
),
sourceTeqo('base Teqo — acervo de falas', snapshot.meta?.readAt),
sourceWeb('pesquisa web — imprensa', null, research.researchedAt),
],
},
buildEmendasEvidenceBlock(research, emendas, textFallback),
buildAnnouncePanel(research, textFallback),
{
kind: 'cards',
title: 'Riscos — oposição/disputa local',
items: buildRiskCards(snapshot, research, textFallback),
},
gapCalloutBlock,
{
kind: 'footerNote',
text: `Gerado em ${formatDateBr(generatedAt)} · snapshot do momento · fontes item a item nas seções de aprofundamento.`,
},
].filter(Boolean),
}
}
const buildElectoralSection = ({ snapshot }) => {
const { municipality, electoral } = snapshot
const years = [2014, 2018, 2022]
const rows = years.map((year) => {
const votes = electoral.series.find((point) => point.year === year)?.votes ?? null
const rank = getMunicipalityVoteRank(municipality.slug, year)
const validVotes = getMunicipalityFederalBaseline(municipality.slug).validVotesByYear[
String(year)
]
return {
year: String(year),
votes: votes === null ? '—' : formatInteger(votes),
share: rank ? formatPercent(rank.share) : '—',
rankLabel: formatRank(rank),
validVotes: validVotes ? formatInteger(validVotes) : '—',
}
})
const gap = electoral.series.length
? null
: {
id: 'serie',
label: 'Série 2014/2018/2022',
reason: 'Sem série na base para este município.',
}
return [
{
kind: 'table',
title: 'Conta eleitoral completa',
columns: [
{ key: 'year', label: 'Ano' },
{ key: 'votes', label: 'Votos', numeric: true },
{ key: 'share', label: '% do próprio voto', numeric: true },
{ key: 'rankLabel', label: 'Rank', numeric: true },
{ key: 'validVotes', label: 'Válidos do cargo', numeric: true },
],
rows,
sources: [sourceTeqo('base Teqo — artefato TSE 2014/2018/2022', snapshot.meta?.readAt)],
},
gap ? gapCallout([gap], 'Lacunas desta seção') : null,
].filter(Boolean)
}
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 buildOppositionBlock = (research) => {
const entries = research.opposition ?? []
if (!entries.length) return null
return {
kind: 'table',
title: 'Oposição — o que usar com fonte (pesquisa)',
columns: [
{ key: 'topic', label: 'Tema', width: 22 },
{ key: 'detail', label: 'O que a fonte diz', width: 62 },
{ key: 'source', label: 'Fonte', width: 16 },
],
rows: entries.map((item) => {
const sources = sourcesOf(item)
return {
topic: item.topic,
detail: withInlineSources(item.detail, sources),
source: formatDateBr(item.sourceDate),
sources,
}
}),
note: 'Fato publicado com fonte datada — não é condenação. Atribua à fonte e não infira culpa; tema nacional pode ser de mão dupla.',
sources: entries.map((item) => ({
kind: 'web',
label: `Oposição — ${item.topic}`,
url: item.sourceUrl,
date: item.sourceDate,
})),
}
}
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) => {
const sources = sourcesOf(item)
return {
name: item.name,
office: item.office,
party: item.party ?? '—',
support: withInlineSources(item.support ?? '—', sources),
source: formatDateBr(item.sourceDate),
sources,
}
}),
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],
},
)
const opposition = buildOppositionBlock(research)
if (opposition) blocks.push(opposition)
return blocks
}
const buildNetworkSection = ({ snapshot, research }) => {
const { leaderships, advisors } = snapshot
const rows = leaderships.rows.map((row) => ({
name: row.name,
support: row.supportStatus ? supportStatusLabels[row.supportStatus] : '—',
organizations: row.organizationNames.length ? row.organizationNames.join(', ') : '—',
access: row.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app',
freshness: formatFreshness(row.updatedAt),
}))
const blocks = []
if (rows.length) {
blocks.push({
kind: 'table',
title: 'Rede e lideranças',
columns: [
{ key: 'name', label: 'Liderança' },
{ key: 'support', label: 'Apoio' },
{ key: 'organizations', label: 'Organizações' },
{ key: 'access', label: 'Acesso' },
{ key: 'freshness', label: 'Frescor' },
],
rows,
note:
leaderships.totalCount > rows.length
? `Mostrando ${rows.length} de ${leaderships.totalCount} lideranças (mesmo corte do dossiê).`
: null,
sources: [sourceTeqo('base Teqo — lideranças e pledges', snapshot.meta?.readAt)],
})
} else {
blocks.push({
kind: 'callout',
tone: 'gap',
title: 'Rede e lideranças',
body: ['Nenhuma liderança vinculada a este município na base.', GAP_COPY],
})
}
blocks.push({
kind: 'stats',
title: 'Responsáveis',
rows: [
{
label: 'Assessores do município',
value: advisors.length
? advisors.map((advisor) => advisor.name).join(', ')
: 'Sem responsável',
},
{ label: 'Pledges declarados', value: formatInteger(snapshot.pledges.declaredTotal) },
{
label: 'Última movimentação de pledge',
value: snapshot.pledges.lastPledgeAt
? formatFreshness(snapshot.pledges.lastPledgeAt)
: 'sem registro',
},
],
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: 'party', label: 'Partido' },
{ key: 'period', label: 'Período' },
{ key: 'source', label: 'Fonte' },
],
rows: cityLeaders.map((leader) => ({
name: leader.name,
role: leader.role,
party: leader.party ?? '—',
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],
})
}
const leaderAgenda = buildLeaderAgendaBlock(research)
if (leaderAgenda) blocks.push(leaderAgenda)
blocks.push(
...buildSourcedListBlocks({
title: 'Dobradinhas (pesquisa)',
note: 'Com quem Solla soma na cidade em 2026 (estadual/federal, chapa e lideranças que transferem voto) — insumo direto da abordagem sugerida; cada item com URL.',
items: research?.alliances ?? [],
}),
)
return blocks
}
/**
* Pauta provável de cada liderança do mesmo campo / da rede: hipótese ancorada
* em fonte pública, NÃO um fato — a nota do bloco deixa isso explícito para
* ninguém tratar a leitura como compromisso do líder.
*/
const buildLeaderAgendaBlock = (research) => {
const entries = research?.leaderAgenda ?? []
if (!entries.length) return null
return {
kind: 'table',
title: 'Pauta das lideranças (pesquisa)',
columns: [
{ key: 'name', label: 'Líder', width: 18 },
{ key: 'field', label: 'Campo', width: 13 },
{ key: 'topics', label: 'Pauta provável', width: 22 },
{ key: 'hook', label: 'Gancho recente', width: 33 },
{ key: 'source', label: 'Fonte', width: 14 },
],
rows: entries.map((item) => {
const sources = sourcesOf(item)
return {
name: item.name,
field: item.field ?? '—',
topics: item.topics ?? '—',
hook: withInlineSources(item.hook ?? '—', sources),
source: formatDateBr(item.sourceDate),
sources,
}
}),
note: '“Pauta provável” é hipótese ancorada em fonte pública — validar na conversa; não é declaração do líder. Prioriza rede da campanha e mesmo campo.',
sources: entries.map((item) => ({
kind: 'web',
label: `${item.name} — pauta provável`,
url: item.sourceUrl,
date: item.sourceDate,
})),
}
}
const buildConjunctureSection = ({ snapshot, research }) => {
const conjuncture = snapshot.conjuncture
const trend = conjuncture?.politicalTrend
const rows = [
{
label: 'Tendência política',
value: trend?.status ? (politicalTrendLabels[trend.status] ?? 'Sem leitura') : 'Sem leitura',
hint: trend?.note ?? null,
},
{
label: 'Registrada por',
value: trend?.recordedByName ?? '—',
hint: trend?.recordedAt ? formatDateBr(trend.recordedAt) : null,
},
{
label: 'Nível de envolvimento',
value: conjuncture?.engagementLevel
? formatEngagementLevelLabel(conjuncture.engagementLevel)
: 'Sem nível',
hint: conjuncture?.levelNote ?? null,
},
{
label: 'Expectativa de votos',
value: conjuncture?.expectedVotes
? ['pessimistic', 'central', 'optimistic']
.map(
(scenario) =>
`${voteEstimateScenarioLabels[scenario]} ${formatInteger(conjuncture.expectedVotes[scenario])}`,
)
.join(' · ')
: '—',
hint: 'Cenários da base',
},
]
const blocks = [
{
kind: 'stats',
title: 'Conjuntura',
rows,
sources: [sourceTeqo('base Teqo — estratégia do município', snapshot.meta?.readAt)],
},
]
if (conjuncture?.strengths?.length) {
blocks.push({
kind: 'bullets',
title: 'Forças',
items: conjuncture.strengths.map((text) => ({ text })),
})
}
if (conjuncture?.risks?.length) {
blocks.push({
kind: 'bullets',
title: 'Riscos',
items: conjuncture.risks.map((text) => ({ text })),
})
}
if (conjuncture?.dobradinhaNotes) {
blocks.push({ kind: 'prose', title: 'Dobradinhas', text: conjuncture.dobradinhaNotes })
}
if (conjuncture?.nextSteps) {
blocks.push({ kind: 'prose', title: 'Próximos passos', text: conjuncture.nextSteps })
}
if (conjuncture?.budgetNotes) {
blocks.push({
kind: 'prose',
title: 'Emendas aportadas (nota da base)',
text: conjuncture.budgetNotes,
sources: [sourceTeqo('base Teqo — nota manual', snapshot.meta?.readAt)],
})
}
blocks.push(
...buildSourcedListBlocks({
title: 'Investimentos e obras (pesquisa)',
note: 'O que Solla, o governo do estado e o federal entregaram, anunciaram ou têm em obra no município e na região — com fase/valor quando a fonte informar; cada item com URL. Empenho ≠ pagamento.',
items: research?.investments ?? [],
}),
)
return blocks
}
const buildSignalsSection = ({ snapshot }) => {
const { signals } = snapshot
if (!signals.rows.length) {
return [
{
kind: 'callout',
tone: 'gap',
title: 'Sinais recentes',
body: ['Nenhum sinal registrado para o município na base.', GAP_COPY],
},
]
}
return [
{
kind: 'table',
title: 'Sinais recentes',
columns: [
{ key: 'date', label: 'Data' },
{ key: 'polarity', label: 'Polaridade' },
{ key: 'flags', label: 'Sinal' },
{ key: 'responsible', label: 'Responsável' },
{ key: 'excerpt', label: 'Resumo' },
],
rows: signals.rows.map((row) => ({
date: formatDateBr(row.createdAt),
polarity: municipalityUpdatePolarityLabels[row.polarity] ?? '—',
flags: [row.urgent ? 'Urgente' : null, row.adversarySignal ? 'Adversário' : null]
.filter(Boolean)
.join(' · '),
responsible: row.responsibleName ?? '—',
excerpt: row.body ? excerptDeepDive(row.body, SIGNAL_EXCERPT_MAX) : '—',
})),
note:
signals.totalCount > signals.rows.length
? `Mostrando ${signals.rows.length} de ${signals.totalCount} sinais (mesmo corte do dossiê).`
: null,
sources: [sourceTeqo('base Teqo — sinais do município', snapshot.meta?.readAt)],
},
]
}
const buildDemandsVisitsSection = ({ snapshot }) => {
const { demands, activities } = snapshot
const blocks = []
if (demands.rows.length) {
blocks.push({
kind: 'table',
title: 'Demandas',
columns: [
{ key: 'title', label: 'Demanda' },
{ key: 'kind', label: 'Tipo' },
{ key: 'status', label: 'Status' },
{ key: 'updatedAt', label: 'Última atualização' },
],
rows: demands.rows.map((row) => ({
title: row.title,
kind: campaignDemandKindLabels[row.kind] ?? row.kind,
status: campaignDemandStatusLabels[row.status] ?? row.status,
updatedAt: formatDateBr(row.updatedAt),
})),
note:
demands.totalCount > demands.rows.length
? `Mostrando ${demands.rows.length} de ${demands.totalCount} demandas.`
: null,
sources: [sourceTeqo('base Teqo — demandas', snapshot.meta?.readAt)],
})
} else {
blocks.push({
kind: 'callout',
tone: 'gap',
title: 'Demandas',
body: ['Nenhuma demanda vinculada ao município na base.', GAP_COPY],
})
}
const activityRows = [
...activities.upcoming.map((activity) => ({ ...activity, bucket: 'Agendada' })),
...activities.recent.map((activity) => ({ ...activity, bucket: 'Realizada' })),
]
if (activityRows.length) {
blocks.push({
kind: 'table',
title: 'Visitas e agenda',
columns: [
{ key: 'bucket', label: 'Situação' },
{ key: 'date', label: 'Data' },
{ key: 'title', label: 'Atividade' },
{ key: 'locality', label: 'Local' },
{ key: 'status', label: 'Status' },
],
rows: activityRows.map((activity) => ({
bucket: activity.bucket,
date: activity.startAt ? formatDateTimeBr(activity.startAt) : '—',
title: activity.title,
locality: activity.locality ?? '—',
status: activityStatusLabels[activity.status] ?? activity.status,
})),
sources: [sourceTeqo('base Teqo — atividades', snapshot.meta?.readAt)],
})
} else {
blocks.push({
kind: 'callout',
tone: 'gap',
title: 'Visitas e agenda',
body: ['Nenhuma atividade vinculada ao município na base.', GAP_COPY],
})
}
return blocks
}
const buildSourcedListBlocks = ({ title, note, items }) => {
if (!items.length) {
return [
{
kind: 'callout',
tone: 'gap',
title,
body: ['Nenhum item pesquisado com fonte para este município.', GAP_COPY],
},
]
}
return [
{
kind: 'table',
title,
columns: [
{ key: 'topic', label: 'Tema', width: 22 },
{ key: 'detail', label: 'Dado', width: 62 },
{ key: 'source', label: 'Fonte', width: 16 },
],
rows: items.map((item) => {
const sources = sourcesOf(item)
return {
topic: item.topic,
detail: withInlineSources(item.detail, sources),
source: formatDateBr(item.sourceDate),
sources,
}
}),
note,
sources: items.map((item) => ({
kind: 'web',
label: item.topic,
url: item.sourceUrl,
date: item.sourceDate,
})),
},
]
}
const buildDemographicsSection = ({ snapshot, research }) => {
const demographics = snapshot.demographics
if (!demographics) {
return [
{
kind: 'callout',
tone: 'gap',
title: 'Demografia',
body: ['Sem demografia para o código IBGE deste município.', GAP_COPY],
},
]
}
const baseRows = [
{ indicator: 'População', value: formatInteger(demographics.population) },
{ indicator: '0–17 anos', value: formatInteger(demographics.ageBands['0-17']) },
{ indicator: '18–29 anos', value: formatInteger(demographics.ageBands['18-29']) },
{ indicator: '30–59 anos', value: formatInteger(demographics.ageBands['30-59']) },
{ indicator: '60+ anos', value: formatInteger(demographics.ageBands['60+']) },
{ indicator: 'Mulheres', value: formatPercent(demographics.sexShareFemale) },
{
indicator: 'Idade mediana',
value:
demographics.medianAge === null ? '—' : `${formatInteger(demographics.medianAge)} anos`,
},
].map((row) => ({ ...row, source: 'base Teqo' }))
const researchRows = (research.demography ?? []).map((item) => {
const sources = sourcesOf(item)
return {
indicator: item.topic,
value: withInlineSources(item.detail, sources),
source: formatDateBr(item.sourceDate),
sources,
}
})
return [
{
kind: 'table',
title: 'Demografia (IBGE Censo 2022)',
columns: [
{ key: 'indicator', label: 'Indicador', width: 22 },
{ key: 'value', label: 'Dado', width: 61 },
{ key: 'source', label: 'Fonte', width: 17 },
],
rows: [...baseRows, ...researchRows],
note: '“base Teqo” = artefato IBGE Censo 2022 commitado; datas = pesquisa web datada, com URL na seção de fontes.',
sources: [
sourceTeqo('IBGE Censo 2022 (artefato commitado)', null),
...(research.demography ?? []).map((item) => ({
kind: 'web',
label: item.topic,
url: item.sourceUrl,
date: item.sourceDate,
})),
],
},
]
}
const buildEconomySection = ({ research }) =>
buildSourcedListBlocks({
title: 'Atividade econômica (pesquisa)',
note: 'PIB, emprego e principais atividades (IBGE/CAGED e imprensa local) — onde a cidade gera renda e onde depende de fora; cada item com URL.',
items: research.economy ?? [],
})
const buildTransportSection = ({ research }) =>
buildSourcedListBlocks({
title: 'Transporte e conexões (pesquisa)',
note: 'Rodovias (novas/reformadas), aeroporto mais próximo, portos e ferrovia: como a cidade se conecta à Bahia e o que está em obra/planejado — cada item com URL.',
items: research.transport ?? [],
})
/**
* YouTube link starts at the Câmara excerpt of the speech (`excerptTMs` −
* session start), calibrated by the live video: the 2018-03-13 session video
* starts ~37s after `eventStartAt`, so the excerpt at 645s plays at 608s
* (`youtube.com/live/2cX_gKkJH7Q?t=608`, checked in the browser). `speechAt` is
* the API slot clock, not when the deputy speaks.
*/
const YOUTUBE_VIDEO_OFFSET_SECONDS = 37
const withYoutubeTimestamp = (url, offsetSeconds) => {
if (!url) return null
if (!Number.isFinite(offsetSeconds) || offsetSeconds <= 0) return url
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}t=${Math.floor(offsetSeconds)}s`
}
const youtubeStartSeconds = (row) =>
Number.isFinite(row.youtubeExcerptStartSeconds)
? Math.max(0, Math.floor(row.youtubeExcerptStartSeconds) - YOUTUBE_VIDEO_OFFSET_SECONDS)
: null
const speechTopicLabel = (value) =>
SPEECH_TOPICS.find((topic) => topic.value === value)?.label ?? value
/**
* Why the row is in the table decides the "mention" text: a city row is tagged
* with the município in the acervo; a region row is tagged with another
* município of the same Território; a theme row is selected by topic and was
* NOT necessarily tagged with any of them — so it shows the theme, never a
* false "menção ao município".
*/
const speechMention = (row, variant) => {
if (row.mentionExcerpt) return excerptDeepDive(row.mentionExcerpt, SPEECH_MENTION_MAX)
const count = row.mentionedMunicipalityCount
if (variant === 'region') {
return count
? `Marcada no acervo com município da região — o nome não aparece nos trechos (a fala cita ${formatInteger(count)} municípios).`
: 'Marcada no acervo com município da região — trecho não localizado.'
}
return count
? `Marcada no acervo — nome não localizado nos trechos (a fala cita ${formatInteger(count)} municípios).`
: 'Marcada no acervo — trecho não localizado.'
}
const projectSpeechTableRow = (row, variant) => {
const base = {
date: formatDateBr(row.speechAt),
phase: row.phase ?? '—',
description: row.summary ? excerptDeepDive(row.summary, SPEECH_SUMMARY_MAX) : '—',
video: row.youtubeUrl
? withYoutubeTimestamp(row.youtubeUrl, youtubeStartSeconds(row))
: (row.vodPlaybackUrl ?? '—'),
transcript: row.officialTextUrl ?? '—',
}
if (variant === 'theme') {
const labels = (row.topics ?? []).map(speechTopicLabel).join(' · ')
return { ...base, topics: labels || '—' }
}
return { ...base, mention: speechMention(row, variant) }
}
const buildSpeechTable = ({ title, rows, variant = 'city', cityLabel, note, readAt }) => {
const middle =
variant === 'theme'
? { key: 'topics', label: 'Tema', width: 27 }
: {
key: 'mention',
label: variant === 'region' ? 'Município da região citado' : `Menção a ${cityLabel}`,
width: 27,
}
return {
kind: 'table',
title,
columns: [
{ key: 'date', label: 'Data', width: 9 },
{ key: 'phase', label: 'Fase', width: 10 },
{ key: 'description', label: 'O que é', width: 32 },
middle,
{ key: 'video', label: 'Vídeo', width: 11 },
{ key: 'transcript', label: 'Transcrição', width: 11 },
],
rows: rows.map((row) => projectSpeechTableRow(row, variant)),
note,
sources: [sourceTeqo('base Teqo — acervo C153/C155', readAt)],
}
}
const buildSpeechesSection = ({ snapshot }) => {
const { speeches, municipality } = snapshot
const cityLabel = municipality?.name ?? 'o município'
const cityRows = speeches.rows ?? []
const region = speeches.region ?? { label: null, rows: [] }
const topics = speeches.topics ?? { themes: [], rows: [] }
const regionRows = region.rows ?? []
const themeRows = topics.rows ?? []
if (!cityRows.length && !regionRows.length && !themeRows.length) {
return [
{
kind: 'callout',
tone: 'gap',
title: 'Acervo de falas',
body: [
`Busca por município no acervo: nenhum trecho encontrado (${formatInteger(speeches.totalCount)} no total) e nenhum recorte regional/temático.`,
GAP_COPY,
],
},
]
}
const blocks = []
if (cityRows.length) {
blocks.push(
buildSpeechTable({
title: 'Acervo de falas',
rows: cityRows,
variant: 'city',
cityLabel,
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; “Vídeo” e “Transcrição” = links oficiais da Câmara quando existem.',
speeches.totalCount > cityRows.length
? `Mostrando ${cityRows.length} de ${speeches.totalCount} falas.`
: null,
]
.filter(Boolean)
.join(' '),
readAt: snapshot.meta?.readAt,
}),
)
} else {
blocks.push({
kind: 'callout',
tone: 'info',
title: 'Acervo de falas',
body: [
`Nenhuma fala cita ${cityLabel} diretamente; abaixo vão o recorte da região e os temas do mandato (não somar região/polo como fala da cidade).`,
],
})
}
if (regionRows.length) {
blocks.push(
buildSpeechTable({
title: `Acervo — menções à região (${region.label ?? 'Território de Identidade'})`,
rows: regionRows,
variant: 'region',
note: `Falas marcadas no acervo com municípios do mesmo Território de Identidade (${region.label ?? 'região'}) — a região NÃO é a cidade; use como contexto, nunca some à conta do município.`,
readAt: snapshot.meta?.readAt,
}),
)
}
if (themeRows.length) {
blocks.push(
buildSpeechTable({
title: 'Acervo — falas por tema regional',
rows: themeRows,
variant: 'theme',
note: `Falas do mandato **selecionadas por tema** (${(topics.themes ?? [])
.map(speechTopicLabel)
.join(
', ',
)}) — o critério é o tema, não uma menção ao município ou à região; use como repertório do tema, sem somar à conta da cidade.`,
readAt: snapshot.meta?.readAt,
}),
)
}
return blocks
}
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) => {
const sources = sourcesOf(item)
return {
topic: item.persona ? `${item.persona} · ${item.topic}` : item.topic,
suggestion: withInlineSources(item.suggestion, sources),
source: formatDateBr(item.sourceDate),
sources,
}
}),
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) {
blocks.push({
kind: 'table',
title: 'Notícias internas e imprensa local',
// Título é o texto mais longo: larguras explícitas tornam a tabela
// `table-fixed` (URL quebra em vez de empurrar a coluna) e dão a ela a
// maior fatia — sem largura, a URL sem espaços infla a coluna Link.
columns: [
{ key: 'date', label: 'Data', width: 9 },
{ key: 'outlet', label: 'Veículo', width: 14 },
{ key: 'title', label: 'Título', width: 49 },
{ key: 'link', label: 'Link', width: 28 },
],
rows: research.news.map((item) => ({
date: formatDateBr(item.publishedAt),
outlet: item.outlet ?? '—',
title: item.title,
link: item.url,
})),
note: 'Janela de 90 dias; cada item com URL.',
sources: [sourceWeb('pesquisa web datada', null, research.researchedAt)],
})
} else {
blocks.push({
kind: 'callout',
tone: 'gap',
title: 'Notícias internas e imprensa local',
body: [
'Nenhuma matéria com fonte e data dentro da janela de 90 dias.',
'Nenhum post interno relacionado ao município (a base não liga Post a município).',
GAP_COPY,
],
})
}
const newsGaps = research.gaps.filter((gap) => gap.id.startsWith('noticia_'))
const callout = gapCallout(newsGaps, 'Notícias descartadas')
if (callout) blocks.push(callout)
return blocks
}
const buildRegionSection = ({ snapshot }) => {
const panorama = buildTerritoryPanorama(snapshot.municipality.slug)
if (!panorama) {
return [
{
kind: 'callout',
tone: 'gap',
title: 'Panorama regional',
body: ['Município fora do catálogo/artefato TSE.', GAP_COPY],
},
]
}
return [
{
kind: 'stats',
title: 'Panorama regional (Território de Identidade)',
rows: [
{ label: 'Território', value: panorama.region },
{ label: 'Municípios no TI', value: formatInteger(panorama.municipalityCount) },
{ label: `Votos do TI em ${panorama.year}`, value: formatInteger(panorama.regionVotes) },
{
label: '% do voto do candidato no estado',
value: formatPercent(panorama.regionShare),
},
{
label: 'Posição do município no TI',
value: `${formatInteger(panorama.municipalityRank)}º de ${formatInteger(panorama.municipalityTotalUnits)}`,
},
{
label: 'Votos do município no TI',
value: formatInteger(panorama.municipalityVotes),
},
],
sources: [sourceTeqo('artefato TSE commitado — leitura relativa', null)],
},
]
}
/**
* Page 1 never cuts text (C188): it prints the item's `summary` or the full
* `answer`, degrading to a pointer when the page does not fit. This table keeps
* the full text of every checklist item, so the pointer always has a target.
*/
const buildResearchAnswersTable = (research) =>
research.items.length
? {
kind: 'table',
title: 'Pesquisa — respostas integrais (texto completo)',
columns: [
{ key: 'item', label: 'Item', width: 22 },
{ key: 'answer', label: 'Resposta (texto integral)', width: 62 },
{ key: 'date', label: 'Data', width: 16 },
],
rows: research.items.map((item) => {
const sources = sourcesOf(item)
return {
item: item.label,
answer: withInlineSources(item.answer, sources),
date: formatDateBr(item.sourceDate),
sources,
}
}),
note: 'A página 1 imprime o resumo (ou o texto integral) e, quando não cabe, aponta para cá; o texto integral de cada item da pesquisa fica nesta tabela.',
}
: null
const buildSourcesSection = ({ snapshot, research, emendas }) => {
const items = []
items.push({
kind: 'teqo',
label: `Base Teqo — leitura read-only em ${formatDateTimeBr(snapshot.meta?.readAt)} (${snapshot.meta?.database ?? 'produção'})`,
date: snapshot.meta?.readAt,
})
for (const item of research.items) {
items.push({
kind: 'web',
label: item.label,
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({
kind: 'official',
label: `Portal da Transparência — emendas de ${emendas.authorName ?? 'autor'} (${emendas.rows.length} registro(s))`,
url: emendas.sourceUrl,
date: emendas.consultedAt,
})
} else if (emendas) {
items.push({
kind: 'official',
label: `Portal da Transparência — sem resultado utilizável (${emendas.reason})`,
url: emendas.sourceUrl,
date: emendas.consultedAt,
})
}
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,
})
}
const listLabels = {
demography: 'Demografia',
economy: 'Economia',
transport: 'Transporte',
opposition: 'Oposição',
alliances: 'Dobradinhas',
investments: 'Investimentos e obras',
}
for (const listKey of Object.keys(listLabels)) {
for (const item of research[listKey] ?? []) {
items.push({
kind: 'web',
label: `${listLabels[listKey]} — ${item.topic}`,
url: item.sourceUrl,
date: item.sourceDate,
})
}
}
return [
buildResearchAnswersTable(research),
{
kind: 'sources',
title: 'Fontes e limites',
items,
limits: [
`Base Teqo: leitura read-only em ${formatDateTimeBr(snapshot.meta?.readAt)} — o PDF é datado e o dado envelhece (snapshot do momento).`,
'Web: cada item carrega data + URL; o que não veio com fonte virou lacuna.',
'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.',
'Oposição: cada linha é fato publicado com URL + data; o PDF não afirma culpa nem orienta ataque pessoal — use como contraste ancorado.',
],
},
].filter(Boolean)
}
const SNAPSHOT_REQUIRED_GROUPS = [
'electoral',
'pledges',
'leaderships',
'advisors',
'signals',
'activities',
'speeches',
'demands',
]
const assertSnapshotShape = (snapshot) => {
if (!snapshot?.municipality?.slug || !snapshot?.municipality?.name) {
throw new Error('Snapshot inválido: falta municipality.slug/name — regenere com o extrator.')
}
for (const group of SNAPSHOT_REQUIRED_GROUPS) {
if (!(group in snapshot)) {
throw new Error(
`Snapshot inválido: falta "${group}" — extraia de novo com o mesmo SHA do builder.`,
)
}
}
if (!Array.isArray(snapshot.electoral?.series)) {
throw new Error('Snapshot inválido: electoral.series ausente — extraia de novo.')
}
}
/**
* Sections whose only content would be an empty-state callout are omitted
* (sinais/demandas) so the report does not spend a page saying "nothing here".
* Research sections keep their explicit gap callout — a missing source is a
* product lacuna, not noise.
*/
const hasSectionData = (id, snapshot) => {
if (id === 'sinais') return (snapshot.signals?.rows?.length ?? 0) > 0
if (id === 'demandas') {
return (
(snapshot.demands?.rows?.length ?? 0) > 0 ||
(snapshot.activities?.upcoming?.length ?? 0) + (snapshot.activities?.recent?.length ?? 0) > 0
)
}
return true
}
export const buildCityReport = ({
snapshot,
research,
emendas,
generatedAt = new Date(),
textFallback = 'full',
}) => {
assertSnapshotShape(snapshot)
const generatedAtIso = generatedAt instanceof Date ? generatedAt.toISOString() : generatedAt
const sections = [
{
id: 'conta-eleitoral',
title: 'Conta eleitoral completa (2014/2018/2022)',
blocks: buildElectoralSection({ snapshot }),
},
{
id: 'concorrentes',
title: 'Concorrentes no município (federal e estadual)',
blocks: buildCompetitorsSection({ snapshot, research }),
},
{
id: 'rede',
title: 'Rede e lideranças',
blocks: buildNetworkSection({ snapshot, research }),
},
{
id: 'conjuntura',
title: 'Conjuntura',
blocks: buildConjunctureSection({ snapshot, research }),
},
{ id: 'sinais', title: 'Sinais recentes', blocks: buildSignalsSection({ snapshot }) },
{
id: 'demandas',
title: 'Demandas e visitas',
blocks: buildDemandsVisitsSection({ snapshot }),
},
{
id: 'demografia',
title: 'Demografia',
blocks: buildDemographicsSection({ snapshot, research }),
},
{
id: 'economia',
title: 'Atividade econômica (pesquisa)',
blocks: buildEconomySection({ research }),
},
{
id: 'transporte',
title: 'Transporte e conexões (pesquisa)',
blocks: buildTransportSection({ research }),
},
{ id: 'falas', title: 'Acervo de falas', blocks: buildSpeechesSection({ snapshot }) },
{
id: 'noticias',
title: 'Notícias internas e imprensa local',
blocks: buildNewsSection({ research }),
},
{
id: 'regiao',
title: 'Panorama regional (Território de Identidade)',
blocks: buildRegionSection({ snapshot }),
},
{
id: 'abordagem',
title: 'Abordagem sugerida (pesquisa)',
blocks: buildApproachSection({ research }),
},
{
id: 'fontes',
title: 'Fontes e limites',
blocks: buildSourcesSection({ snapshot, research, emendas }),
},
]
.filter((section) => hasSectionData(section.id, snapshot))
.map((section, index) => ({ ...section, title: `${index + 1}. ${section.title}` }))
return {
meta: {
title: REPORT_TITLE,
municipalityName: snapshot.municipality.name,
municipalitySlug: snapshot.municipality.slug,
region: snapshot.municipality.region,
generatedAt: generatedAtIso,
generatedAtLabel: formatDateBr(generatedAtIso),
readAt: snapshot.meta?.readAt ?? null,
readAtLabel: formatDateTimeBr(snapshot.meta?.readAt),
database: snapshot.meta?.database ?? null,
codeSha: snapshot.meta?.codeSha ?? null,
actorRole: snapshot.meta?.actorRole ?? null,
researchedAt: research.researchedAt,
},
page1: buildPageOne({ snapshot, research, emendas, generatedAt: generatedAtIso, textFallback }),
sections,
}
}