diff --git a/.agents/skills/relatorio-cidade/SKILL.md b/.agents/skills/relatorio-cidade/SKILL.md index 4a1cdedf..2726727e 100644 --- a/.agents/skills/relatorio-cidade/SKILL.md +++ b/.agents/skills/relatorio-cidade/SKILL.md @@ -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/.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/.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 (``): 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/.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/.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` ≠ diff --git a/docs/changelog/2026-09-15-c163-relatorio-v2.md b/docs/changelog/2026-09-15-c163-relatorio-v2.md new file mode 100644 index 00000000..5757c5f4 --- /dev/null +++ b/docs/changelog/2026-09-15-c163-relatorio-v2.md @@ -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. diff --git a/scripts/cityReportSnapshot.mjs b/scripts/cityReportSnapshot.mjs index 8a855acc..853634b0 100644 --- a/scripts/cityReportSnapshot.mjs +++ b/scripts/cityReportSnapshot.mjs @@ -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, } } diff --git a/scripts/lib/cityReportBlocks.mjs b/scripts/lib/cityReportBlocks.mjs index ea5c2003..e2acda33 100644 --- a/scripts/lib/cityReportBlocks.mjs +++ b/scripts/lib/cityReportBlocks.mjs @@ -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 }), }, ] diff --git a/scripts/lib/cityReportRender.mjs b/scripts/lib/cityReportRender.mjs index a21f7456..f48689c3 100644 --- a/scripts/lib/cityReportRender.mjs +++ b/scripts/lib/cityReportRender.mjs @@ -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) => `${url}`) + 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) => `
${htmlEscape(row.label)} - ${htmlEscape(row.value)}${row.hint ? `${htmlEscape(row.hint)}` : ''}${row.source ? `fonte: ${htmlEscape(sourceLabel(row.source))}` : ''} + ${htmlEscape(row.value)}${row.hint ? `${htmlEscape(row.hint)}` : ''}${row.source ? `fonte: ${htmlWithLinks(sourceLabel(row.source))}` : ''}
`, ) .join('') @@ -192,7 +198,7 @@ const renderBullets = (block) => { const items = block.items .map((item) => { const source = item.source - ? ` fonte: ${htmlEscape(sourceLabel(item.source))}` + ? ` fonte: ${htmlWithLinks(sourceLabel(item.source))}` : '' return `
  • ${htmlEscape(item.text)}${source}
  • ` }) @@ -220,7 +226,7 @@ const renderTable = (block) => { `${block.columns .map( (column) => - `${htmlEscape(row[column.key])}`, + `${htmlWithLinks(row[column.key])}`, ) .join('')}`, ) @@ -291,7 +297,7 @@ const renderBlockHtml = (block) => { throw new Error(`Bloco desconhecido no render HTML: ${block.kind}`) } const sources = block.sources?.length - ? `

    Fontes: ${block.sources.map((source) => htmlEscape(sourceLabel(source))).join(' · ')}

    ` + ? `

    Fontes: ${block.sources.map((source) => htmlWithLinks(sourceLabel(source))).join(' · ')}

    ` : '' const className = block.kind === 'table' ? 'block table-block' : 'block' return `
    ${title}${body}${sources}
    ` diff --git a/scripts/lib/cityReportResearch.mjs b/scripts/lib/cityReportResearch.mjs index 5778971d..316ac816 100644 --- a/scripts/lib/cityReportResearch.mjs +++ b/scripts/lib/cityReportResearch.mjs @@ -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, } } diff --git a/tests/unit/cityReportBlocks.unit.spec.ts b/tests/unit/cityReportBlocks.unit.spec.ts index 1777c5a6..672f3294 100644 --- a/tests/unit/cityReportBlocks.unit.spec.ts +++ b/tests/unit/cityReportBlocks.unit.spec.ts @@ -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 = {}) => ({ 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 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', () => { diff --git a/tests/unit/cityReportRender.unit.spec.ts b/tests/unit/cityReportRender.unit.spec.ts index 89b0a51a..0edeafec 100644 --- a/tests/unit/cityReportRender.unit.spec.ts +++ b/tests/unit/cityReportRender.unit.spec.ts @@ -95,6 +95,13 @@ describe('renderReportHtml', () => { expect(html).toContain('<script>') }) + it('turns every URL into a clickable anchor', () => { + expect(html).toContain( + 'https://exemplo.test/prefeito', + ) + 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 |') }) diff --git a/tests/unit/cityReportResearch.unit.spec.ts b/tests/unit/cityReportResearch.unit.spec.ts index 60a8170a..1bd8cdfd 100644 --- a/tests/unit/cityReportResearch.unit.spec.ts +++ b/tests/unit/cityReportResearch.unit.spec.ts @@ -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({