teqo-backup/scripts/lib/cityReportBlocks.mjs
Francisco Solla 9fae859c0d feat(relatorio-cidade): leva o 2º turno (Lula × Flávio) ao relatório
Artefato oficial por unidade (município e ZE de Salvador): TSE 2026
presidente por seção + Solla 2026 e a fatia de 2022 da análise
2º turno; cada número validado contra o baseline TSE e os totais da BA
pinados (pnpm build:second-round).

O relatório ganha a seção "2º turno no município (Lula × Flávio)" com
X₁/X₂ pela mesma fórmula do /potencial, o papel determinístico da cidade
(prioridade/mobilizacao/defesa/expansao) e o novo item de pesquisa
segundo_turno (movimento local com fonte); a seção fica fora da página 1
porque é determinística e a página 1 falha fechada por medição.
2026-10-09 04:01:20 -03:00

1824 lines
61 KiB
JavaScript
Raw 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 {
BAHIA_SECOND_ROUND_GENERATED_AT,
getBahiaSecondRoundUnit,
secondRoundAdversaryCapturePct,
secondRoundLulaShares,
secondRoundPotentialOf,
secondRoundSollaShareOfLula,
} from '../../src/lib/bahiaSecondRound.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 { secondRoundRoleGuidance, secondRoundRoleLabels } from '../../src/lib/secondRoundRole.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)}`
}
/** "+1.234" / "−208.310" — the 2º-turno deltas never lose the sign. */
const signedInteger = (value) =>
`${value > 0 ? '+' : value < 0 ? '−' : ''}${formatInteger(Math.abs(value))}`
/** "+3,2 p.p." / "−4,0 p.p." — pt-BR decimal, explicit sign. */
const signedPoints = (value) =>
`${value > 0 ? '+' : value < 0 ? '−' : ''}${Math.abs(value).toFixed(1).replace('.', ',')} p.p.`
/**
* 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)
}
/**
* Local movement of the 2º turno (web): acts, palanques and positioning of
* local leaders in the Lula × Flávio race. A missing item is the explicit gap.
*/
const buildSecondRoundResearchBlock = (research) => {
const item = researchItemById(research, 'segundo_turno')
if (!item) {
return gapCallout(
[
{
id: 'segundo_turno',
label: 'Movimento local do 2º turno',
reason: 'Não pesquisado.',
},
],
'Lacuna desta seção',
)
}
const sources = sourcesOf(item)
return {
kind: 'table',
title: '2º turno — movimento local e palanque (pesquisa)',
columns: [
{ key: 'detail', label: 'O que a fonte diz', width: 72 },
{ key: 'source', label: 'Fonte', width: 28 },
],
rows: [
{
detail: withInlineSources(item.details ?? item.answer, sources),
source: formatDateBr(item.sourceDate),
sources,
},
],
note: 'Atos, palanques e posicionamentos locais na disputa Lula × Flávio — fato publicado com fonte datada.',
sources: sources.map((url) => ({
kind: 'web',
label: 'Movimento local do 2º turno',
url,
date: item.sourceDate,
})),
}
}
/**
* 2º turno section (Lula × Flávio): deterministic numbers from the committed
* artifact (official TSE 2026 president T1 per section, aggregated to the
* campaign unit) + the local web movement. The role copy directs the visit
* (mobilize/defend); it never promises vote transfer.
*/
const buildSecondRoundSection = ({ snapshot, research }) => {
const unit = getBahiaSecondRoundUnit(snapshot.municipality.slug)
if (!unit) {
return [
gapCallout(
[
{
id: 'segundo_turno',
label: 'Artefato do 2º turno',
reason: 'Unidade fora do artefato TSE 2026 — regenere com `pnpm build:second-round`.',
},
],
'Lacunas desta seção',
),
buildSecondRoundResearchBlock(research),
].filter(Boolean)
}
const potential = secondRoundPotentialOf(unit)
const shares = secondRoundLulaShares(unit)
const capture = secondRoundAdversaryCapturePct(unit)
const sollaRatio = secondRoundSollaShareOfLula(unit)
const sources = [
sourceOfficial(
'TSE — 1º turno 2026 (presidente, por seção) e 2022; deputado federal 2026',
null,
null,
),
sourceTeqo(
'base Teqo — artefato do 2º turno (pnpm build:second-round)',
BAHIA_SECOND_ROUND_GENERATED_AT,
),
]
const rows = [
{
label: 'Lula (PT)',
y22: formatInteger(unit.lula22),
y26: formatInteger(unit.lula26),
delta: signedInteger(unit.dLula),
},
{
label: 'Adversário (Bolsonaro→Flávio)',
y22: formatInteger(unit.adversary22),
y26: formatInteger(unit.adversary26),
delta: signedInteger(unit.dAdversary),
},
{
label: 'Solla — dep. federal 1313',
y22: formatInteger(unit.solla22),
y26: formatInteger(unit.solla26),
delta: signedInteger(unit.dSolla),
},
{
label: 'Comparecimento',
y22: formatInteger(unit.comparecimento22),
y26: formatInteger(unit.comparecimento26),
delta: signedInteger(unit.comparecimento26 - unit.comparecimento22),
},
{
label: 'Abstenção',
y22: formatInteger(unit.abstencao22),
y26: formatInteger(unit.abstencao26),
delta: signedInteger(unit.abstencao26 - unit.abstencao22),
},
{
label: 'Brancos + nulos',
y22: formatInteger(unit.brancosNulos22),
y26: formatInteger(unit.brancosNulos26),
delta: signedInteger(unit.brancosNulos26 - unit.brancosNulos22),
},
]
const calloutBody = [secondRoundRoleGuidance[unit.role]]
if (capture !== null) {
calloutBody.push(
`Captura do adversário: ${formatPercent(capture / 100)} da perda de Lula em votos (pode passar de 100% quando o comparecimento cresce).`,
)
}
if (sollaRatio !== null) {
calloutBody.push(
`Solla 2026 = ${formatPercent(sollaRatio / 100)} dos votos de Lula no recorte — a base local disponível para organizar a mobilização.`,
)
}
const kpis = [
{
label: 'Lula em 2026',
value: formatPercent(shares.share26 / 100),
hint: `dos válidos · Δ ${signedPoints(shares.deltaPp)} vs 2022`,
},
{
label: 'Votos fora do 2º turno',
value: potential ? `X₂ = ${formatInteger(potential.x2)}` : '—',
hint: potential
? `X₁ = ${formatInteger(potential.x1)} já no comparecimento; X₂ soma os ausentes`
: 'sem cenário para os números do recorte',
},
{
label: 'Solla 2026',
value: formatInteger(unit.solla26),
hint: `Δ ${signedInteger(unit.dSolla)} vs 2022${
sollaRatio !== null ? ` · ${formatPercent(sollaRatio / 100)} dos votos de Lula` : ''
}`,
},
]
return [
{
kind: 'callout',
tone: unit.role === 'defesa' ? 'risk' : 'decision',
title: `Papel no 2º turno: ${secondRoundRoleLabels[unit.role]}`,
body: calloutBody,
sources,
},
{ kind: 'kpis', items: kpis, sources: [sources[0]] },
{
kind: 'table',
title: 'A conta 2022 → 1º turno 2026 (presidente e Solla 1313)',
columns: [
{ key: 'label', label: 'Indicador', width: 34 },
{ key: 'y22', label: '2022', numeric: true, width: 22 },
{ key: 'y26', label: '2026', numeric: true, width: 22 },
{ key: 'delta', label: 'Δ', numeric: true, width: 22 },
],
rows,
note:
unit.dLula < 0
? `Captura do adversário = ΔFlávio ÷ |ΔLula| = ${
capture === null ? '—' : formatPercent(capture / 100)
}; pode passar de 100% porque a perda de Lula não é a única fonte do ganho (comparecimento e terceira via também mudam).`
: `Lula não perdeu votos no recorte (Δ ${signedInteger(unit.dLula)}).`,
sources,
},
{
kind: 'prose',
text: 'X₁ = votos já dentro do comparecimento de 2026 que não foram de Lula nem de Flávio (nulos, brancos e terceira via); X₂ = X₁ + ausentes. São votos em jogo, não transferência garantida: o cenário de 2º turno é hipotético. Persuasão em eleição geral tem efeito médio próximo de zero — o papel de Solla é mobilizar, proteger e dar voz, com contato na última semana.',
sources,
},
buildSecondRoundResearchBlock(research),
].filter(Boolean)
}
/**
* Page-1 one-liner of the 2º turno was intentionally left out: page 1 is a
* measured, fail-closed surface that already sits at the budget for the
* heaviest cities, and deterministic content cannot be shrunk by the text
* fallback. The 2º turno lives in its own section (right after the electoral
* account), with the full artifact numbers.
*/
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: 'segundo-turno',
title: '2º turno no município (Lula × Flávio)',
blocks: buildSecondRoundSection({ snapshot, research }),
},
{
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,
}
}