mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
- Frente de oposição: lista de pesquisa 'opposition' (fato publicado com URL+data, sem inferir culpa) renderizada na seção 2, com nota de guardrail e URLs na seção de fontes. - Dobradinhas: lista 'alliances' (com quem Solla soma na cidade) como bloco na seção de rede, insumo direto da abordagem. - Investimentos: lista 'investments' (Solla, estado e federal, município e região; empenho ≠ pagamento) como bloco na conjuntura. - Seções dinâmicas: numeração por posição; Sinais recentes e Demandas e visitas omitidas quando vazias (lacuna explícita só onde é produto). Skill, changelog e testes unit cobrindo validador e builder.
1314 lines
43 KiB
JavaScript
1314 lines
43 KiB
JavaScript
/**
|
||
* 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; sections 1–10 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 {
|
||
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'
|
||
|
||
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
|
||
|
||
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,
|
||
})
|
||
|
||
/**
|
||
* Página 1 = resumo de uma olhada: a fonte do item entra compacta (pesquisa web
|
||
* + data). A URL completa de cada item é preservada em "Fontes e limites"
|
||
* (seção 10) — imprimir a URL por linha estoura a página 1 com a pesquisa real
|
||
* (ver guarda de overflow do builder).
|
||
*/
|
||
const researchAnswerItem = (research, id) => {
|
||
const item = researchItemById(research, id)
|
||
if (!item) return null
|
||
return {
|
||
text: item.answer,
|
||
source: sourceWeb(null, null, item.sourceDate),
|
||
}
|
||
}
|
||
|
||
const gapCallout = (gaps, title = 'Lacunas desta seção') => {
|
||
if (!gaps.length) return null
|
||
const shown = gaps.slice(0, GAP_CALLOUT_LIMIT)
|
||
const remaining = gaps.length - shown.length
|
||
return {
|
||
kind: 'callout',
|
||
tone: 'gap',
|
||
title,
|
||
body: [
|
||
GAP_COPY,
|
||
...shown.map((gap) => `${gap.label ? `${gap.label}: ` : ''}${gap.reason}`),
|
||
...(remaining > 0 ? [`e mais ${remaining} ponto(s) 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 ? advisors.map((advisor) => advisor.name).join(', ') : '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) => {
|
||
const rows = []
|
||
const simpleAnswers = [
|
||
{ id: 'prefeito', label: 'Prefeito(a)', item: prefeito },
|
||
{ id: 'vice', label: 'Vice', item: researchAnswerItem(research, 'vice') },
|
||
{
|
||
id: 'relacao_campo',
|
||
label: 'Relação com o campo',
|
||
item: researchAnswerItem(research, 'relacao_campo'),
|
||
},
|
||
]
|
||
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
|
||
? conjuncture.stateDeputies.map((deputy) => deputy.name).join(', ')
|
||
: null
|
||
rows.push({ label: 'Dobradinhas', value: dobradinhas ?? 'sem registro na base' })
|
||
const vereadores = researchAnswerItem(research, 'vereadores')
|
||
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 buildAnnouncePair = (research) => {
|
||
const relacao = researchAnswerItem(research, 'relacao_campo')
|
||
const items = []
|
||
if (relacao) {
|
||
items.push({ text: `Relação local confirmada: ${relacao.text}`, source: relacao.source })
|
||
}
|
||
items.push(
|
||
{ text: 'Pauta ou entrega com fonte e data ao lado da frase.' },
|
||
{ text: 'Agradecimento institucional com papel confirmado.' },
|
||
{ text: 'Compromisso novo com escopo que a campanha cumpre.' },
|
||
)
|
||
return {
|
||
kind: 'pair',
|
||
left: { title: 'O que anunciar agora', tone: 'decision', items },
|
||
right: {
|
||
title: 'O que NÃO anunciar',
|
||
tone: 'risk',
|
||
items: [
|
||
{ text: 'Empenho tratado como pagamento (defeso).' },
|
||
{ text: 'Nada sem fonte ("sem fonte, não publica").' },
|
||
{ text: 'Dado staff-only fora da coordenação.' },
|
||
{ text: 'PII além do necessário.' },
|
||
],
|
||
},
|
||
note: ADVERTENCIA_DEFESO,
|
||
}
|
||
}
|
||
|
||
const buildRiskCards = ({ leaderships, advisors, conjuncture }, research) => {
|
||
const opposition =
|
||
researchAnswerItem(research, 'disputa_local') ?? researchAnswerItem(research, 'quem_investe')
|
||
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 ? conjuncture.risks.join(' · ') : '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) => {
|
||
if (emendas?.status === 'ok') return null
|
||
const evidence = researchAnswerItem(research, 'emendas_web')
|
||
if (!evidence) return null
|
||
return {
|
||
kind: 'callout',
|
||
tone: 'decision',
|
||
title: 'Emendas — indícios web (sem atribuição oficial ao município)',
|
||
body: [evidence.text, 'Não somar região/polo como emenda da cidade; URLs nas fontes.'],
|
||
}
|
||
}
|
||
|
||
const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => {
|
||
const prefeito = researchAnswerItem(research, 'prefeito')
|
||
const gapCalloutBlock = gapCallout(
|
||
collectPageOneGaps({ electoral: snapshot.electoral, 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) }],
|
||
},
|
||
],
|
||
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),
|
||
buildAnnouncePair(research),
|
||
{
|
||
kind: 'cards',
|
||
title: 'Riscos — oposição/disputa local',
|
||
items: buildRiskCards(snapshot, research),
|
||
},
|
||
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) => ({
|
||
topic: item.topic,
|
||
detail: item.detail,
|
||
source: formatDateBr(item.sourceDate),
|
||
})),
|
||
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) => ({
|
||
name: item.name,
|
||
office: item.office,
|
||
party: item.party ?? '—',
|
||
support: item.support ?? '—',
|
||
source: formatDateBr(item.sourceDate),
|
||
})),
|
||
note: 'Nomes declarados em fontes datadas; o histórico de votos vem das tabelas acima quando o nome aparece na base.',
|
||
sources: preCandidates.map((item) => ({
|
||
kind: 'web',
|
||
label: `${item.name} — ${item.office}`,
|
||
url: item.sourceUrl,
|
||
date: item.sourceDate,
|
||
})),
|
||
}
|
||
: {
|
||
kind: 'callout',
|
||
tone: 'gap',
|
||
title: 'Prováveis candidatos do campo do prefeito em 2026',
|
||
body: ['Nenhum pré-candidato pesquisado com fonte.', GAP_COPY],
|
||
},
|
||
)
|
||
|
||
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],
|
||
})
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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 ? row.body.slice(0, 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) => ({
|
||
topic: item.topic,
|
||
detail: item.detail,
|
||
source: formatDateBr(item.sourceDate),
|
||
})),
|
||
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) => ({
|
||
indicator: item.topic,
|
||
value: item.detail,
|
||
source: formatDateBr(item.sourceDate),
|
||
}))
|
||
|
||
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 buildSpeechesSection = ({ snapshot }) => {
|
||
const { speeches, municipality } = snapshot
|
||
if (!speeches.rows.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).`,
|
||
GAP_COPY,
|
||
],
|
||
},
|
||
]
|
||
}
|
||
const cityLabel = municipality?.name ?? 'o município'
|
||
return [
|
||
{
|
||
kind: 'table',
|
||
title: 'Acervo de falas',
|
||
columns: [
|
||
{ key: 'date', label: 'Data', width: 9 },
|
||
{ key: 'phase', label: 'Fase', width: 10 },
|
||
{ key: 'description', label: 'O que é', width: 32 },
|
||
{ key: 'mention', label: `Menção a ${cityLabel}`, width: 27 },
|
||
{ key: 'video', label: 'Vídeo', width: 11 },
|
||
{ key: 'transcript', label: 'Transcrição', width: 11 },
|
||
],
|
||
rows: speeches.rows.map((row) => ({
|
||
date: formatDateBr(row.speechAt),
|
||
phase: row.phase ?? '—',
|
||
description: row.summary ? row.summary.slice(0, SPEECH_SUMMARY_MAX) : '—',
|
||
mention: row.mentionExcerpt
|
||
? row.mentionExcerpt.slice(0, SPEECH_MENTION_MAX)
|
||
: row.mentionedMunicipalityCount
|
||
? `Marcada no acervo — nome não localizado nos trechos (a fala cita ${formatInteger(row.mentionedMunicipalityCount)} municípios).`
|
||
: 'Marcada no acervo — trecho não localizado.',
|
||
video: row.youtubeUrl
|
||
? withYoutubeTimestamp(row.youtubeUrl, youtubeStartSeconds(row))
|
||
: (row.vodPlaybackUrl ?? '—'),
|
||
transcript: row.officialTextUrl ?? '—',
|
||
})),
|
||
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 > speeches.rows.length
|
||
? `Mostrando ${speeches.rows.length} de ${speeches.totalCount} falas.`
|
||
: null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' '),
|
||
sources: [sourceTeqo('base Teqo — acervo C153/C155', snapshot.meta?.readAt)],
|
||
},
|
||
]
|
||
}
|
||
|
||
const buildApproachSection = ({ research }) => {
|
||
const approach = research.approach ?? []
|
||
if (!approach.length) {
|
||
return [
|
||
{
|
||
kind: 'callout',
|
||
tone: 'gap',
|
||
title: 'Abordagem sugerida (pesquisa)',
|
||
body: ['Nenhuma sugestão pesquisada para este município.', GAP_COPY],
|
||
},
|
||
]
|
||
}
|
||
return [
|
||
{
|
||
kind: 'table',
|
||
title: 'Abordagem sugerida (pesquisa)',
|
||
columns: [
|
||
{ key: 'topic', label: 'Frente' },
|
||
{ key: 'suggestion', label: 'Sugestão' },
|
||
{ key: 'source', label: 'Fonte' },
|
||
],
|
||
rows: approach.map((item) => ({
|
||
topic: item.persona ? `${item.persona} · ${item.topic}` : item.topic,
|
||
suggestion: item.suggestion,
|
||
source: formatDateBr(item.sourceDate),
|
||
})),
|
||
note: 'Sugestões das personas de ciência política e coordenação de campanha, ancoradas nas fontes listadas (URL por item); sem fonte, não entra.',
|
||
sources: approach.map((item) => ({
|
||
kind: 'web',
|
||
label: `${item.topic}${item.persona ? ` — ${item.persona}` : ''}`,
|
||
url: item.sourceUrl,
|
||
date: item.sourceDate,
|
||
})),
|
||
},
|
||
]
|
||
}
|
||
|
||
const buildNewsSection = ({ research }) => {
|
||
const blocks = []
|
||
if (research.news.length) {
|
||
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)],
|
||
},
|
||
]
|
||
}
|
||
|
||
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 [
|
||
{
|
||
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.',
|
||
],
|
||
},
|
||
]
|
||
}
|
||
|
||
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() }) => {
|
||
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 }),
|
||
sections,
|
||
}
|
||
}
|