teqo-backup/scripts/lib/cityReportBlocks.mjs
Francisco Solla 321ac7b872
Some checks failed
Deploy / preflight (push) Has been cancelled
Deploy / verify (push) Has been cancelled
Deploy / deploy staging (teqo-staging) (push) Has been cancelled
Deploy / requeue when main advanced (push) Has been cancelled
Deploy / deploy production (teqo-1313) (push) Has been cancelled
feat(relatorio-cidade): foca o relatório na mobilização do 2º turno
A federal foi decidida em 04/10; o documento de cidade passa a servir
inteiro à eleição de Lula. Saem conta eleitoral/cadeira de Solla,
concorrentes e pré-candidatos, emendas e entregas (sem fetch do Portal
da Transparência no builder), acervo de falas, panorama regional e
sinais/demandas; demografia, economia e transporte ficam como contexto
da visita.

A página 1 vira só mobilização: KPIs oficiais do artefato TSE 2026
(Lula/Δ, X₁/X₂, Solla), papel determinístico da cidade, quem mobiliza,
movimento local do 2º turno, riscos e o painel "O que pedir e falar"
(pedido Lula 13; guardrail de votos em jogo no lugar do defeso).

O checklist cai de 10 para 8 itens (emendas_web e quem_investe saem;
relacao_campo/disputa_local reancorados no 2º turno); listas
preCandidates/investments/emendasIndicators saem do contrato (arquivos
antigos seguem aceitos em replay). O snapshot enxuga para rede +
conjuntura + demografia (cityReportTerritory.mjs removido).

Prova: unit 5.232 verdes, int do snapshot sem PII e sem os grupos
aposentados, lint/format/typecheck/knip limpos e rebuild da fixture de
Cruz das Almas (9 seções, 16 páginas contra 21, página 1 fechando sem
fallback).
2026-10-10 16:57:20 -03:00

