mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C163): concorrentes, falas descritivas, indícios de emenda, abordagem e links clicáveis no relatório (Related #1025)
This commit is contained in:
parent
85de335cc3
commit
63b60ca7a6
9 changed files with 819 additions and 92 deletions
|
|
@ -73,6 +73,9 @@ URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Págin
|
|||
"details": "opcional",
|
||||
"sourceUrl": "https://…", // obrigatório em item publicado
|
||||
"sourceDate": "2026-09-10", // obrigatório
|
||||
"extraSources": [ // opcional; cada uma exige url+date
|
||||
{ "label": "Polo regional", "url": "https://…", "date": "2026-09-01" }
|
||||
],
|
||||
"consultedAt": "2026-09-15T09:00:00.000Z" // opcional
|
||||
}
|
||||
],
|
||||
|
|
@ -83,6 +86,27 @@ URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Págin
|
|||
"url": "https://…", "summary": "…"
|
||||
}
|
||||
],
|
||||
"approach": [ // sugestões das personas; tema+texto+fonte
|
||||
{
|
||||
"persona": "Ciência política", // ou "Coordenação de campanha"
|
||||
"topic": "Saúde regional (polo)",
|
||||
"suggestion": "…",
|
||||
"sourceUrl": "https://…", "sourceDate": "2026-04-07"
|
||||
}
|
||||
],
|
||||
"preCandidates": [ // prováveis candidatos do campo do prefeito em 2026
|
||||
{
|
||||
"name": "…", "office": "Deputado federal", "party": "PSDB",
|
||||
"support": "Campo do prefeito …",
|
||||
"sourceUrl": "https://…", "sourceDate": "2026-04-18"
|
||||
}
|
||||
],
|
||||
"leaders": [ // últimos prefeitos/vices, vereadores mais votados
|
||||
{
|
||||
"name": "…", "role": "Ex-prefeito", "period": "2017–2024",
|
||||
"sourceUrl": "https://…", "sourceDate": "2024-08-14"
|
||||
}
|
||||
],
|
||||
"gaps": [{ "id": "…", "label": "…", "reason": "…" }] // lacunas que o agente já sabe
|
||||
}
|
||||
```
|
||||
|
|
@ -90,11 +114,48 @@ URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Págin
|
|||
Checklist (`id`s): `prefeito`, `vice`, `relacao_campo`, `vereadores`,
|
||||
`disputa_local`, `quem_investe`, `noticias`, `imprensa_local`, `emendas_web`.
|
||||
Item sem `sourceUrl`/`sourceDate` é convertido em lacuna pelo validador; item
|
||||
ausente também.
|
||||
ausente também. `approach`, `preCandidates` e `leaders` são listas de pesquisa:
|
||||
cada entrada sem fonte vira lacuna (`abordagem_sem_fonte`,
|
||||
`precandidato_sem_fonte`, `lideranca_sem_fonte`) e não entra no PDF.
|
||||
|
||||
Emendas: sem `--emendas`, o builder consulta o Portal da Transparência
|
||||
(`PORTAL_TRANSPARENCIA_API_KEY` no ambiente; sem chave → lacuna). O resultado
|
||||
fica cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
|
||||
**`emendas_web` — indícios de emenda (município, região ou polo):** quando a
|
||||
fonte oficial não atribui emenda ao município, pesquise artigos, falas e
|
||||
indicações de emenda para (a) o município, (b) a região/Território de
|
||||
Identidade e (c) a maior cidade próxima (polo regional — cidades pequenas usam
|
||||
serviços do polo). Registre cada indício com URL+data; use `extraSources` para
|
||||
mais de uma fonte. O PDF mostra o item como "Emendas — indícios web" na página
|
||||
1 e as URLs na seção de fontes. **Nunca somar** indício de região/polo como
|
||||
emenda da cidade.
|
||||
|
||||
**Emendas oficiais:** sem `--emendas`, o builder consulta o Portal da
|
||||
Transparência (`PORTAL_TRANSPARENCIA_API_KEY` no ambiente; sem chave → lacuna).
|
||||
A API oficial **não filtra por município** (só UF/Nacional/Múltiplo na
|
||||
`localidadeDoGasto`); o builder casa pela localidade com o nome do município e,
|
||||
sem linha atribuível, degrada para lacuna (Issue #1025). O resultado fica
|
||||
cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
|
||||
|
||||
## Conteúdo do relatório
|
||||
|
||||
- **Página 1 (uma olhada):** identificação/prioridade/classe/nível, conta
|
||||
eleitoral 2022 (votos, % do próprio voto, rank/435) **+ expectativa de votos
|
||||
(cenário central com pessimista/otimista)** — a meta de cadeira e a cobertura
|
||||
de pledges **saíram** da página 1; quem é quem (prefeito, vice, relação com o
|
||||
campo, lideranças, dobradinhas, vereadores), o que Solla entregou (emendas
|
||||
oficiais com fase, acervo de falas, notícias ≤90 dias), **indícios web de
|
||||
emendas** quando a fonte oficial não atribui ao município, anunciar × não
|
||||
anunciar, riscos e pontos sem leitura.
|
||||
- **Seções 2+:** `1. Conta eleitoral` · `2. Concorrentes no município (federal
|
||||
e estadual)` — top 5 por votos de 2022 na base TSE, com série 2014/2018/2022
|
||||
e os prováveis candidatos do campo do prefeito (pesquisa) · `3. Rede e
|
||||
lideranças` — inclui as lideranças locais pesquisadas (ex-prefeitos/vices,
|
||||
vereadores mais votados) · `4. Conjuntura` · `5. Sinais` · `6. Demandas e
|
||||
visitas` · `7. Demografia` · `8. Acervo de falas` — cada fala com "O que é"
|
||||
(sumário oficial) e "Menção ao município" (passagem que cita a cidade ou, se
|
||||
o nome não aparece nos trechos, a marcação do acervo com o nº de municípios)
|
||||
· `9. Notícias e imprensa` · `10. Panorama regional` · `11. Abordagem
|
||||
sugerida (personas)` · `12. Fontes e limites`.
|
||||
- **Links clicáveis:** todo URL no PDF é um link (`<a href>`): células de
|
||||
tabela, fontes por linha e a seção de fontes.
|
||||
|
||||
## Guardrails de produto (não negociáveis)
|
||||
|
||||
|
|
@ -106,6 +167,12 @@ fica cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
|
|||
lideranças entram porque o produto pede "quem é quem".
|
||||
- **Completo para o candidato**: estimativas e nível N0–N4 entram no PDF, sem
|
||||
marca de restrição; o que não sai é para o palanque.
|
||||
- **Indício regional não é emenda da cidade**: emendas do município, da região
|
||||
ou do polo entram rotuladas na evidência web e **nunca somadas**; a fonte
|
||||
oficial ausente continua lacuna explícita.
|
||||
- **Abordagem é análise ancorada**: as sugestões das personas (governo do PT na
|
||||
região, projetos futuros, prioridades locais) saem do `approach` com fonte por
|
||||
item — sem fonte, não entra; o PDF deixa claro que é análise, não fato novo.
|
||||
- **Artefato gitignored**: o repo é público; o PDF/MD com dado interno nunca é
|
||||
commitado (só a skill/scripts/changelog).
|
||||
|
||||
|
|
@ -116,6 +183,9 @@ fica cacheado em `data/relatorios-cidade/<base>.emendas.json` para replay.
|
|||
- **`tsx` ausente no homeserver**: `pnpm install --prod=false`.
|
||||
- **API de emendas 429/erro**: o builder degrada para lacuna com URL+motivo;
|
||||
reexecute depois (o cache só é escrito com resultado utilizável).
|
||||
- **Emenda sem atribuição oficial**: é o comportamento esperado (a API não
|
||||
expõe o município). Pesquise os indícios web (município/região/polo) no item
|
||||
`emendas_web`; sem indício, a lacuna fica explícita — nunca zero silencioso.
|
||||
- **Chromium**: vem do `@playwright/test`; se faltar binário,
|
||||
`pnpm exec playwright install chromium`.
|
||||
- **Snapshot de outro município**: o builder recusa (`municipalitySlug` ≠
|
||||
|
|
|
|||
1
docs/changelog/2026-09-15-c163-relatorio-v2.md
Normal file
1
docs/changelog/2026-09-15-c163-relatorio-v2.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C163 — relatório de cidade v2: concorrentes, falas descritivas, indícios de emenda, abordagem e links (2026-09-15):** evolução do PDF pré-viagem pedida pelo CG. **(1) Página 1:** saem "Meta 2026 (central)" e "Cobertura de pledges" (não usados); entra **Expectativa de votos** (cenário central com pessimista/otimista de `conjuncture.expectedVotes`). **(2) Acervo de falas:** cada fala ganha "O que é" (sumário oficial da Câmara) e "Menção ao município" — a passagem que cita a cidade ou, quando o nome não aparece nos segmentos (caso de Itamaraju: 0 de 17 trechos; fala marcada com 25 municípios via gazetteer), a marcação do acervo com a contagem; o composer projeta `summary`, `mentionExcerpt` e `mentionedMunicipalityCount`. **(3) Emendas:** quando a fonte oficial não atribui emenda ao município (a API não expõe município — Issue #1025), a página 1 mostra **indícios web** para município, região ou polo (cidades pequenas usam serviços do polo), com `extraSources` por item e regra explícita de **não somar** região/polo como emenda da cidade; o gap de emendas saiu do callout "Pontos sem leitura" (fica no KPI e no bloco de indícios). **(4) Concorrentes:** nova seção "2. Concorrentes no município (federal e estadual)" — top 5 por votos de 2022 no município da base TSE (`electionCandidateVote`, via `municipalityElectionGeography`) com série 2014/2018/2022, mais "Prováveis candidatos do campo do prefeito em 2026" (pesquisa com fonte). **(5) Lideranças:** pesquisa web de ex-prefeitos/vices e vereadores mais votados na seção de rede (`leaders` com fonte por item). **(6) Abordagem:** nova seção "11. Abordagem sugerida (pesquisa)" com sugestões das personas de ciência política e coordenação (governo do PT na região, projetos futuros, prioridades locais), cada uma com URL+data (`approach`); sem fonte vira lacuna. **(7) Links:** todo URL no PDF vira âncora clicável (células de tabela, fontes por linha e seção de fontes); índice sem numeração duplicada. Validador ganhou `extraSources`, `approach`, `preCandidates` e `leaders` (entradas sem fonte viram lacuna, nunca inferência). Seções renumeradas (12 no total). Snapshot regerado em produção read-only com o composer novo. Verificação: unit 3117/3117, `tsc --noEmit` e eslint verdes; PDF de Itamaraju em 7 páginas com página 1 fechando.
|
||||
|
|
@ -10,9 +10,16 @@
|
|||
* is exercised by `tests/int/cityReportSnapshot.int.spec.ts`.
|
||||
*/
|
||||
|
||||
import { FEDERAL_DEPUTY_OFFICE, HISTORICAL_SERIES_YEARS } from '../src/lib/electionResults.ts'
|
||||
import { getMunicipalityCatalogEntry } from '../src/lib/municipalityCatalog.ts'
|
||||
import { getMunicipalityVoteRank } from '../src/lib/municipalityVoteRank.ts'
|
||||
import { normalizeForSearch } from '../src/lib/speechSearch.ts'
|
||||
import { campaignRoleLabels } from '../src/utilities/campaignUserProfile.ts'
|
||||
import { loadMunicipalityDossierData } from '../src/utilities/municipality/municipalityDossierData.ts'
|
||||
import {
|
||||
municipalityElectionGeography,
|
||||
municipalityGeographyWhere,
|
||||
} from '../src/utilities/municipality/municipalityElectionGeography.ts'
|
||||
import {
|
||||
getMunicipalityDetailViewModel,
|
||||
resolveAccessibleMunicipalityContext,
|
||||
|
|
@ -23,6 +30,10 @@ import { loadSegmentsForSpeeches } from '../src/utilities/speech/speechPageData.
|
|||
|
||||
const SPEECH_LIMIT = 10
|
||||
const DEMAND_LIMIT = 20
|
||||
const COMPETITOR_LIMIT = 5
|
||||
/** Data value of the office enum (only the federal constant is exported today). */
|
||||
const STATE_DEPUTY_OFFICE = 'deputado_estadual'
|
||||
const REFERENCE_YEAR = 2022
|
||||
|
||||
const projectActivity = (activity) => ({
|
||||
id: activity.id,
|
||||
|
|
@ -32,7 +43,14 @@ const projectActivity = (activity) => ({
|
|||
locality: activity.locality,
|
||||
})
|
||||
|
||||
const loadSpeeches = async (payload, actor, municipalityID) => {
|
||||
/**
|
||||
* Speeches mentioning the município, projected for the report: the official
|
||||
* summary says what the speech is; `mentionExcerpt` is the passage that
|
||||
* literally names the city (null when the acervo tag has no literal mention in
|
||||
* the ASR segments — e.g. a gazetteer tagging among dozens of municípios);
|
||||
* `mentionedMunicipalityCount` gives that context.
|
||||
*/
|
||||
const loadSpeeches = async (payload, actor, municipalityID, municipalityName) => {
|
||||
const result = await payload.find({
|
||||
collection: 'speech',
|
||||
where: buildSpeechListWhere({ page: 1, municipalities: [municipalityID] }),
|
||||
|
|
@ -41,9 +59,11 @@ const loadSpeeches = async (payload, actor, municipalityID) => {
|
|||
sort: '-speechAt',
|
||||
select: {
|
||||
speechAt: true,
|
||||
type: true,
|
||||
phase: true,
|
||||
summary: true,
|
||||
officialTextUrl: true,
|
||||
mentionedMunicipalities: true,
|
||||
},
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
|
|
@ -53,16 +73,25 @@ const loadSpeeches = async (payload, actor, municipalityID) => {
|
|||
actor,
|
||||
result.docs.map((doc) => doc.id),
|
||||
)
|
||||
const normalizedCity = municipalityName ? normalizeForSearch(municipalityName) : ''
|
||||
return {
|
||||
totalCount: result.totalDocs,
|
||||
rows: result.docs.map((doc) => {
|
||||
const first = (segmentsBySpeech.get(doc.id) ?? [])[0]
|
||||
const segments = segmentsBySpeech.get(doc.id) ?? []
|
||||
const mentionSegment = normalizedCity
|
||||
? segments.find((segment) => normalizeForSearch(segment.text).includes(normalizedCity))
|
||||
: undefined
|
||||
return {
|
||||
id: doc.id,
|
||||
speechAt: doc.speechAt ?? null,
|
||||
type: doc.type ?? null,
|
||||
phase: doc.phase ?? null,
|
||||
summary: doc.summary ?? null,
|
||||
officialTextUrl: doc.officialTextUrl ?? null,
|
||||
excerpt: first?.text ?? doc.summary ?? null,
|
||||
mentionExcerpt: mentionSegment?.text ?? null,
|
||||
mentionedMunicipalityCount: Array.isArray(doc.mentionedMunicipalities)
|
||||
? doc.mentionedMunicipalities.length
|
||||
: 0,
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
|
@ -91,6 +120,57 @@ const loadDemands = async (payload, actor, municipalityID) => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Top federal/state deputy candidates by votes inside the município (reference
|
||||
* year), with the 2014/2018/2022 series — the report's "main competitors"
|
||||
* table. Same TSE collection the app's comparison card reads.
|
||||
*/
|
||||
const loadCompetitors = async (payload, actor, slug, office) => {
|
||||
const entry = getMunicipalityCatalogEntry(slug)
|
||||
if (!entry) return { office, rows: [] }
|
||||
const result = await payload.find({
|
||||
collection: 'electionCandidateVote',
|
||||
where: {
|
||||
and: [
|
||||
{ year: { in: [...HISTORICAL_SERIES_YEARS] } },
|
||||
{ office: { equals: office } },
|
||||
{ turn: { equals: '1' } },
|
||||
{ voteType: { equals: 'nominal' } },
|
||||
municipalityGeographyWhere(municipalityElectionGeography(entry)),
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { year: true, candidateNumber: true, candidateName: true, party: true, votes: true },
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const byCandidate = new Map()
|
||||
for (const row of result.docs) {
|
||||
const current = byCandidate.get(row.candidateNumber) ?? {
|
||||
candidateNumber: row.candidateNumber,
|
||||
name: row.candidateName,
|
||||
party: row.party ?? null,
|
||||
votesByYear: {},
|
||||
}
|
||||
current.votesByYear[String(row.year)] =
|
||||
(current.votesByYear[String(row.year)] ?? 0) + (row.votes ?? 0)
|
||||
byCandidate.set(row.candidateNumber, current)
|
||||
}
|
||||
|
||||
const rows = [...byCandidate.values()]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(right.votesByYear[String(REFERENCE_YEAR)] ?? 0) -
|
||||
(left.votesByYear[String(REFERENCE_YEAR)] ?? 0) ||
|
||||
left.candidateNumber - right.candidateNumber,
|
||||
)
|
||||
.slice(0, COMPETITOR_LIMIT)
|
||||
return { office, year: REFERENCE_YEAR, rows }
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the snapshot. `actor` must be a real `campaignUser` doc (the CLI
|
||||
* resolves and fails closed); loaders run with `overrideAccess: false` so the
|
||||
|
|
@ -115,10 +195,12 @@ export const composeCityReportSnapshot = async ({
|
|||
const context = await resolveAccessibleMunicipalityContext(payload, actor, slug)
|
||||
const detail = await getMunicipalityDetailViewModel(payload, context, actor)
|
||||
const dossier = await loadMunicipalityDossierData(payload, actor, detail)
|
||||
const [advisors, speeches, demands] = await Promise.all([
|
||||
const [advisors, speeches, demands, federalCompetitors, stateCompetitors] = await Promise.all([
|
||||
loadAdvisorSummaries(payload, actor, detail.advisorIDs),
|
||||
loadSpeeches(payload, actor, context.id),
|
||||
loadSpeeches(payload, actor, context.id, detail.city),
|
||||
loadDemands(payload, actor, context.id),
|
||||
loadCompetitors(payload, actor, slug, FEDERAL_DEPUTY_OFFICE),
|
||||
loadCompetitors(payload, actor, slug, STATE_DEPUTY_OFFICE),
|
||||
])
|
||||
|
||||
const strategy = detail.strategy
|
||||
|
|
@ -208,6 +290,11 @@ export const composeCityReportSnapshot = async ({
|
|||
: null,
|
||||
speeches,
|
||||
demands,
|
||||
competitors: {
|
||||
referenceYear: REFERENCE_YEAR,
|
||||
federal: federalCompetitors.rows,
|
||||
state: stateCompetitors.rows,
|
||||
},
|
||||
demographics: dossier.demographics,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ import {
|
|||
campaignDemandStatusLabels,
|
||||
} from '../../src/lib/schemas/campaignDemand.ts'
|
||||
import { municipalityUpdatePolarityLabels } from '../../src/lib/schemas/municipalityUpdate.ts'
|
||||
import { voteEstimateScenarioLabels } from '../../src/lib/voteEstimate.ts'
|
||||
import {
|
||||
formatVoteEstimateEndpointsLabel,
|
||||
voteEstimateScenarioLabels,
|
||||
} from '../../src/lib/voteEstimate.ts'
|
||||
import { supportStatusLabels } from '../../src/utilities/leadership/leadershipLabels.ts'
|
||||
import {
|
||||
municipalityPriorityLabels,
|
||||
|
|
@ -47,7 +50,8 @@ const ADVERTENCIA_DEFESO =
|
|||
|
||||
const GAP_CALLOUT_LIMIT = 4
|
||||
const SIGNAL_EXCERPT_MAX = 180
|
||||
const SPEECH_EXCERPT_MAX = 220
|
||||
const SPEECH_SUMMARY_MAX = 220
|
||||
const SPEECH_MENTION_MAX = 220
|
||||
|
||||
const sourceTeqo = (label, date) => ({ kind: 'teqo', label, date: date ?? null })
|
||||
const sourceWeb = (label, url, date) => ({
|
||||
|
|
@ -94,15 +98,6 @@ const gapCallout = (gaps, title = 'Lacunas desta seção') => {
|
|||
}
|
||||
}
|
||||
|
||||
const emendasGap = (emendas) =>
|
||||
emendas?.status === 'gap'
|
||||
? {
|
||||
id: 'emendas',
|
||||
label: 'Emendas',
|
||||
reason: emendas.detail ? `${emendas.reason} (${emendas.detail})` : emendas.reason,
|
||||
}
|
||||
: null
|
||||
|
||||
const buildIdentificationItems = ({ municipality, goal, conjuncture, advisors }) => [
|
||||
{ label: 'Território', value: municipality.region },
|
||||
{
|
||||
|
|
@ -125,21 +120,26 @@ const buildIdentificationItems = ({ municipality, goal, conjuncture, advisors })
|
|||
},
|
||||
]
|
||||
|
||||
const buildElectoralKpis = ({ electoral, goal }) => [
|
||||
{
|
||||
label: 'Votos em 2022',
|
||||
value: formatInteger(electoral.rank2022?.votes ?? null),
|
||||
hint: electoral.rank2022
|
||||
? `% do próprio voto: ${formatPercent(electoral.rank2022.share)}`
|
||||
: null,
|
||||
},
|
||||
{ label: 'Rank no estado', value: formatRank(electoral.rank2022) },
|
||||
{
|
||||
label: 'Meta 2026 (central)',
|
||||
value: goal ? formatInteger(goal.suggestedGoal) : '—',
|
||||
hint: goal ? 'Cenário central' : null,
|
||||
},
|
||||
]
|
||||
const buildElectoralKpis = ({ electoral, conjuncture }) => {
|
||||
const expectedVotes = conjuncture?.expectedVotes ?? null
|
||||
return [
|
||||
{
|
||||
label: 'Votos em 2022',
|
||||
value: formatInteger(electoral.rank2022?.votes ?? null),
|
||||
hint: electoral.rank2022
|
||||
? `% do próprio voto: ${formatPercent(electoral.rank2022.share)}`
|
||||
: null,
|
||||
},
|
||||
{ label: 'Rank no estado', value: formatRank(electoral.rank2022) },
|
||||
{
|
||||
label: 'Expectativa de votos',
|
||||
value: formatInteger(expectedVotes?.central ?? null),
|
||||
hint: expectedVotes
|
||||
? (formatVoteEstimateEndpointsLabel(expectedVotes) ?? 'Cenários da base')
|
||||
: 'sem registro na base',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const buildWhoRows = ({ leaderships, advisors, conjuncture }, research, prefeito) => {
|
||||
const rows = []
|
||||
|
|
@ -231,7 +231,7 @@ const buildRiskCards = ({ leaderships, advisors, conjuncture }, research) => {
|
|||
{
|
||||
title: 'Oposição local',
|
||||
body: opposition
|
||||
? `${opposition.text} (fonte: pesquisa web · ${formatDateBr(opposition.source.date)}; URLs na seção 10)`
|
||||
? `${opposition.text} (fonte: pesquisa web · ${formatDateBr(opposition.source.date)})`
|
||||
: 'Sem leitura suficiente — lacuna',
|
||||
},
|
||||
{
|
||||
|
|
@ -247,20 +247,34 @@ const buildRiskCards = ({ leaderships, advisors, conjuncture }, research) => {
|
|||
]
|
||||
}
|
||||
|
||||
const collectPageOneGaps = ({ electoral, goal, prefeito }, emendas) => {
|
||||
/**
|
||||
* Page-1 gaps: the emendas gap is not repeated here — it is carried by the
|
||||
* "O que Solla entregou" KPI and, when the agent researched it, by the web
|
||||
* evidence block ("nunca zero silencioso" stays explicit on those surfaces).
|
||||
*/
|
||||
const collectPageOneGaps = ({ electoral, prefeito }) => {
|
||||
const gaps = []
|
||||
if (!prefeito) gaps.push({ id: 'prefeito', label: 'Prefeito(a)', reason: 'Não pesquisado.' })
|
||||
if (!goal) gaps.push({ id: 'meta', label: 'Meta', reason: 'Sem conta da cadeira na base.' })
|
||||
if (!electoral.rank2022) gaps.push({ id: 'rank', label: 'Rank', reason: 'Fora do artefato TSE.' })
|
||||
const deliveriesGap = emendasGap(emendas)
|
||||
if (deliveriesGap) gaps.push(deliveriesGap)
|
||||
return gaps
|
||||
}
|
||||
|
||||
const buildEmendasEvidenceBlock = (research, emendas) => {
|
||||
if (emendas?.status === 'ok') return null
|
||||
const evidence = researchAnswerItem(research, 'emendas_web')
|
||||
if (!evidence) return null
|
||||
return {
|
||||
kind: 'callout',
|
||||
tone: 'decision',
|
||||
title: 'Emendas — indícios web (sem atribuição oficial ao município)',
|
||||
body: [evidence.text, 'Não somar região/polo como emenda da cidade; URLs nas fontes.'],
|
||||
}
|
||||
}
|
||||
|
||||
const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => {
|
||||
const prefeito = researchAnswerItem(research, 'prefeito')
|
||||
const gapCalloutBlock = gapCallout(
|
||||
collectPageOneGaps({ electoral: snapshot.electoral, goal: snapshot.goal, prefeito }, emendas),
|
||||
collectPageOneGaps({ electoral: snapshot.electoral, prefeito }),
|
||||
'Pontos sem leitura',
|
||||
)
|
||||
|
||||
|
|
@ -273,23 +287,7 @@ const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => {
|
|||
cells: [
|
||||
{
|
||||
title: 'Conta eleitoral 2022',
|
||||
blocks: [
|
||||
{ kind: 'kpis', items: buildElectoralKpis(snapshot) },
|
||||
{
|
||||
kind: 'stats',
|
||||
rows: [
|
||||
{
|
||||
label: 'Cobertura de pledges',
|
||||
value: snapshot.goal
|
||||
? formatPercent(snapshot.goal.goalCoverage.coverageRatio)
|
||||
: '—',
|
||||
hint: snapshot.goal
|
||||
? `${formatInteger(snapshot.goal.goalCoverage.committed)} de ${formatInteger(snapshot.goal.goalCoverage.goal)}`
|
||||
: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
blocks: [{ kind: 'kpis', items: buildElectoralKpis(snapshot) }],
|
||||
},
|
||||
{
|
||||
title: 'Quem é quem',
|
||||
|
|
@ -297,7 +295,10 @@ const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => {
|
|||
},
|
||||
],
|
||||
sources: [
|
||||
sourceTeqo('base Teqo — TSE 2022, conta da cadeira e lideranças', snapshot.meta?.readAt),
|
||||
sourceTeqo(
|
||||
'base Teqo — TSE 2022, expectativa de votos e lideranças',
|
||||
snapshot.meta?.readAt,
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -314,6 +315,7 @@ const buildPageOne = ({ snapshot, research, emendas, generatedAt }) => {
|
|||
sourceWeb('pesquisa web — imprensa', null, research.researchedAt),
|
||||
],
|
||||
},
|
||||
buildEmendasEvidenceBlock(research, emendas),
|
||||
buildAnnouncePair(research),
|
||||
{
|
||||
kind: 'cards',
|
||||
|
|
@ -373,7 +375,93 @@ const buildElectoralSection = ({ snapshot }) => {
|
|||
].filter(Boolean)
|
||||
}
|
||||
|
||||
const buildNetworkSection = ({ snapshot }) => {
|
||||
const COMPETITOR_SERIES_YEARS = ['2014', '2018', '2022']
|
||||
|
||||
const buildCompetitorsTable = ({ title, rows, referenceYear, readAt }) => ({
|
||||
kind: 'table',
|
||||
title,
|
||||
columns: [
|
||||
{ key: 'name', label: 'Candidato' },
|
||||
{ key: 'party', label: 'Partido' },
|
||||
...COMPETITOR_SERIES_YEARS.map((year) => ({ key: `y${year}`, label: year, numeric: true })),
|
||||
],
|
||||
rows: rows.map((row) => ({
|
||||
name: row.name,
|
||||
party: row.party ?? '—',
|
||||
...Object.fromEntries(
|
||||
COMPETITOR_SERIES_YEARS.map((year) => [
|
||||
`y${year}`,
|
||||
year in (row.votesByYear ?? {}) ? formatInteger(row.votesByYear[year]) : '—',
|
||||
]),
|
||||
),
|
||||
})),
|
||||
note: `Top 5 por votos de ${referenceYear} no município (TSE, 1º turno, votos nominais); “—” = sem votos na série.`,
|
||||
sources: [sourceTeqo('base Teqo — TSE 2014/2018/2022', readAt)],
|
||||
})
|
||||
|
||||
const buildCompetitorsSection = ({ snapshot, research }) => {
|
||||
const competitors = snapshot.competitors ?? { referenceYear: 2022, federal: [], state: [] }
|
||||
const offices = [
|
||||
{ key: 'federal', label: 'Deputado federal' },
|
||||
{ key: 'state', label: 'Deputado estadual' },
|
||||
]
|
||||
const blocks = offices.map((office) => {
|
||||
const rows = competitors[office.key] ?? []
|
||||
return rows.length
|
||||
? buildCompetitorsTable({
|
||||
title: `Concorrentes — ${office.label}`,
|
||||
rows,
|
||||
referenceYear: competitors.referenceYear ?? 2022,
|
||||
readAt: snapshot.meta?.readAt,
|
||||
})
|
||||
: {
|
||||
kind: 'callout',
|
||||
tone: 'gap',
|
||||
title: `Concorrentes — ${office.label}`,
|
||||
body: ['Sem série de concorrentes na base para este município.', GAP_COPY],
|
||||
}
|
||||
})
|
||||
|
||||
const preCandidates = research.preCandidates ?? []
|
||||
blocks.push(
|
||||
preCandidates.length
|
||||
? {
|
||||
kind: 'table',
|
||||
title: 'Prováveis candidatos do campo do prefeito em 2026 (pesquisa)',
|
||||
columns: [
|
||||
{ key: 'name', label: 'Nome' },
|
||||
{ key: 'office', label: 'Cargo' },
|
||||
{ key: 'party', label: 'Partido' },
|
||||
{ key: 'support', label: 'Apoio' },
|
||||
{ key: 'source', label: 'Fonte' },
|
||||
],
|
||||
rows: preCandidates.map((item) => ({
|
||||
name: item.name,
|
||||
office: item.office,
|
||||
party: item.party ?? '—',
|
||||
support: item.support ?? '—',
|
||||
source: formatDateBr(item.sourceDate),
|
||||
})),
|
||||
note: 'Nomes declarados em fontes datadas; o histórico de votos vem das tabelas acima quando o nome aparece na base.',
|
||||
sources: preCandidates.map((item) => ({
|
||||
kind: 'web',
|
||||
label: `${item.name} — ${item.office}`,
|
||||
url: item.sourceUrl,
|
||||
date: item.sourceDate,
|
||||
})),
|
||||
}
|
||||
: {
|
||||
kind: 'callout',
|
||||
tone: 'gap',
|
||||
title: 'Prováveis candidatos do campo do prefeito em 2026',
|
||||
body: ['Nenhum pré-candidato pesquisado com fonte.', GAP_COPY],
|
||||
},
|
||||
)
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
const buildNetworkSection = ({ snapshot, research }) => {
|
||||
const { leaderships, advisors } = snapshot
|
||||
const rows = leaderships.rows.map((row) => ({
|
||||
name: row.name,
|
||||
|
|
@ -432,6 +520,40 @@ const buildNetworkSection = ({ snapshot }) => {
|
|||
sources: [sourceTeqo('base Teqo — pledges', snapshot.meta?.readAt)],
|
||||
})
|
||||
|
||||
const cityLeaders = research?.leaders ?? []
|
||||
if (cityLeaders.length) {
|
||||
blocks.push({
|
||||
kind: 'table',
|
||||
title: 'Lideranças da cidade (pesquisa)',
|
||||
columns: [
|
||||
{ key: 'name', label: 'Nome' },
|
||||
{ key: 'role', label: 'Papel' },
|
||||
{ key: 'period', label: 'Período' },
|
||||
{ key: 'source', label: 'Fonte' },
|
||||
],
|
||||
rows: cityLeaders.map((leader) => ({
|
||||
name: leader.name,
|
||||
role: leader.role,
|
||||
period: leader.period ?? '—',
|
||||
source: formatDateBr(leader.sourceDate),
|
||||
})),
|
||||
note: 'Últimos prefeitos/vices, vereadores mais votados e lideranças locais — pesquisa web com URL por item.',
|
||||
sources: cityLeaders.map((leader) => ({
|
||||
kind: 'web',
|
||||
label: `${leader.name} — ${leader.role}`,
|
||||
url: leader.sourceUrl,
|
||||
date: leader.sourceDate,
|
||||
})),
|
||||
})
|
||||
} else {
|
||||
blocks.push({
|
||||
kind: 'callout',
|
||||
tone: 'gap',
|
||||
title: 'Lideranças da cidade (pesquisa)',
|
||||
body: ['Nenhuma liderança local pesquisada com fonte.', GAP_COPY],
|
||||
})
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
|
|
@ -658,7 +780,7 @@ const buildDemographicsSection = ({ snapshot }) => {
|
|||
}
|
||||
|
||||
const buildSpeechesSection = ({ snapshot }) => {
|
||||
const { speeches } = snapshot
|
||||
const { speeches, municipality } = snapshot
|
||||
if (!speeches.rows.length) {
|
||||
return [
|
||||
{
|
||||
|
|
@ -672,6 +794,7 @@ const buildSpeechesSection = ({ snapshot }) => {
|
|||
},
|
||||
]
|
||||
}
|
||||
const cityLabel = municipality?.name ?? 'o município'
|
||||
return [
|
||||
{
|
||||
kind: 'table',
|
||||
|
|
@ -679,24 +802,71 @@ const buildSpeechesSection = ({ snapshot }) => {
|
|||
columns: [
|
||||
{ key: 'date', label: 'Data' },
|
||||
{ key: 'phase', label: 'Fase' },
|
||||
{ key: 'excerpt', label: 'Trecho' },
|
||||
{ key: 'description', label: 'O que é' },
|
||||
{ key: 'mention', label: `Menção a ${cityLabel}` },
|
||||
{ key: 'link', label: 'Link' },
|
||||
],
|
||||
rows: speeches.rows.map((row) => ({
|
||||
date: formatDateBr(row.speechAt),
|
||||
phase: row.phase ?? '—',
|
||||
excerpt: row.excerpt ? row.excerpt.slice(0, SPEECH_EXCERPT_MAX) : '—',
|
||||
description: row.summary ? row.summary.slice(0, SPEECH_SUMMARY_MAX) : '—',
|
||||
mention: row.mentionExcerpt
|
||||
? row.mentionExcerpt.slice(0, SPEECH_MENTION_MAX)
|
||||
: row.mentionedMunicipalityCount
|
||||
? `Marcada no acervo — nome não localizado nos trechos (a fala cita ${formatInteger(row.mentionedMunicipalityCount)} municípios).`
|
||||
: 'Marcada no acervo — trecho não localizado.',
|
||||
link: row.officialTextUrl ?? '—',
|
||||
})),
|
||||
note:
|
||||
note: [
|
||||
'“O que é” = sumário oficial da Câmara; “Menção” = passagem que cita o município ou, quando o nome não aparece nos trechos, a marcação do acervo (C153/C155) com o nº de municípios da fala.',
|
||||
speeches.totalCount > speeches.rows.length
|
||||
? `Mostrando ${speeches.rows.length} de ${speeches.totalCount} falas.`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
sources: [sourceTeqo('base Teqo — acervo C153/C155', snapshot.meta?.readAt)],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const buildApproachSection = ({ research }) => {
|
||||
const approach = research.approach ?? []
|
||||
if (!approach.length) {
|
||||
return [
|
||||
{
|
||||
kind: 'callout',
|
||||
tone: 'gap',
|
||||
title: 'Abordagem sugerida (pesquisa)',
|
||||
body: ['Nenhuma sugestão pesquisada para este município.', GAP_COPY],
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: 'table',
|
||||
title: 'Abordagem sugerida (pesquisa)',
|
||||
columns: [
|
||||
{ key: 'topic', label: 'Frente' },
|
||||
{ key: 'suggestion', label: 'Sugestão' },
|
||||
{ key: 'source', label: 'Fonte' },
|
||||
],
|
||||
rows: approach.map((item) => ({
|
||||
topic: item.persona ? `${item.persona} · ${item.topic}` : item.topic,
|
||||
suggestion: item.suggestion,
|
||||
source: formatDateBr(item.sourceDate),
|
||||
})),
|
||||
note: 'Sugestões das personas de ciência política e coordenação de campanha, ancoradas nas fontes listadas (URL por item); sem fonte, não entra.',
|
||||
sources: approach.map((item) => ({
|
||||
kind: 'web',
|
||||
label: `${item.topic}${item.persona ? ` — ${item.persona}` : ''}`,
|
||||
url: item.sourceUrl,
|
||||
date: item.sourceDate,
|
||||
})),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const buildNewsSection = ({ research }) => {
|
||||
const blocks = []
|
||||
if (research.news.length) {
|
||||
|
|
@ -789,6 +959,14 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => {
|
|||
url: item.sourceUrl,
|
||||
date: item.sourceDate,
|
||||
})
|
||||
for (const extra of item.extraSources ?? []) {
|
||||
items.push({
|
||||
kind: 'web',
|
||||
label: extra.label ?? `${item.label} — fonte adicional`,
|
||||
url: extra.url,
|
||||
date: extra.date,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (emendas?.status === 'ok') {
|
||||
items.push({
|
||||
|
|
@ -808,6 +986,14 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => {
|
|||
for (const item of research.news) {
|
||||
items.push({ kind: 'web', label: item.title, url: item.url, date: item.publishedAt })
|
||||
}
|
||||
for (const item of research.approach ?? []) {
|
||||
items.push({
|
||||
kind: 'web',
|
||||
label: `Abordagem — ${item.topic}${item.persona ? ` (${item.persona})` : ''}`,
|
||||
url: item.sourceUrl,
|
||||
date: item.sourceDate,
|
||||
})
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
|
|
@ -820,6 +1006,8 @@ const buildSourcesSection = ({ snapshot, research, emendas }) => {
|
|||
'Não existe voto por bairro/seção — o relatório não inventa granularidade.',
|
||||
'Efeito eleitoral de emenda é condicional ao crédito local — o PDF não promete voto.',
|
||||
'Emendas são lidas da fonte oficial em tempo de geração e não são persistidas na base.',
|
||||
'Quando a fonte oficial não atribui emenda ao município, indícios web (município, região ou polo) entram como evidência datada — nunca somados como emenda da cidade.',
|
||||
'Abordagem sugerida é análise das personas ancorada nas fontes listadas item a item, não fato verificado além delas.',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
@ -862,29 +1050,43 @@ export const buildCityReport = ({ snapshot, research, emendas, generatedAt = new
|
|||
title: '1. Conta eleitoral completa (2014/2018/2022)',
|
||||
blocks: buildElectoralSection({ snapshot }),
|
||||
},
|
||||
{ id: 'rede', title: '2. Rede e lideranças', blocks: buildNetworkSection({ snapshot }) },
|
||||
{ id: 'conjuntura', title: '3. Conjuntura', blocks: buildConjunctureSection({ snapshot }) },
|
||||
{ id: 'sinais', title: '4. Sinais recentes', blocks: buildSignalsSection({ snapshot }) },
|
||||
{
|
||||
id: 'concorrentes',
|
||||
title: '2. Concorrentes no município (federal e estadual)',
|
||||
blocks: buildCompetitorsSection({ snapshot, research }),
|
||||
},
|
||||
{
|
||||
id: 'rede',
|
||||
title: '3. Rede e lideranças',
|
||||
blocks: buildNetworkSection({ snapshot, research }),
|
||||
},
|
||||
{ id: 'conjuntura', title: '4. Conjuntura', blocks: buildConjunctureSection({ snapshot }) },
|
||||
{ id: 'sinais', title: '5. Sinais recentes', blocks: buildSignalsSection({ snapshot }) },
|
||||
{
|
||||
id: 'demandas',
|
||||
title: '5. Demandas e visitas',
|
||||
title: '6. Demandas e visitas',
|
||||
blocks: buildDemandsVisitsSection({ snapshot }),
|
||||
},
|
||||
{ id: 'demografia', title: '6. Demografia', blocks: buildDemographicsSection({ snapshot }) },
|
||||
{ id: 'falas', title: '7. Acervo de falas', blocks: buildSpeechesSection({ snapshot }) },
|
||||
{ id: 'demografia', title: '7. Demografia', blocks: buildDemographicsSection({ snapshot }) },
|
||||
{ id: 'falas', title: '8. Acervo de falas', blocks: buildSpeechesSection({ snapshot }) },
|
||||
{
|
||||
id: 'noticias',
|
||||
title: '8. Notícias internas e imprensa local',
|
||||
title: '9. Notícias internas e imprensa local',
|
||||
blocks: buildNewsSection({ research }),
|
||||
},
|
||||
{
|
||||
id: 'regiao',
|
||||
title: '9. Panorama regional (Território de Identidade)',
|
||||
title: '10. Panorama regional (Território de Identidade)',
|
||||
blocks: buildRegionSection({ snapshot }),
|
||||
},
|
||||
{
|
||||
id: 'abordagem',
|
||||
title: '11. Abordagem sugerida (pesquisa)',
|
||||
blocks: buildApproachSection({ research }),
|
||||
},
|
||||
{
|
||||
id: 'fontes',
|
||||
title: '10. Fontes e limites',
|
||||
title: '12. Fontes e limites',
|
||||
blocks: buildSourcesSection({ snapshot, research, emendas }),
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ const htmlEscape = (value) =>
|
|||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
|
||||
const URL_PATTERN = /(https?:\/\/[^\s<>"')]+)/g
|
||||
|
||||
/** Escapes the text and turns every bare URL into a clickable anchor (PDF links). */
|
||||
const htmlWithLinks = (value) =>
|
||||
htmlEscape(value).replace(URL_PATTERN, (url) => `<a href="${url}">${url}</a>`)
|
||||
|
||||
const sourceKindLabels = {
|
||||
teqo: 'base Teqo',
|
||||
web: 'pesquisa web',
|
||||
|
|
@ -128,7 +134,7 @@ const PRINT_CSS = `
|
|||
.table-note { color: #71717a; font-size: 7.2pt; margin: 1mm 0 0; }
|
||||
.prose p { margin: 0 0 1.4mm; }
|
||||
.page-break { break-before: page; }
|
||||
.index ol { columns: 2; margin: 0; padding-left: 5mm; color: #3f3f46; font-size: 8.6pt; }
|
||||
.index ol { columns: 2; margin: 0; padding-left: 0; list-style: none; color: #3f3f46; font-size: 8.6pt; }
|
||||
.report-section { margin-bottom: 6mm; }
|
||||
.report-section > h3 { font-size: 11pt; margin: 0 0 2mm; break-after: avoid; }
|
||||
.block + .block { margin-top: 3mm; }
|
||||
|
|
@ -181,7 +187,7 @@ const renderStats = (block) => {
|
|||
.map(
|
||||
(row) => `<div class="stat">
|
||||
<span class="stat-label">${htmlEscape(row.label)}</span>
|
||||
<span class="stat-value">${htmlEscape(row.value)}${row.hint ? `<span class="stat-hint">${htmlEscape(row.hint)}</span>` : ''}${row.source ? `<span class="row-source">fonte: ${htmlEscape(sourceLabel(row.source))}</span>` : ''}</span>
|
||||
<span class="stat-value">${htmlEscape(row.value)}${row.hint ? `<span class="stat-hint">${htmlEscape(row.hint)}</span>` : ''}${row.source ? `<span class="row-source">fonte: ${htmlWithLinks(sourceLabel(row.source))}</span>` : ''}</span>
|
||||
</div>`,
|
||||
)
|
||||
.join('')
|
||||
|
|
@ -192,7 +198,7 @@ const renderBullets = (block) => {
|
|||
const items = block.items
|
||||
.map((item) => {
|
||||
const source = item.source
|
||||
? ` <span class="row-source">fonte: ${htmlEscape(sourceLabel(item.source))}</span>`
|
||||
? ` <span class="row-source">fonte: ${htmlWithLinks(sourceLabel(item.source))}</span>`
|
||||
: ''
|
||||
return `<li>${htmlEscape(item.text)}${source}</li>`
|
||||
})
|
||||
|
|
@ -220,7 +226,7 @@ const renderTable = (block) => {
|
|||
`<tr>${block.columns
|
||||
.map(
|
||||
(column) =>
|
||||
`<td${column.numeric ? ' class="num"' : ''}>${htmlEscape(row[column.key])}</td>`,
|
||||
`<td${column.numeric ? ' class="num"' : ''}>${htmlWithLinks(row[column.key])}</td>`,
|
||||
)
|
||||
.join('')}</tr>`,
|
||||
)
|
||||
|
|
@ -291,7 +297,7 @@ const renderBlockHtml = (block) => {
|
|||
throw new Error(`Bloco desconhecido no render HTML: ${block.kind}`)
|
||||
}
|
||||
const sources = block.sources?.length
|
||||
? `<p class="source">Fontes: ${block.sources.map((source) => htmlEscape(sourceLabel(source))).join(' · ')}</p>`
|
||||
? `<p class="source">Fontes: ${block.sources.map((source) => htmlWithLinks(sourceLabel(source))).join(' · ')}</p>`
|
||||
: ''
|
||||
const className = block.kind === 'table' ? 'block table-block' : 'block'
|
||||
return `<div class="${className}">${title}${body}${sources}</div>`
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ const RESEARCH_CHECKLIST = [
|
|||
{ id: 'quem_investe', label: 'Quem mais investe na cidade (bancada e adversários)' },
|
||||
{ id: 'noticias', label: 'Notícias da cidade/região (janela ≤90 dias)' },
|
||||
{ id: 'imprensa_local', label: 'Imprensa e rádio local' },
|
||||
{ id: 'emendas_web', label: 'Emendas na web (quando a fonte oficial não responder)' },
|
||||
{
|
||||
id: 'emendas_web',
|
||||
label: 'Emendas — indícios web (município, região ou polo)',
|
||||
},
|
||||
]
|
||||
|
||||
export const RESEARCH_CHECKLIST_IDS = RESEARCH_CHECKLIST.map((item) => item.id)
|
||||
|
|
@ -87,6 +90,24 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
|
|||
})
|
||||
continue
|
||||
}
|
||||
const extraSources = []
|
||||
for (const extra of Array.isArray(entry.extraSources) ? entry.extraSources : []) {
|
||||
const extraUrl = isNonEmptyString(extra?.url) ? extra.url.trim() : null
|
||||
const extraDate = isValidDate(extra?.date) ? extra.date : null
|
||||
if (!extraUrl || !extraDate) {
|
||||
gaps.push({
|
||||
id: 'fonte_extra',
|
||||
label: checklistById.get(id).label,
|
||||
reason: 'Fonte adicional sem URL ou data — descartada.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
extraSources.push({
|
||||
label: isNonEmptyString(extra.label) ? extra.label.trim() : null,
|
||||
url: extraUrl,
|
||||
date: extraDate,
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
id,
|
||||
label: checklistById.get(id).label,
|
||||
|
|
@ -94,6 +115,7 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
|
|||
details: isNonEmptyString(entry.details) ? entry.details.trim() : null,
|
||||
sourceUrl,
|
||||
sourceDate,
|
||||
extraSources,
|
||||
consultedAt: isValidDate(entry.consultedAt) ? entry.consultedAt : null,
|
||||
})
|
||||
}
|
||||
|
|
@ -147,11 +169,111 @@ export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
|
|||
})
|
||||
}
|
||||
|
||||
const approach = []
|
||||
for (const entry of Array.isArray(raw.approach) ? raw.approach : []) {
|
||||
const topic = isNonEmptyString(entry?.topic) ? entry.topic.trim() : null
|
||||
const suggestion = isNonEmptyString(entry?.suggestion) ? entry.suggestion.trim() : null
|
||||
const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null
|
||||
const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null
|
||||
if (!topic || !suggestion) {
|
||||
gaps.push({
|
||||
id: 'abordagem_incompleta',
|
||||
label: topic,
|
||||
reason: 'Sugestão sem tema ou texto — descartada.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!sourceUrl || !sourceDate) {
|
||||
gaps.push({
|
||||
id: 'abordagem_sem_fonte',
|
||||
label: topic,
|
||||
reason: 'Sugestão sem fonte: URL e data são obrigatórias.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
approach.push({
|
||||
persona: isNonEmptyString(entry.persona) ? entry.persona.trim() : null,
|
||||
topic,
|
||||
suggestion,
|
||||
sourceUrl,
|
||||
sourceDate,
|
||||
consultedAt: isValidDate(entry.consultedAt) ? entry.consultedAt : null,
|
||||
})
|
||||
}
|
||||
|
||||
const preCandidates = []
|
||||
for (const entry of Array.isArray(raw.preCandidates) ? raw.preCandidates : []) {
|
||||
const name = isNonEmptyString(entry?.name) ? entry.name.trim() : null
|
||||
const office = isNonEmptyString(entry?.office) ? entry.office.trim() : null
|
||||
const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null
|
||||
const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null
|
||||
if (!name || !office) {
|
||||
gaps.push({
|
||||
id: 'precandidato_incompleto',
|
||||
label: name,
|
||||
reason: 'Pré-candidato sem nome ou cargo — descartado.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!sourceUrl || !sourceDate) {
|
||||
gaps.push({
|
||||
id: 'precandidato_sem_fonte',
|
||||
label: name,
|
||||
reason: 'Pré-candidato sem fonte: URL e data são obrigatórias.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
preCandidates.push({
|
||||
name,
|
||||
office,
|
||||
party: isNonEmptyString(entry.party) ? entry.party.trim() : null,
|
||||
support: isNonEmptyString(entry.support) ? entry.support.trim() : null,
|
||||
note: isNonEmptyString(entry.note) ? entry.note.trim() : null,
|
||||
sourceUrl,
|
||||
sourceDate,
|
||||
})
|
||||
}
|
||||
|
||||
const leaders = []
|
||||
for (const entry of Array.isArray(raw.leaders) ? raw.leaders : []) {
|
||||
const name = isNonEmptyString(entry?.name) ? entry.name.trim() : null
|
||||
const role = isNonEmptyString(entry?.role) ? entry.role.trim() : null
|
||||
const sourceUrl = isNonEmptyString(entry?.sourceUrl) ? entry.sourceUrl.trim() : null
|
||||
const sourceDate = isValidDate(entry?.sourceDate) ? entry.sourceDate : null
|
||||
if (!name || !role) {
|
||||
gaps.push({
|
||||
id: 'lideranca_incompleta',
|
||||
label: name,
|
||||
reason: 'Liderança sem nome ou papel — descartada.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!sourceUrl || !sourceDate) {
|
||||
gaps.push({
|
||||
id: 'lideranca_sem_fonte',
|
||||
label: name,
|
||||
reason: 'Liderança sem fonte: URL e data são obrigatórias.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
leaders.push({
|
||||
name,
|
||||
role,
|
||||
period: isNonEmptyString(entry.period) ? entry.period.trim() : null,
|
||||
note: isNonEmptyString(entry.note) ? entry.note.trim() : null,
|
||||
sourceUrl,
|
||||
sourceDate,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
municipalitySlug: raw.municipalitySlug.trim(),
|
||||
researchedAt: researchedAt.toISOString(),
|
||||
items,
|
||||
news,
|
||||
approach,
|
||||
preCandidates,
|
||||
leaders,
|
||||
gaps,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,18 +8,31 @@ import {
|
|||
|
||||
const generatedAt = new Date('2026-09-15T12:00:00.000Z')
|
||||
|
||||
const validItem = (id: string) => ({
|
||||
const validItem = (id: string, overrides: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
answer: `Resposta de ${id}`,
|
||||
sourceUrl: `https://exemplo.test/${id}`,
|
||||
sourceDate: '2026-09-10',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const research = normalizeResearchInput(
|
||||
{
|
||||
municipalitySlug: 'feira-de-santana',
|
||||
researchedAt: '2026-09-14T10:00:00.000Z',
|
||||
items: RESEARCH_CHECKLIST_IDS.map(validItem),
|
||||
items: RESEARCH_CHECKLIST_IDS.map((id) =>
|
||||
id === 'emendas_web'
|
||||
? validItem(id, {
|
||||
extraSources: [
|
||||
{
|
||||
label: 'Polo regional — Teixeira de Freitas',
|
||||
url: 'https://exemplo.test/polo',
|
||||
date: '2026-09-01',
|
||||
},
|
||||
],
|
||||
})
|
||||
: validItem(id),
|
||||
),
|
||||
news: [
|
||||
{
|
||||
title: 'Matéria local',
|
||||
|
|
@ -28,6 +41,34 @@ const research = normalizeResearchInput(
|
|||
outlet: 'Jornal Local',
|
||||
},
|
||||
],
|
||||
approach: [
|
||||
{
|
||||
persona: 'Ciência política',
|
||||
topic: 'Saúde',
|
||||
suggestion: 'Ancorar a agenda na defesa do SUS e no SAMU.',
|
||||
sourceUrl: 'https://exemplo.test/abordagem',
|
||||
sourceDate: '2026-09-10',
|
||||
},
|
||||
],
|
||||
preCandidates: [
|
||||
{
|
||||
name: 'Deputado do Prefeito',
|
||||
office: 'Deputado federal',
|
||||
party: 'PSDB',
|
||||
support: 'Prefeito Jorge Almeida',
|
||||
sourceUrl: 'https://exemplo.test/pre-candidato',
|
||||
sourceDate: '2026-04-18',
|
||||
},
|
||||
],
|
||||
leaders: [
|
||||
{
|
||||
name: 'Ex-Prefeito',
|
||||
role: 'Prefeito',
|
||||
period: '2017–2024',
|
||||
sourceUrl: 'https://exemplo.test/lideranca',
|
||||
sourceDate: '2026-03-05',
|
||||
},
|
||||
],
|
||||
gaps: [],
|
||||
},
|
||||
{ now: generatedAt },
|
||||
|
|
@ -182,8 +223,26 @@ const snapshot = {
|
|||
budgetNotes: 'Emenda de custeio aportada em 2025',
|
||||
stateDeputies: [{ id: 5, name: 'Deputada Estadual', party: 'PT' }],
|
||||
},
|
||||
competitors: {
|
||||
referenceYear: 2022,
|
||||
federal: [
|
||||
{
|
||||
candidateNumber: 1313,
|
||||
name: 'Solla',
|
||||
party: 'PT',
|
||||
votesByYear: { '2018': 40_000, '2022': 50_000 },
|
||||
},
|
||||
{
|
||||
candidateNumber: 4545,
|
||||
name: 'Concorrente A',
|
||||
party: 'PSDB',
|
||||
votesByYear: { '2018': 20_000, '2022': 30_000 },
|
||||
},
|
||||
],
|
||||
state: [],
|
||||
},
|
||||
speeches: {
|
||||
totalCount: 1,
|
||||
totalCount: 2,
|
||||
rows: [
|
||||
{
|
||||
id: 30,
|
||||
|
|
@ -192,8 +251,18 @@ const snapshot = {
|
|||
phase: 'Discurso em plenário',
|
||||
summary: 'Resumo da fala',
|
||||
officialTextUrl: 'https://camara.test/discurso',
|
||||
watchHref: '/campanha/comunicacao/acervo/30?t=120',
|
||||
excerpt: 'Trecho da fala sobre Feira de Santana',
|
||||
mentionExcerpt: 'Trecho da fala que cita Feira de Santana',
|
||||
mentionedMunicipalityCount: 3,
|
||||
},
|
||||
{
|
||||
id: 31,
|
||||
speechAt: '2026-06-01T10:00:00.000Z',
|
||||
year: 2026,
|
||||
phase: 'Pequeno Expediente',
|
||||
summary: 'Saúde indígena na Bahia',
|
||||
officialTextUrl: 'https://camara.test/discurso-2',
|
||||
mentionExcerpt: null,
|
||||
mentionedMunicipalityCount: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -230,11 +299,13 @@ type ReportRow = {
|
|||
value: string
|
||||
source?: { url?: string | null; date?: string | null }
|
||||
}
|
||||
type TableRow = Record<string, string>
|
||||
type ReportBlock = {
|
||||
kind: string
|
||||
title?: string
|
||||
items?: ReportItem[]
|
||||
rows?: ReportRow[]
|
||||
columns?: Array<{ key: string; label: string }>
|
||||
cells?: Array<{ title: string; blocks: ReportBlock[] }>
|
||||
body?: string[]
|
||||
sources?: Array<{ kind: string; url?: string | null }>
|
||||
|
|
@ -251,10 +322,86 @@ const asReport = (value: unknown) => value as ReportShape
|
|||
describe('buildCityReport', () => {
|
||||
const report = asReport(buildCityReport({ snapshot, research, emendas, generatedAt }))
|
||||
|
||||
it('builds the 10 deepening sections in the product order', () => {
|
||||
expect(report.sections).toHaveLength(10)
|
||||
it('builds the 12 deepening sections in the product order', () => {
|
||||
expect(report.sections).toHaveLength(12)
|
||||
expect(report.sections[0].title).toMatch(/Conta eleitoral completa/)
|
||||
expect(report.sections[9].title).toMatch(/Fontes e limites/)
|
||||
expect(report.sections[1].title).toMatch(/Concorrentes no município/)
|
||||
expect(report.sections[10].title).toMatch(/Abordagem sugerida/)
|
||||
expect(report.sections[11].title).toMatch(/Fontes e limites/)
|
||||
})
|
||||
|
||||
it('lists the main competitors with their vote series and the pre-candidates', () => {
|
||||
const concorrentes = report.sections.find((section) => section.id === 'concorrentes')!
|
||||
const federal = concorrentes.blocks[0]
|
||||
const rows = federal.rows as unknown as TableRow[]
|
||||
expect(federal.title).toContain('Deputado federal')
|
||||
expect(rows.map((row) => row.name)).toEqual(['Solla', 'Concorrente A'])
|
||||
expect(rows[1].y2022).toBe('30.000')
|
||||
expect(rows[1].y2014).toBe('—')
|
||||
const preCandidates = concorrentes.blocks[2]
|
||||
const preRows = preCandidates.rows as unknown as TableRow[]
|
||||
expect(preRows[0].name).toBe('Deputado do Prefeito')
|
||||
expect(preCandidates.sources![0]).toEqual(
|
||||
expect.objectContaining({ url: 'https://exemplo.test/pre-candidato' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('lists the researched city leaders in the network section', () => {
|
||||
const rede = report.sections.find((section) => section.id === 'rede')!
|
||||
const leadersBlock = rede.blocks.find(
|
||||
(block) => block.title === 'Lideranças da cidade (pesquisa)',
|
||||
)!
|
||||
const rows = leadersBlock.rows as unknown as TableRow[]
|
||||
expect(rows[0].name).toBe('Ex-Prefeito')
|
||||
expect(rows[0].period).toBe('2017–2024')
|
||||
expect(leadersBlock.sources![0]).toEqual(
|
||||
expect.objectContaining({ url: 'https://exemplo.test/lideranca' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows expected votes on page 1 instead of the goal/pledge coverage', () => {
|
||||
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
|
||||
const kpis = grid.cells![0].blocks.find((block) => block.kind === 'kpis')!
|
||||
expect(kpis.items!.map((item) => item.label)).toEqual([
|
||||
'Votos em 2022',
|
||||
'Rank no estado',
|
||||
'Expectativa de votos',
|
||||
])
|
||||
expect(kpis.items![2].value).toBe('50.000')
|
||||
expect(kpis.items![2].hint).toContain('Pessimista 40.000')
|
||||
expect(kpis.items![2].hint).toContain('Otimista 70.000')
|
||||
const leftLabels = grid.cells![0].blocks.flatMap((block) =>
|
||||
(block.rows ?? []).map((row) => row.label),
|
||||
)
|
||||
expect(leftLabels).not.toContain('Cobertura de pledges')
|
||||
expect(JSON.stringify(report.page1)).not.toContain('Meta 2026')
|
||||
})
|
||||
|
||||
it('describes what each speech is and how the município is mentioned', () => {
|
||||
const falas = report.sections.find((section) => section.id === 'falas')!.blocks[0]
|
||||
const rows = falas.rows as unknown as TableRow[]
|
||||
expect(falas.columns!.map((column) => column.label)).toEqual([
|
||||
'Data',
|
||||
'Fase',
|
||||
'O que é',
|
||||
`Menção a ${snapshot.municipality.name}`,
|
||||
'Link',
|
||||
])
|
||||
expect(rows[0].description).toBe('Resumo da fala')
|
||||
expect(rows[0].mention).toContain('cita Feira de Santana')
|
||||
expect(rows[1].description).toBe('Saúde indígena na Bahia')
|
||||
expect(rows[1].mention).toContain('nome não localizado nos trechos')
|
||||
expect(rows[1].mention).toContain('25 municípios')
|
||||
})
|
||||
|
||||
it('renders the persona approach section and its sources', () => {
|
||||
const abordagem = report.sections.find((section) => section.id === 'abordagem')!.blocks[0]
|
||||
const rows = abordagem.rows as unknown as TableRow[]
|
||||
expect(rows[0].topic).toContain('Ciência política')
|
||||
expect(rows[0].suggestion).toContain('SAMU')
|
||||
expect(abordagem.sources![0]).toEqual(
|
||||
expect.objectContaining({ url: 'https://exemplo.test/abordagem' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps page 1 in the contract order (six blocks + footer)', () => {
|
||||
|
|
@ -314,10 +461,37 @@ describe('buildCityReport', () => {
|
|||
(block) => block.kind === 'kpis' && block.title === 'O que Solla entregou',
|
||||
)!
|
||||
expect(delivered.items![0].value).toBe('Sem fonte oficial')
|
||||
const gaps = gapReport.page1.blocks.find(
|
||||
(block) => block.kind === 'callout' && block.title === 'Pontos sem leitura',
|
||||
)!
|
||||
expect(gaps.body!.join(' ')).toMatch(/Emendas/)
|
||||
expect(delivered.items![0].hint).toContain('sem emenda atribuível')
|
||||
})
|
||||
|
||||
it('shows the web evidence for emendas when the official source is a gap', () => {
|
||||
const gapReport = asReport(
|
||||
buildCityReport({
|
||||
snapshot,
|
||||
research,
|
||||
emendas: {
|
||||
status: 'gap',
|
||||
reason: 'Sem emenda do autor com localidade Feira de Santana na janela.',
|
||||
detail: 'API não expõe o município da emenda.',
|
||||
sourceUrl: 'https://api.portaldatransparencia.gov.br/api-de-dados/emendas',
|
||||
consultedAt: '2026-09-15T11:00:00.000Z',
|
||||
},
|
||||
generatedAt,
|
||||
}),
|
||||
)
|
||||
const evidence = gapReport.page1.blocks.find(
|
||||
(block) =>
|
||||
block.kind === 'callout' &&
|
||||
block.title === 'Emendas — indícios web (sem atribuição oficial ao município)',
|
||||
)
|
||||
expect(evidence).toBeTruthy()
|
||||
expect(evidence!.body!.join(' ')).toContain('Resposta de emendas_web')
|
||||
const evidenceOk = asReport(buildCityReport({ snapshot, research, emendas, generatedAt }))
|
||||
expect(
|
||||
evidenceOk.page1.blocks.some(
|
||||
(block) => block.title === 'Emendas — indícios web (sem atribuição oficial ao município)',
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('puts research answers in Quem é quem with compact sources and URLs in section 10', () => {
|
||||
|
|
@ -329,6 +503,7 @@ describe('buildCityReport', () => {
|
|||
expect(prefeito.source!.date).toBe('2026-09-10')
|
||||
const fontes = report.sections.find((section) => section.id === 'fontes')!.blocks[0]
|
||||
expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/prefeito')).toBe(true)
|
||||
expect(fontes.items!.some((item) => item.url === 'https://exemplo.test/polo')).toBe(true)
|
||||
})
|
||||
|
||||
it('builds the region panorama from the committed artifact', () => {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,13 @@ describe('renderReportHtml', () => {
|
|||
expect(html).toContain('<script>')
|
||||
})
|
||||
|
||||
it('turns every URL into a clickable anchor', () => {
|
||||
expect(html).toContain(
|
||||
'<a href="https://exemplo.test/prefeito">https://exemplo.test/prefeito</a>',
|
||||
)
|
||||
expect(html).toContain('href="https://exemplo.test/emendas_web"')
|
||||
})
|
||||
|
||||
it('prints the gaps as callouts and the limits list', () => {
|
||||
expect(html).toContain('Lacuna explícita')
|
||||
expect(html).toContain('Limites')
|
||||
|
|
@ -106,7 +113,9 @@ describe('renderReportMd', () => {
|
|||
|
||||
it('carries the same sections as tables and headings', () => {
|
||||
expect(md).toContain('## 1. Conta eleitoral completa')
|
||||
expect(md).toContain('## 10. Fontes e limites')
|
||||
expect(md).toContain('## 2. Concorrentes no município (federal e estadual)')
|
||||
expect(md).toContain('## 11. Abordagem sugerida (pesquisa)')
|
||||
expect(md).toContain('## 12. Fontes e limites')
|
||||
expect(md).toContain('| Ano | Votos |')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,61 @@ describe('normalizeResearchInput', () => {
|
|||
expect(research.gaps.filter((gap) => gap.reason === 'Não pesquisado.')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps item extra sources with URL + date and drops ones without', () => {
|
||||
const research = normalizeResearchInput(
|
||||
baseResearch({
|
||||
items: [
|
||||
validItem('emendas_web', {
|
||||
extraSources: [
|
||||
{ label: 'Polo regional', url: 'https://exemplo.test/polo', date: '2026-09-01' },
|
||||
{ label: 'Sem data', url: 'https://exemplo.test/sem-data' },
|
||||
],
|
||||
}),
|
||||
...RESEARCH_CHECKLIST_IDS.filter((id) => id !== 'emendas_web').map((id) => validItem(id)),
|
||||
],
|
||||
}),
|
||||
{ now },
|
||||
)
|
||||
const item = research.items.find((entry) => entry.id === 'emendas_web')!
|
||||
expect(item.extraSources).toHaveLength(1)
|
||||
expect(item.extraSources[0].url).toBe('https://exemplo.test/polo')
|
||||
expect(research.gaps).toContainEqual(
|
||||
expect.objectContaining({ id: 'fonte_extra', reason: expect.stringMatching(/URL ou data/) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps sourced approach suggestions and gaps ones without source', () => {
|
||||
const research = normalizeResearchInput(
|
||||
baseResearch({
|
||||
approach: [
|
||||
{
|
||||
persona: 'Coordenação',
|
||||
topic: 'Saúde',
|
||||
suggestion: 'Agenda no hospital regional.',
|
||||
sourceUrl: 'https://exemplo.test/saude',
|
||||
sourceDate: '2026-09-01',
|
||||
},
|
||||
{
|
||||
persona: 'Ciência política',
|
||||
topic: 'Sem fonte',
|
||||
suggestion: 'Não entra.',
|
||||
sourceUrl: null,
|
||||
sourceDate: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ now },
|
||||
)
|
||||
expect(research.approach).toHaveLength(1)
|
||||
expect(research.approach[0].persona).toBe('Coordenação')
|
||||
expect(research.gaps).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'abordagem_sem_fonte',
|
||||
reason: expect.stringMatching(/URL e data/),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('drops news outside the 90-day window and without source', () => {
|
||||
const research = normalizeResearchInput(
|
||||
baseResearch({
|
||||
|
|
|
|||
Loading…
Reference in a new issue