1186 lines
39 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).
*
* Since the 2026-10-10 refocus the report serves the 2º turno (Lula × Flávio):
* page 1 is the mobilization summary (role of the unit, official numbers, who
* mobilizes, local movement, the ask) and the numbered sections are the deep
* dive (2º turno, network, conjuncture, demography, economy, transport, news,
* approach, sources). The mandate-era blocks of the pre-travel report (Solla's
* own electoral account and chair math, competitors, delivered emendas, Câmara
* speeches, region panorama, signals/demands) were retired; demography, economy
* and transport stay as visit context. Numbering is positional over the blocks
* actually built.
*
* "Sem fonte, não publica": every claim carries its source; what is missing
* becomes an explicit gap.
*/
import {
BAHIA_SECOND_ROUND_GENERATED_AT,
getBahiaSecondRoundUnit,
secondRoundAdversaryCapturePct,
secondRoundLulaShares,
secondRoundPotentialOf,
secondRoundSollaShareOfLula,
} from '../../src/lib/bahiaSecondRound.ts'
import { formatEngagementLevelLabel } from '../../src/lib/engagementLevel.ts'
import { secondRoundRoleGuidance, secondRoundRoleLabels } from '../../src/lib/secondRoundRole.ts'
import { supportStatusLabels } from '../../src/utilities/leadership/leadershipLabels.ts'
import {
municipalityPriorityLabels,
politicalTrendLabels,
} from '../../src/utilities/municipality/municipalityLabels.ts'
import {
formatDateBr,
formatDateTimeBr,
formatFreshness,
formatInteger,
formatPercent,
} from './cityReportFormat.mjs'
import { researchItemById } from './cityReportResearch.mjs'
import { capList, moreItemsLabel, summaryListText, summarySurfaceText } from './reportText.mjs'
const REPORT_TITLE = 'Relatório de cidade — 2º turno (Lula × Flávio)'
const GAP_COPY = 'Lacuna explícita — não preencher por inferência.'
const SECOND_ROUND_GUARDRAIL =
'X₁/X₂ 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 ~0.'
const GAP_CALLOUT_LIMIT = 4
/**
* 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
/** Joined name lists (Responsável, Dobradinhas) — one line in the strip/stat row. */
const PAGE_ONE_NAMES_MAX = 20
/**
* 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
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, conjuncture, advisors }) => [
{ label: 'Território', value: municipality.region },
{
label: 'Prioridade',
value: conjuncture ? (municipalityPriorityLabels[conjuncture.priority] ?? '—') : '—',
},
{
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',
},
]
/** The three official numbers of the unit — shared by page 1 and the section. */
const secondRoundKpiItems = (unit, potential, shares, sollaRatio) => [
{
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` : ''
}`,
},
]
/**
* Page-1 cell of the 2º turno: the official numbers plus the deterministic role
* of the unit. A unit outside the artifact degrades to an explicit gap instead
* of an empty cell.
*/
const buildSecondRoundPageBlocks = ({ snapshot }) => {
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',
),
]
}
const potential = secondRoundPotentialOf(unit)
const shares = secondRoundLulaShares(unit)
const sollaRatio = secondRoundSollaShareOfLula(unit)
return [
{
kind: 'kpis',
items: secondRoundKpiItems(unit, potential, shares, sollaRatio),
},
{
kind: 'callout',
tone: unit.role === 'defesa' ? 'risk' : 'decision',
title: `Papel no 2º turno: ${secondRoundRoleLabels[unit.role]}`,
body: [secondRoundRoleGuidance[unit.role]],
},
]
}
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
}
/**
* Local movement of the 2º turno (web): acts, palanques and positioning of
* local leaders in the Lula × Flávio race — what the source says, dated. A
* missing item is the explicit gap (page 1 and the section).
*/
const buildSecondRoundMovementBlock = (research, fallback) => {
const movement = researchAnswerItem(research, 'segundo_turno', fallback)
if (!movement) {
return gapCallout(
[
{
id: 'segundo_turno',
label: 'Movimento local do 2º turno',
reason: 'Não pesquisado.',
},
],
'Movimento local do 2º turno (pesquisa)',
)
}
return {
kind: 'callout',
tone: 'info',
title: 'Movimento local do 2º turno (pesquisa)',
body: [`${movement.text} (fonte: pesquisa web · ${formatDateBr(movement.source.date)})`],
}
}
/** The ask on the palanque: Lula 13, anchored in the confirmed local relation. */
const buildAskPanel = (research, fallback) => {
const relacao = researchAnswerItem(research, 'relacao_campo', fallback)
const items = [
{
text: 'Pedido no palanque: Lula 13 — mobilizar, proteger e dar voz; sem prometer transferência de voto.',
},
]
if (relacao) {
items.push({ text: `Relação local confirmada: ${relacao.text}` })
}
return {
kind: 'panel',
tone: 'decision',
title: 'O que pedir e falar',
items,
note: SECOND_ROUND_GUARDRAIL,
}
}
const buildRiskCards = ({ leaderships, advisors, conjuncture }, research, fallback) => {
const opposition = researchAnswerItem(research, 'disputa_local', 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: a missing prefeito is the only research hole summarized here. */
const collectPageOneGaps = ({ prefeito }) => {
const gaps = []
if (!prefeito) gaps.push({ id: 'prefeito', label: 'Prefeito(a)', reason: 'Não pesquisado.' })
return gaps
}
const buildPageOne = ({ snapshot, research, generatedAt, textFallback }) => {
const prefeito = researchAnswerItem(research, 'prefeito', textFallback)
const gapCalloutBlock = gapCallout(collectPageOneGaps({ prefeito }), 'Pontos sem leitura')
return {
blocks: [
{ kind: 'strip', items: buildIdentificationItems(snapshot) },
{
kind: 'grid',
columns: 2,
cells: [
{
title: '2º turno — Lula × Flávio',
blocks: buildSecondRoundPageBlocks({ snapshot }),
},
{
title: 'Quem mobiliza',
blocks: [
{ kind: 'stats', rows: buildWhoRows(snapshot, research, prefeito, textFallback) },
],
},
],
sources: [
sourceTeqo('lideranças, responsáveis e prioridade', snapshot.meta?.readAt),
sourceOfficial('TSE — 1º turno 2026 (presidente, por seção) e 2022', null, null),
sourceTeqo(
'artefato do 2º turno (pnpm build:second-round)',
BAHIA_SECOND_ROUND_GENERATED_AT,
),
],
},
buildSecondRoundMovementBlock(research, textFallback),
{
kind: 'cards',
title: 'Riscos — oposição/disputa local',
items: buildRiskCards(snapshot, research, textFallback),
},
buildAskPanel(research, textFallback),
gapCalloutBlock,
{
kind: 'footerNote',
text: `Gerado em ${formatDateBr(generatedAt)} · snapshot do momento · fontes item a item nas seções de aprofundamento.`,
},
].filter(Boolean),
}
}
/**
* 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',
),
buildSecondRoundMovementTable(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.`,
)
}
return [
{
kind: 'callout',
tone: unit.role === 'defesa' ? 'risk' : 'decision',
title: `Papel no 2º turno: ${secondRoundRoleLabels[unit.role]}`,
body: calloutBody,
sources,
},
{
kind: 'kpis',
items: secondRoundKpiItems(unit, potential, shares, sollaRatio),
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,
},
buildSecondRoundMovementTable(research),
].filter(Boolean)
}
/**
* The movement table of the deep dive: what the source says (with inline
* sources) + the date. The page-1 block prints the researched summary instead.
*/
const buildSecondRoundMovementTable = (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,
})),
}
}
const buildOppositionBlock = (research) => {
const entries = research.opposition ?? []
if (!entries.length) return null
return {
kind: 'table',
title: 'Frente de 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 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('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('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 (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,
},
]
const blocks = [
{
kind: 'stats',
title: 'Conjuntura',
rows,
sources: [sourceTeqo('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 })
}
const opposition = buildOppositionBlock(research)
if (opposition) blocks.push(opposition)
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 ?? [],
})
const buildNewsSection = ({ research }) => {
const blocks = []
if (research.news.length) {
blocks.push({
kind: 'table',
title: 'Notícias 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 e imprensa local',
body: ['Nenhuma matéria com fonte e data dentro da janela de 90 dias.', 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 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,
})),
},
]
}
/**
* 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 }) => {
const items = []
items.push({
kind: 'teqo',
label: `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,
})
}
}
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',
}
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.',
'2º turno: números oficiais do artefato TSE 2026 (presidente por seção, agregado à unidade); X₁/X₂ são votos em jogo, não transferência garantida — cenário hipotético.',
'Não existe voto por bairro/seção — o relatório não inventa granularidade.',
'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 = ['pledges', 'leaderships', 'advisors']
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.`,
)
}
}
}
export const buildCityReport = ({
snapshot,
research,
generatedAt = new Date(),
textFallback = 'full',
}) => {
assertSnapshotShape(snapshot)
const generatedAtIso = generatedAt instanceof Date ? generatedAt.toISOString() : generatedAt
const sections = [
{
id: 'segundo-turno',
title: '2º turno no município (Lula × Flávio)',
blocks: buildSecondRoundSection({ snapshot, research }),
},
{
id: 'rede',
title: 'Rede e lideranças',
blocks: buildNetworkSection({ snapshot, research }),
},
{
id: 'conjuntura',
title: 'Conjuntura',
blocks: buildConjunctureSection({ snapshot, research }),
},
{
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: 'noticias', title: 'Notícias e imprensa local', blocks: buildNewsSection({ research }) },
{
id: 'abordagem',
title: 'Abordagem sugerida (pesquisa)',
blocks: buildApproachSection({ research }),
},
{
id: 'fontes',
title: 'Fontes e limites',
blocks: buildSourcesSection({ snapshot, research }),
},
].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, generatedAt: generatedAtIso, textFallback }),
sections,
}
}