feat(C163): relatório de cidade pré-viagem (agente + skill → PDF A4) (Closes #1007)

This commit is contained in:
Francisco Solla 2026-09-15 06:58:21 -03:00
parent f41ef92473
commit e579406b1f
23 changed files with 3741 additions and 0 deletions

View file

@ -0,0 +1,129 @@
---
name: relatorio-cidade
description: 'Gera o relatório de cidade pré-viagem (PDF A4 + .md) a partir da base Teqo read-only + pesquisa web datada + emendas oficiais.'
---
# Relatório de cidade pré-viagem (C163)
Entrega um **PDF A4 datado + companion `.md`** por município, cruzando a base de
produção do Teqo (**read-only**, snapshot do momento) com pesquisa web (data +
URL por item) e emendas lidas da fonte oficial **em tempo de geração**. Página 1
= resumo de uma olhada; páginas 2+ = aprofundamento; tudo sem fonte vira
**lacuna explícita** — nunca inferência.
## Quando usar
- O candidato/CG pede "o relatório de <cidade>" antes de uma viagem.
- Quem executa é o agente (pesquisa web + escrita dos JSONs) + os dois scripts
(`extract` no homeserver, `build` local). O PDF sai para leitura de bolso.
## Pipeline (3 passos)
1. **Slug canônico.** O município é sempre um slug do catálogo
(`src/lib/municipalityCatalog.ts`, 435 unidades; Salvador = `salvador-ze-N`).
Não invente slug: confirme no catálogo (`pnpm exec tsx -e` ou a lista em
`/campanha/municipios`).
2. **Pesquisa web → `data/relatorios-cidade/<slug>.research.json`.**
Cada item do checklist com `sourceUrl` + `sourceDate`; sem fonte, **não
escreva o item** (ele vira lacuna). Notícias: janela ≤90 dias.
3. **Extração read-only no homeserver** (receita completa no runbook
`docs/ops/teqo-1313-deploy.md` §"C163 — relatório de cidade"):
```bash
ssh homeserver
source ~/.nvm/nvm.sh
cd ~/teqo-report && git fetch origin main && git checkout main && git pull --ff-only
[ -d node_modules/tsx ] || pnpm install --prod=false
set -a; source ~/stack/teqo-1313.env; set +a
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
export CITY_REPORT_CONFIRM=1
NODE_OPTIONS="--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs" \
node scripts/extract-city-report-snapshot.mjs \
--municipality=<slug> --out=data/relatorios-cidade/<slug>.snapshot.json
```
Depois `scp homeserver:~/teqo-report/data/relatorios-cidade/<slug>.snapshot.json data/relatorios-cidade/`.
O `~/teqo-report` é checkout de **scratch** — não use `~/teqo-deploy` (deploy
em andamento no mesmo host). O SHA do extrator e do builder devem ser o mesmo.
4. **Render local:**
```bash
NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-city-report.mjs \
--snapshot=data/relatorios-cidade/<slug>.snapshot.json \
--research=data/relatorios-cidade/<slug>.research.json \
--out-dir=docs/research/relatorios-cidade
```
Saídas: `docs/research/relatorios-cidade/<slug>-<YYYY-MM-DD>.pdf` + `.md`
(companion revisável em diff). Intermediários gitignored em
`data/relatorios-cidade/` (HTML, JSONs de emendas).
Flags úteis: `--emendas=<json>` (replay sem rede), `--author=<nome>`
(default `JORGE SOLLA`), `--generated-at=<ISO>` (reprodutibilidade),
`CITY_REPORT_STRICT=1` (falha o run se houver lacuna de pesquisa — para
conferência, não para entrega).
## Contrato dos JSONs
`research.json`:
```jsonc
{
"municipalitySlug": "feira-de-santana", // tem de bater com o snapshot
"researchedAt": "2026-09-15T10:00:00.000Z", // obrigatório
"items": [
{
"id": "prefeito", // id do checklist (abaixo)
"answer": "Nome (Partido), situação",
"details": "opcional",
"sourceUrl": "https://…", // obrigatório em item publicado
"sourceDate": "2026-09-10", // obrigatório
"consultedAt": "2026-09-15T09:00:00.000Z" // opcional
}
],
"news": [
{
"title": "…", "outlet": "…",
"publishedAt": "2026-09-01T00:00:00.000Z", // janela ≤90 dias do researchedAt
"url": "https://…", "summary": "…"
}
],
"gaps": [{ "id": "…", "label": "…", "reason": "…" }] // lacunas que o agente já sabe
}
```
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.
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.
## Guardrails de produto (não negociáveis)
- **Sem fonte, não publica**: afirmação não trivial sem data+URL não entra.
- **Empenho ≠ pagamento** (defeso/ano eleitoral): o bloco de emendas mostra a
fase (empenhada, liquidada, paga, restos) e o "anunciar × não anunciar" é fixo.
- **Leitura relativa**: % do próprio voto, rank, LQ — nunca % estadual absoluto.
- **PII mínima**: contatos completos (telefone/e-mail) nunca entram; nomes de
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.
- **Artefato gitignored**: o repo é público; o PDF/MD com dado interno nunca é
commitado (só a skill/scripts/changelog).
## Troubleshooting
- **Página 1 estourou**: o builder aborta com `scrollHeight > útil`; corte copy
do resumo/`caps`, não mexa no layout para "espremer".
- **`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).
- **Chromium**: vem do `@playwright/test`; se faltar binário,
`pnpm exec playwright install chromium`.
- **Snapshot de outro município**: o builder recusa (`municipalitySlug` ≠
snapshot) — regenere, nunca edite o JSON à mão para "casar".
## Referências
- Intenção: `docs/plans/relatorio-cidade-viagem.md`; impl:
`docs/plans/relatorio-cidade-viagem-impl.md`.
- Precedente de PDF: `scripts/build-solla-ceuci-salvador-report.mjs` (C157).
- Runbook: `docs/ops/teqo-1313-deploy.md` §C163.

5
.gitignore vendored
View file

@ -78,6 +78,11 @@ next-env.d.ts
# Solla × Ceuci report HTML cache (scripts/build-solla-ceuci-salvador-report.mjs)
/data/solla-ceuci-salvador/
# C163 — city pre-trip report: snapshot/research/HTML cache + internal PDF/MD.
# The repo is public; the report carries campaign-internal data and is never committed.
/data/relatorios-cidade/
/docs/research/relatorios-cidade/
# Local-only sensitive material (agents may read; never commit content)
/private/**
!/private/README.md

View file

@ -0,0 +1,31 @@
---
description: Gera o relatório de cidade pré-viagem (PDF A4 + .md) — base Teqo read-only + pesquisa web datada + emendas oficiais
mode: subagent
---
# Relatório de cidade pré-viagem — subagente
Você executa a skill `.agents/skills/relatorio-cidade/SKILL.md` (fonte canônica:
pipeline, contrato dos JSONs, guardrails, troubleshooting). Leia-a antes de
rodar qualquer comando.
## Papel
1. Confirmar o slug canônico do município (catálogo; Salvador = `salvador-ze-N`).
2. Fazer a **pesquisa web datada** e escrever
`data/relatorios-cidade/<slug>.research.json` no contrato da skill — item sem
`sourceUrl`/`sourceDate` não vai para o arquivo (vira lacuna explícita).
3. Conduzir a extração read-only no homeserver (`~/teqo-report`, `CITY_REPORT_CONFIRM=1`,
proxy `127.0.0.1:5433`) e trazer o snapshot.
4. Rodar o builder local e entregar os caminhos do PDF + `.md`.
## Limites
- **Nunca** escreva na base de produção: a extração é read-only por connection
options; não use `db:pull`/snapshot de banco.
- **Nunca** commite o PDF/MD/snapshot (dado interno; repo público — artefato
gitignored).
- Não invente fato sem fonte; não trate empenho como pagamento; não inclua
telefone/e-mail de liderança.
- Não edite os scripts nem o layout para "caber": se a página 1 estourar, corte
copy/caps do resumo.

View file

@ -0,0 +1 @@
**C163 — Relatório de cidade pré-viagem (agente + skill → PDF A4) (2026-09-15):** documento por município para o candidato/CG antes da viagem — **PDF A4 datado + companion `.md`** gerados sob demanda, cruzando a base Teqo lida **direto da produção, read-only** (snapshot do momento) com pesquisa web datada (URL + data por item) e emendas lidas da fonte oficial **em tempo de geração, sem persistir nada**. Página 1 = resumo de uma olhada (identificação/prioridade/classe/nível, conta eleitoral 2022 relativa — votos, % do próprio voto, rank/435, meta e cobertura de pledges —, quem é quem, o que Solla entregou com fase das emendas, anunciar × não anunciar com o defeso, riscos); páginas 2+ = aprofundamento em 10 seções (série 2014/2018/2022, rede/lideranças com frescor, conjuntura, sinais, demandas/visitas, demografia, acervo de falas, notícias, panorama regional da TI, fontes e limites). Guardrails: "sem fonte, não publica" (item sem URL/data vira lacuna explícita), empenho ≠ pagamento, leitura relativa, PII de contato fora, PDF completo para o candidato (estimativas e nível N0–N4 entram). Entrega: `scripts/extract-city-report-snapshot.mjs` (Payload Local API no homeserver com `default_transaction_read_only=on` na connection string + guard `CITY_REPORT_CONFIRM=1`; mesmos loaders do E16 — `loadMunicipalityDossierData`, detail VM, rank do artefato TSE, acervo por `buildSpeechListWhere`, demandas por município), `scripts/build-city-report.mjs` (blocos puros → HTML+MD do mesmo conteúdo, `page.pdf` do Chromium já instalado, guarda de página 1), composer de integração em `scripts/cityReportSnapshot.mjs` e libs puras em `scripts/lib/cityReport{Format,Research,Territory,Blocks,Render,Database}.mjs` + `portalTransparenciaEmendas.mjs` (Portal da Transparência com fallback lacuna), skill `.agents/skills/relatorio-cidade/` + subagente `.opencode/agent/relatorio-cidade.md`, runbook em `docs/ops/teqo-1313-deploy.md` §C163. Artefatos (PDF/MD/snapshot/HTML) **gitignored** — o repo é público e o relatório carrega dado interno de campanha. Testes: 39 unit (formatters, checklist de pesquisa, normalização de emendas, blocos, renderers, URL read-only) + 1 int (composição do snapshot com fixtures, provando PII fora e totais). Issue #1007.

View file

@ -691,6 +691,46 @@ anterior de cada fala está em `updates[].previousUrl` no JSON do run. O import
(`pnpm camara:import --date <dia>`) reconstrói o link a partir da API (a fonte
é a URL legada devolvida pela Câmara); reexecutar o reparo é idempotente.
## C163 — relatório de cidade pré-viagem (leitura read-only)
O gerador de PDF por cidade (`/work-issue` C163) extrai um snapshot da **base
de produção** sem escrever nada: a sessão Postgres é aberta com
`default_transaction_read_only=on` (options da connection string, aplicada pelo
próprio `scripts/extract-city-report-snapshot.mjs`) e o render acontece fora do
homeserver. Nenhum artefato é persistido na base; o snapshot e o PDF/MD são
gitignored (`data/relatorios-cidade/`, `docs/research/relatorios-cidade/`).
Fluxo completo e contrato dos JSONs: skill `.agents/skills/relatorio-cidade`.
Receita do homeserver (checkout de scratch, **nunca** o `~/teqo-deploy` do
deploy em andamento):
```bash
ssh homeserver
source ~/.nvm/nvm.sh # Node 24 (engines do repo)
test -d ~/teqo-report || git clone https://github.com/fsolla/teqo.git ~/teqo-report
cd ~/teqo-report
git fetch origin main && git checkout main && git pull --ff-only origin main
# tsx vem do install completo (o deploy usa --prod e não traz devDeps):
[ -d node_modules/tsx ] || pnpm install --prod=false
set -a; source ~/stack/teqo-1313.env; set +a
# o host `postgres` só resolve na rede do stack; do host usa-se o proxy socat:
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
export CITY_REPORT_CONFIRM=1 # guard de intenção explícita
NODE_OPTIONS="--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs" \
node scripts/extract-city-report-snapshot.mjs \
--municipality=<slug> --out=data/relatorios-cidade/<slug>.snapshot.json
```
Depois, na workstation: `scp homeserver:~/teqo-report/data/relatorios-cidade/<slug>.snapshot.json data/relatorios-cidade/`
e `node scripts/build-city-report.mjs --snapshot=... --research=...`. O snapshot
carrega `readAt` + SHA do código — o PDF é datado por construção.
Guardas: sem `CITY_REPORT_CONFIRM=1` o extrator recusa; a URL com `options`
read-only é aplicada antes do Payload subir (qualquer write estoura no servidor);
`PORTAL_TRANSPARENCIA_API_KEY` é opcional (sem chave, emendas viram lacuna
explícita, nunca zero). Nunca aponte o extrator para o banco local esperando
dados de produção — o caminho é o proxy do homeserver.
## Referências
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`)

View file

@ -0,0 +1,191 @@
# Impl: Relatório de cidade pré-viagem (agente + skill → PDF A4)
Status: executado
Atualizado em: 2026-09-15
Issue: #1007
Intenção: docs/plans/relatorio-cidade-viagem.md
Appetite restante: herdado — ~2–3 dias eng. Cortes explícitos se estourar: (a) emendas oficiais viram lacuna explícita; (b) página 1 fecha nos caps do E16 (8 lideranças / 5 sinais / 3+2 visitas); (c) skill-first: o subagente pode shipar como wrapper fino depois, sem bloquear o script→PDF.
## Leitura da intenção
- **Outcome:** chamar o agente/skill com um município e sair um **PDF A4 datado** (+ companion `.md` com números e proveniência), lendo a base de produção **read-only** no estado do momento; página 1 = resumo de uma olhada (conta eleitoral 2022, quem é quem, o que foi entregue, anunciar × não anunciar, riscos); páginas 2+ = aprofundamento com fonte por item e lacuna explícita onde não há dado.
- **O que NÃO negociar:**
- "Sem fonte, não publica" — afirmação não trivial sem origem (base Teqo com data de leitura; web/fonte oficial com data + URL) não entra; ausência vira lacuna explícita, nunca inferência.
- Leitura **relativa** (% do próprio voto, rank, LQ/captura) — nunca % estadual absoluto.
- Empenho ≠ pagamento; bloco "anunciar × NÃO anunciar" com defeso/ano eleitoral.
- PII mínima: contatos completos (telefone/e-mail) fora; conteúdo completo para o candidato **dentro do PDF** (estimativas e nível N0–N4 entram, sem marca de restrição).
- Emendas lidas da fonte oficial **em tempo de geração, sem persistir**; nenhuma collection/migration/Consent novo; nenhum `db:pull`/snapshot de banco.
- **O que reavaliar (hipóteses da intenção que a execução deve confrontar):**
- **Visibilidade da entrega.** A intenção resolveu "PDF + `.md` commitados em `docs/research/`"; o repositório é **público** (`gh repo view`: `visibility: PUBLIC`) e o relatório carrega dados internos de campanha (nomes de lideranças, pledges, estimativas staff-only, conjuntura). A entrega mantém o caminho `docs/research/relatorios-cidade/`, mas **gitignored** (decisão D6); publicar exigiria um renderer sanitizado, fora do appetite.
- "Notícias internas" não existe como entidade no codebase: `Post` não tem relação com município; o que existe é `municipalityUpdate` (sinais). A seção vira "Sinais internos + imprensa local (web)".
- Os caps do E16 (`DOSSIER_LEADERSHIP_LIMIT = 8`, `DOSSIER_SIGNAL_LIMIT = 5`, 3+2 atividades, `municipalityDossierData.ts:38-43`) são o corte natural contra o rabbit hole "relatório de 40 páginas" — o relatório usa os caps e mostra `totalCount`.
- A fonte oficial de emendas é decisão de implementação (ver D5): Portal da Transparência `/api-de-dados/emendas`, com lacuna explícita como fallback.
- O acervo de falas não tem loader "falas do município"; a query é do extrator, compondo o dono `buildSpeechListWhere`.
## Abordagem recomendada
```mermaid
flowchart LR
A[pedido: relatorio de um municipio] --> B[skill relatorio-cidade: checklist + contratos]
B --> C[ssh homeserver: extract-city-report-snapshot.mjs]
C -->|Payload Local API, read-only PGOPTIONS, ator role candidate| D[data/relatorios-cidade/slug-data.snapshot.json]
B --> E[pesquisa web do agente: checklist com URL + data]
E --> F[data/relatorios-cidade/slug-data.research.json]
D --> G[build-city-report.mjs local]
F --> G
G -->|fetch Portal da Transparencia, sem persistir| G
G --> H[docs/research/relatorios-cidade/slug-data.pdf + .md]
G --> I[guarda: pagina 1 em 1 pagina; sem fonte nao publica]
```
**Opções consideradas:** A (recomendada) extração **no homeserver** read-only + snapshot JSON + render local; B túnel SSH (`ssh -L 5433:127.0.0.1:5433`) e roda tudo na workstation; C roda tudo no homeserver, inclusive Chromium; D rota `/campanha` com print CSS (como no E16).
**Recomendação:** **A** — a extração roda onde a produção vive (mesma receita `socat 127.0.0.1:5433` do runbook), o processo não carrega credencial de prod na workstation e o render fica no único lugar que já tem Chromium (precedente C157). O snapshot JSON é um intermediário explícito: revisável, re-renderizável sem tocar produção de novo e a prova de proveniência (data de leitura + SHA do código).
**Rejeitadas:**
- **B** porque a credencial de produção passa a viver no processo local e o guard local (`assertLocalDatabase`) não distingue `127.0.0.1` do proxy socat de um Postgres Docker — o passe seria silencioso; além de manter uma lane viva para produção na workstation.
- **C** porque instalar Chromium no homeserver polui o host de deploy e acopla o render ao servidor.
- **D** porque exige sessão autenticada e imprime a casca do app; já rejeitada em E16/C157 (precedente `docs/plans/relatorio-sobreposicao-solla-ceuci-salvador-impl.md`).
### Componentes / mudanças
- **`scripts/extract-city-report-snapshot.mjs`** (novo): CLI `--municipality=<slug> --out=<json>`; seta `PGOPTIONS='-c default_transaction_read_only=on -c statement_timeout=30000'` **antes** de importar `src/payload.config.ts` (o `pg` lê `PGOPTIONS` em `ConnectionParameters` — `node_modules/pg/lib/connection-parameters.js`; nenhuma mudança no config do app); exige `CITY_REPORT_CONFIRM=1` sempre (opt-in explícito, padrão C155); imprime host/DB sem credenciais (reusa `databaseHostname`/`loadCliEnv`/`dieWithLabel` de `scripts/lib/cli.mjs`); resolve o ator e chama o composer; grava o snapshot; nunca escreve no banco (qualquer write estoura no servidor).
- **`scripts/cityReportSnapshot.mjs`** (novo, camada de integração testada por int; mora ao lado do extrator, fora de `scripts/lib/` puro): `composeCityReportSnapshot({ payload, actor, slug })` compondo os donos, sem duplicar:
- `resolveAccessibleMunicipalityContext` (`src/utilities/municipality/municipalityPageData.ts:678`) e `getMunicipalityDetailViewModel` (`municipalityPageData.ts:700`) → `strategy` (priority, expectedVotes, politicalTrend, engagementLevel N0–N4, strengths/risks, dobradinhas, budgetNotes, stateDeputies).
- `loadMunicipalityDossierData` (`src/utilities/municipality/municipalityDossierData.ts:99`) → baseline 2014/2018/2022 + tally 2022 + ticket, goalAccount (suggestedGoal, goalCoverage, territorialClass, territoryCaptureBenchmark), pledgeAggregate, lideranças (cap 8 + total), sinais (cap 5 + total, deliberação), visitas (3+2), demografia (`demographicsForCode`).
- `getMunicipalityVoteRank` (`src/lib/municipalityVoteRank.ts:66`) → rank/435 + share do próprio voto.
- Falas: `buildSpeechListWhere({ page: 1, municipalities: [id] })` (`src/utilities/speech/speechListFilters.ts:42`) numa query própria com select mínimo (`speechAt`, `year`, `phase`, `officialTextUrl`, `summary`, `mentionedMunicipalities`) + `loadSegmentsForSpeeches` (`src/utilities/speech/speechPageData.ts:110`) + `municipalityIdsOfSpeech`/`buildWatchHref` (`src/utilities/speech/speechViewModels.ts`).
- Demandas: query própria `campaignDemand` por `municipality` (não existe loader com esse filtro — `buildDemandListWhere`, `src/utilities/demand/demandListUrl.ts:40`, só cobre status/kind/q/activity; não vale criar dono para 1 call site).
- **Projeção anti-PII:** descarta `phone`/`email`/`phones` do `LeadershipRowViewModel`, contatos completos e `cost`/`receipts` de demandas; nomes entram porque o produto os pede (página 1 "quem é quem"), contatos nunca.
- **`scripts/build-city-report.mjs`** (novo): CLI `--snapshot=<json> --research=<json> [--emendas=<json>] --out-dir=docs/research/relatorios-cidade`; sem `--emendas` faz o fetch oficial (D5) e cacheia o resultado em `data/relatorios-cidade/<slug>-<data>.emendas.json` (replay sem rede); monta blocos → HTML (print CSS próprio) → `page.pdf({ format: 'A4', printBackground: true })` com header/footer paginado (mecânica do C157, `scripts/build-solla-ceuci-salvador-report.mjs:1117-1136`) → companion `.md` **dos mesmos blocos**; guarda de página 1 (mede `scrollHeight > clientHeight` do bloco de resumo e aborta se estourar); escreve HTML intermediário em `data/relatorios-cidade/` (gitignored).
- **`scripts/lib/cityReportBlocks.mjs`** (novo, puro): `buildReportBlocks({ snapshot, research, emendas })` → árvore de blocos (paragraphs/bullets/kpis/table/callout/gap) na ordem do contrato de conteúdo; aplica o checklist de pesquisa e converte item sem `sourceUrl`/`sourceDate` em lacuna ("sem fonte, não publica"); sintetiza lacuna para checklist ausente; tetos por seção.
- **`scripts/lib/cityReportRender.mjs`** (novo, puro): `renderReportHtml(blocks, meta, css)` e `renderReportMd(blocks, meta)`; `PRINT_CSS` (port do rascunho A4: A4 794×1123, micro-labels, acento âmbar para decisão/lacuna, Fira Sans + DejaVu fallback — sem Tailwind CDN); `htmlEscape`.
- **`scripts/lib/cityReportResearch.mjs`** (novo, puro): valida/normaliza o JSON de pesquisa contra `RESEARCH_CHECKLIST` fixo (prefeito/vice, federação/relação, vereadores/dobradas, disputa local, quem investe, notícias ≤90 dias, imprensa local), exige URL+data por item, normaliza gaps.
- **`scripts/lib/portalTransparenciaEmendas.mjs`** (novo, puro + fetch injetável): normaliza o payload de `/api-de-dados/emendas` (valores empenhado/liquidado/pago/resto pago, ano, tipo, `codigoAutor`) e aplica o filtro de autor exato em memória (D5); retorna `{ status: 'ok', rows, sourceUrl, consultedAt }` ou `{ status: 'gap', reason, sourceUrl, consultedAt }`.
- **Testes:** `tests/unit/cityReportBlocks.unit.spec.ts`, `tests/unit/cityReportRender.unit.spec.ts`, `tests/unit/cityReportResearch.unit.spec.ts`, `tests/unit/portalTransparenciaEmendas.unit.spec.ts`; `tests/int/cityReportSnapshot.int.spec.ts` (padrão de `tests/int/municipalityDossierData.int.spec.ts` + `installCampaignFixtures`; mocka `municipalityElectoralBaseline` como o teste do dossiê já faz) provando caps/totais/proveniência/PII fora.
- **Skill/agente:** `.agents/skills/relatorio-cidade/SKILL.md` (frontmatter `name`/`description`; checklist de pesquisa, shape dos dois JSONs, comandos exatos de extração/SSH/proxy/confirm, `PORTAL_TRANSPARENCIA_API_KEY` em `.env.local`, guardrails literais, troubleshooting) e `.opencode/agent/relatorio-cidade.md` (`mode: subagent`, fino, apontando para a skill — sem `opencode.json`, sem permissão nova).
- **Docs:** `.gitignore` (`/data/relatorios-cidade/`, `/docs/research/relatorios-cidade/`); subseção "Relatório de cidade (read-only)" em `docs/ops/teqo-1313-deploy.md` com a receita do proxy; `docs/changelog/2026-09-15-c163.md`.
- **Sem alias em `package.json`** (precedente C157): invocação por `node`, documentada na skill; `package.json` = high-risk no `ci-scope` e forçaria suíte full/build por um relatório.
- **Migration:** **sem migration** (a intenção veta; emendas não persistem).
- **Access / Consent:** nenhuma chave/Consent novo; todas as leituras do relatório usam `overrideAccess:false` com um **ator real** — `campaignUser` role `candidate` (fallback `coordinator`), escolhido por query e fail-closed se não existir; a leitura da linha do ator usa `overrideAccess:true` (leitura de serviço, precedente dos scripts). É o papel que legitimamente vê tudo (estimativas, N0–N4, acervo) — sem bypass novo nos loaders.
- **UI:** Impeccable **B** já aprovado no gate (`docs/plans/relatorio-cidade-viagem-a4-draft.html`). Nada de tela de app; o "shell" é o próprio rascunho A4, que vira CSS print (shape→craft→critique→polish: shape fechado no draft; craft = port do CSS; critique = página 1 em 1 página + legibilidade em impressão P&B; polish = acentos de decisão/lacuna). O print CSS do app (`(campaign)/.../layout.tsx:92-100`) não é reusado — caminho de render é outro (Playwright).
### Dados → forma
- **Forma escolhida:** resumo de uma olhada em blocos compactos (KPI numérico + listas + barras simples de cobertura) com a leitura **relativa** (votos 2022, % do próprio voto, rank/435, meta/cobertura); séries 2014/2018/2022 em tabela; fonte em nota por seção; lacuna em callout âmbar; "anunciar × não anunciar" em dois cartões com o defeso explícito.
- **Rejeitadas:** % estadual absoluto (kernel do produto: leitura relativa); mapas/choropleth e gráficos ricos por cidade (fora de escopo declarado); voto por bairro/zona (não existe granularidade; o relatório não inventa); dossiê longo (anti-goal; caps do E16 e página 1 fechada).
### Contrato de dados e reúso (loaders, shapes, puro testável)
- **Snapshot** (`data/relatorios-cidade/<slug>-<data>.snapshot.json`, gitignored):
```jsonc
{
"meta": { "readAt": "ISO", "codeSha": "…", "database": "host:porta/teqo_1313",
"readOnly": true, "actorRole": "candidate", "actorId": 0,
"source": "base Teqo (produção, leitura read-only)" },
"municipality": { "id", "slug", "name", "kind", "region", "ibgeCode", "tseCityCode", "tseZones" },
"electoral": { "series": [], "tally2022": {}, "ticket2022": {}, "rank": {}, "goal": {} },
"leaderships": { "rows": [], "totalCount": 0, "withoutOwnerCount": 0 },
"pledges": {}, "signals": { "rows": [], "totalCount": 0 },
"activities": { "upcoming": [], "recent": [] },
"conjuncture": { "priority", "politicalTrend", "engagementLevel", "strengths", "risks",
"dobradinhas", "budgetNotes", "stateDeputies", "expectedVotes" },
"speeches": { "rows": [], "totalCount": 0 }, "demands": { "rows": [], "totalCount": 0 },
"demographics": {}
}
```
- **Pesquisa** (`…research.json`, escrita pelo agente): `{ municipalitySlug, researchedAt, items: [{ id, answer, details, sourceUrl, sourceDate, consultedAt }], news: [{ title, outlet, publishedAt, url, summary }], gaps: [{ id, reason }] }` — cada `id` do checklist ou item com fonte, ou gap.
- **Emendas** (resultado do fetch, cacheado): `{ status, rows: [{ year, type, empenhado, liquidado, pago, restoPago, authorCode }], sourceUrl, consultedAt }` ou `{ status: 'gap', reason, sourceUrl, consultedAt }`.
- **Blocos** = modelo único de conteúdo; HTML e MD saem do mesmo `blocks` (o MD substitui visual por "(disponível no PDF)" e lista todas as fontes — padrão C157).
- **Puro/unit-testável:** `buildReportBlocks`, os dois renderers, validação do checklist, normalização de emendas, formatters. **Integração (int):** `composeCityReportSnapshot` (fixtures + mock de baseline). **Não testável por unit:** o CLI (testado ponta a ponta no tracer com banco local).
## Fases verificáveis
1. **Tracer bullet (0,5–1 dia)** — menor fatia ponta-a-ponta: `scripts/extract-city-report-snapshot.mjs` + `scripts/cityReportSnapshot.mjs` mínimos (município + electoral + lideranças + meta) e `scripts/build-city-report.mjs` renderizando página 1 já com o CSS print do draft. Prova: na worktree, `pnpm db:seed:minimal` + `CITY_REPORT_CONFIRM=1 … node scripts/extract-city-report-snapshot.mjs --municipality=<slug do seed> --out=data/relatorios-cidade/prova.snapshot.json` → `node scripts/build-city-report.mjs --snapshot=… --research=<mínimo> --out-dir=docs/research/relatorios-cidade` → **PDF A4 datado abrível, com lacunas onde a base não tem** (baseline TSE local pode não existir; é lacuna, não falha).
2. **Conteúdo completo e guardrails (≈1 dia)** — página 1 fechando em 1 página (guarda de overflow), seções 2+ (conta eleitoral completa, falas, demandas, demografia, TI, fontes e limites), checklist de pesquisa + validação, fetch de emendas com fallback, companion `.md` dos mesmos blocos, unit tests.
3. **Skill + agente + ops (≈0,5 dia)** — `SKILL.md`, subagente, subseção no runbook, `.gitignore`, int test, changelog.
4. **Gates (≈0,5 dia)** — `pnpm format`, `pnpm gate:fast` (`lint`+`typecheck`+`test:unit`), int focado (`pnpm test:int -- cityReport`), `pnpm gate:push` (knip/cycles), push via `pnpm push`; PR com `Closes #1007`. E2E não é tocado (sem `src/`); CI seleciona o que o classifier mandar.
## Rabbit holes / Não escopo (engenharia)
- **Não** criar collection/migration/loader top-level em `src/utilities/` (pin em `tests/unit/codebaseConventions.unit.spec.ts:389`); o script importa os donos existentes.
- **Não** usar Tailwind CDN no template (render offline) nem instalar Chromium no homeserver (D1).
- **Não** persistir emendas/pesquisa/snapshot no banco; **não** fazer `db:pull`/snapshot de banco (a intenção veta).
- **Não** criar alias `pnpm` para os scripts (arrastaria suíte full/build; precedente C157).
- **Não** sanitizar/publicar o relatório no repo público nesta entrega; `--publish-sanitized` fica como gatilho de revisitação (D6).
- **Não** raspar vereadores/dobradas um a um nem perseguir toda a internet: checklist fixo, janela ≤90 dias, lacuna explícita.
- **Não** construir segundo cadastro de prefeito/vereador: isso vive na pesquisa web datada.
- **Não** mexer nos guards existentes (`assertLocalDatabase`, `assertTestDatabase`, `guard-dev-db`).
## Riscos e mitigação
- **Checkout do homeserver em deploy concorrente** (`~/teqo-deploy`): o comando da skill faz `git fetch/checkout` documentado; se houver corrida, usar checkout de scratch (`~/teqo-report`) — só leitura, sem tocar o deploy.
- **`tsx` ausente no homeserver** (install com `NODE_ENV=production`): mitigação na skill — `pnpm install --prod=false` quando `node_modules/tsx` não existir; o guard de intent (`CITY_REPORT_CONFIRM=1`) e o banner deixam claro o alvo.
- **Credencial/túnel**: eliminados por D1 (extração remota); a leitura é read-only por sessão (`PGOPTIONS`) — fail-closed mesmo se um loader tentasse escrever.
- **Emendas (API oficial)**: chave ausente, rate limit (30 req/min — 4 anos = 4 requests), homônimos de autor. Mitigação: filtro de autor exato em memória; >1 `codigoAutor` distinto para o mesmo nome → lacuna; ausência/erro → lacuna com URL + motivo, nunca zero silencioso.
- **Alucinação da pesquisa web**: o script rejeita item sem fonte e exige data; a revisão humana (CG) segue sendo o filtro final; o documento é datado e mostra o que não foi achado.
- **Página 1 estourando**: guarda de medição aborta o build (o corte é copy/caps, não layout quebrado).
- **Repo público (LGPD/estratégia)**: entrega na intenção (PDF+MD) com diretório gitignored; contatos nunca entram; snapshot/pesquisa também gitignored.
- **Fonte/caimento visual**: Fira Sans pode não estar instalada (fallback DejaVu, como no C157); nenhum webfont remoto no PDF.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: comando com uma cidade → PDF A4 datado + `.md`; página 1 responde o job; seções 2+; guardrails de fonte/lacuna/defeso/PII; estimativas e N0–N4 completos para o candidato.
- [ ] Invariantes AGENTS/engineering-standards: sem collection/migration/Consent; leitura de produção read-only (fail-closed); `overrideAccess:false` com ator real nos loaders; nada de PII completa; nada commitado do artefato interno; `push:false` intocado; sem alias em `package.json`.
- [ ] Testes de domínio: unit da lib pura (blocos/render/pesquisa/emendas) + int do composer (caps, totais, proveniência, PII fora) onde as regras de acesso/leitura importam.
- [ ] Entrega: PDF+MD em `docs/research/relatorios-cidade/` (gitignored), HTML/JSONs em `data/relatorios-cidade/` (gitignored), skill+subagente, subseção no runbook, changelog `2026-09-15-c163.md`.
- [ ] CI verde com PR `Closes #1007`; `pnpm push` (gate) executado.
## Decisões de engenharia
1. **Como o gerador lê a produção.**
**Opções:** A extração no homeserver (Payload Local API + `PGOPTIONS` read-only) → snapshot JSON → render local | B túnel SSH `-L 5433` e tudo local | C tudo no homeserver com Chromium.
**Recomendação:** A — usa a receita do runbook (`socat 127.0.0.1:5433`), não move credencial para a workstation, separa leitura (remota) de render (local, C157) e deixa o snapshot como prova auditável.
**Rejeitadas:** B porque o guard local não distingue o proxy de um DB Docker (passe silencioso) e mantém lane de prod aberta na workstation; C porque instala Chromium no host de deploy.
2. **Onde vive o código.**
**Opções:** A `scripts/*.mjs` + `scripts/lib/*.mjs` puros, importando os donos de `src/utilities/**` | B novo loader em `src/utilities/municipality/` | C rota em `/campanha`.
**Recomendação:** A — o app não consome o relatório (1 call site), evita o pin de top-level e convenções de `server-only`, e mantém a lib pura unit-testável; o reúso continua via import dos donos (E16, rank, filtros de falas).
**Rejeitadas:** B porque cria superfície de `src` sem consumidor de app (DRY <3 call sites) sujeita a knip/convenções; C já rejeitada em E16/C157.
3. **Invocação do agente/skill.**
**Opções:** A skill `.agents/skills/relatorio-cidade/SKILL.md` + subagente `.opencode/agent/relatorio-cidade.md` | B só skill | C só command.
**Recomendação:** A — a intenção pede "agente + skill"; a skill guarda o conhecimento (checklist, shapes, comandos, guardrails) e o subagente é a casca de workflow; a pesquisa web se beneficia de contexto isolado.
**Rejeitadas:** B porque o "agente" é pedido explícito e o subagente é o gatilho; C porque um command é atalho redundante (a descrição do subagente já é a entrada) — barato, mas sem consumidor.
4. **Template e render.**
**Opções:** A blocos puros + dois renderers (HTML/PDF e MD) + print CSS próprio | B template HTML one-off no script (estilo C157) | C HTML externo com placeholders.
**Recomendação:** A — um modelo de conteúdo alimenta PDF e `.md`, então o companion não drifta; os blocos e renderers são unit-testáveis; o visual é o port do draft aprovado.
**Rejeitadas:** B porque o `.md` divergiria do HTML por construção; C por escaping/fragmentação sem ganho de teste.
5. **Fonte oficial de emendas.**
**Opções:** A Portal da Transparência `/api-de-dados/emendas` (header `chave-api-dados`, filtros `codigoMunicipio` + `ano`, autor filtrado em memória, fase empenhado/liquidado/pago/resto pago) | B Câmara Dados Abertos | C só web/manual.
**Recomendação:** A, com **fallback lacuna explícita** (sem chave, erro, zero linhas após filtro de autor ou homônimo ambíguo) e sem persistir nada; URL da consulta + `consultedAt` entram no item; janela dos anos do mandato (2023–2026, 4 requests ≪ rate limit 30/min).
**Rejeitadas:** B porque a API da Câmara não expõe emendas orçamentárias individuais (só emendas a proposições); C porque não é determinística nem datável/paginável.
6. **Companion `.md`, proveniência e visibilidade da entrega.**
**Opções:** A commit literal do PDF+MD em `docs/research/` | B escrever PDF+MD no caminho da intenção (`docs/research/relatorios-cidade/`) com o diretório gitignored e proveniência completa | C só PDF.
**Recomendação:** B — mantém o entregável e o caminho da intenção, preserva a revisão em diff local e evita publicar dados internos de campanha num repo **público**; cada bloco carrega `{kind: teqo|web|official, label, url?, date?}`, o `.md` fecha com "Fontes e limites" e o cabeçalho traz `readAt`, "snapshot do momento" e SHA do código (a base envelhece — o PDF é datado).
**Rejeitadas:** A porque viola o fail-closed de LGPD/estratégia do AGENTS (nomes/pledges/estimativas no GitHub público); C porque perde a revisão em diff pedida. Publicação sanitizada fica fora do appetite com gatilho: pedido explícito do CG por versão pública.
## Self-score
- **Decisões caras com rejeitadas: 5/5** — D1–D6 têm opções, recomendação e rejeitadas explícitas (inclusive a única reavaliação de entrega, D6).
- **Cabe no appetite: 4/5** — 2 scripts + lib pura + skill/agente + testes em 2–3 dias; risco de estouro nomeado (API de emendas, fidelidade do print CSS) com cortes prontos (lacuna; port do draft).
- **Rabbit holes nomeados: 4/5** — Tailwind CDN, Chromium no homeserver, alias `pnpm`, sanitização/publicação, segundo cadastro, scrape de vereadores, PII — todos explicitamente fora.
- **Reusa shells/helpers: 5/5** — E16 (`loadMunicipalityDossierData`, VM/detail), rank/share, filtros de falas, mecânica PDF do C157, `scripts/lib/cli.mjs`, fixtures de int, rascunho A4 como shell visual.
- **Aceite de produto satisfeito: 5/5** — página 1 fechada, guardrails literais, PDF datado e completo, lacuna explícita; única ressalva sinalizada é a visibilidade do artefato (D6), resolvida fail-closed.
Média **4,6/5** — gate ≥4 atendido.
## Simplificação e débitos (triage 2026-09-15)
**Aplicado na sessão** (2 revisores + fixes): `cityReportSnapshot.mjs` saiu de `scripts/lib/` (é integração, não lib pura) e reusa o dossier para atividades e `campaignRoleLabels` do dono; projeção enxuta (PII + campos sem leitor fora); exports mortos removidos (knip volta a 0); render sem duplo wrapper, `sourceLabel` único e `throw` em kind desconhecido nos dois formatos; `formatMoneyCompact` renomeado; `formatFreshness` reusa `municipalitySignalAgeInDays`; `buildPageOne` decomposto em builders; validação de shape do snapshot + aviso de divergência de SHA no builder; fallback `—` nos labels; fonte no card de oposição; constantes de truncamento; guarda de página 1 com orçamento nomeado; testes ajustados (contagem do contrato, região por label, PII tautológica removida — provada no int com contato real).
**Adiado com gatilho / explicitamente fora:**
- **Filtro de município nas emendas aceita linha sem código** (fail-open documentado: confia no filtro server-side `codigoMunicipio`). Gatilho: primeira extração real com `PORTAL_TRANSPARENCIA_API_KEY`; se a API não expuser código por linha, decidir entre manter o filtro documentado ou fail-closed (virar lacuna).
- **Formatters próprios vs donos** (`formatElectionNumber`, ordinal de colocação, fuso Bahia): política de null/`—` própria e copy do PDF; reavaliar se uma 2ª superfície precisar dos mesmos formatos.
- **`tableOrGap` / nota "Mostrando X de Y" repetidos** em 5 seções: DRY adiado (copy diverge de propósito); gatilho: 6ª seção.
- **Cobertura automatizada da guarda de página 1 e do fetch do Portal**: provados por execução do CLI/skill; sem teste por natureza (Chromium/rede).

View file

@ -0,0 +1,195 @@
/**
* City report builder (C163): snapshot + research (+ emendas) → PDF A4 + `.md`.
*
* Runs on the workstation: no database access, no persistence. Emendas come
* from the official API at generation time (or from `--emendas=<json>` for a
* replay) and are never stored in the base.
*
* Usage:
* NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-city-report.mjs \
* --snapshot=data/relatorios-cidade/<slug>.snapshot.json \
* --research=data/relatorios-cidade/<slug>.research.json \
* --out-dir=docs/research/relatorios-cidade
*/
import { chromium } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildCityReport } from './lib/cityReportBlocks.mjs'
import { renderReportHtml, renderReportMd } from './lib/cityReportRender.mjs'
import { normalizeResearchInput } from './lib/cityReportResearch.mjs'
import { dieWithLabel, isTruthyEnv, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
import {
DEFAULT_AUTHOR_NAME,
EMENDAS_YEARS,
fetchAuthorEmendas,
} from './lib/portalTransparenciaEmendas.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-city-report'
const die = dieWithLabel(LABEL)
const DEFAULT_OUT_DIR = 'docs/research/relatorios-cidade'
const CACHE_DIR = 'data/relatorios-cidade'
const MM_TO_PX = 96 / 25.4
const PDF_MARGINS_MM = { top: 14, bottom: 16, left: 12, right: 12 }
/** Measure the summary at the real printable width — screen 1280px wraps nothing. */
const PRINTABLE_WIDTH_PX = Math.round((210 - PDF_MARGINS_MM.left - PDF_MARGINS_MM.right) * MM_TO_PX)
const PRINTABLE_HEIGHT_PX = Math.round(
(297 - PDF_MARGINS_MM.top - PDF_MARGINS_MM.bottom) * MM_TO_PX,
)
/** Rounding slack of the mm→px conversion; content must still fit the page. */
const PAGE_ONE_TOLERANCE_PX = 8
const PAGE_ONE_BUDGET_PX = PRINTABLE_HEIGHT_PX - PAGE_ONE_TOLERANCE_PX
loadCliEnv()
const { flags } = parseEqualsFlags(process.argv.slice(2))
const snapshotPath = typeof flags.snapshot === 'string' ? flags.snapshot : null
const researchPath = typeof flags.research === 'string' ? flags.research : null
const emendasPath = typeof flags.emendas === 'string' ? flags.emendas : null
const outDir = typeof flags['out-dir'] === 'string' ? flags['out-dir'] : DEFAULT_OUT_DIR
const authorName = typeof flags.author === 'string' ? flags.author : DEFAULT_AUTHOR_NAME
const generatedAt =
typeof flags['generated-at'] === 'string' ? new Date(flags['generated-at']) : new Date()
if (!snapshotPath || !researchPath) {
die(
'Uso: --snapshot=<json> --research=<json> [--emendas=<json>] [--out-dir=<dir>] [--author=<nome>]',
)
}
if (Number.isNaN(generatedAt.getTime())) die(`--generated-at inválida: ${flags['generated-at']}`)
const readJson = async (relativePath, label) => {
try {
return JSON.parse(await readFile(resolve(ROOT, relativePath), 'utf8'))
} catch (error) {
die(
`Falha ao ler ${label} (${relativePath}): ${error instanceof Error ? error.message : error}`,
)
}
}
const snapshot = await readJson(snapshotPath, 'snapshot')
const research = normalizeResearchInput(await readJson(researchPath, 'pesquisa'), {
now: generatedAt,
})
const currentCodeSha = (() => {
try {
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim()
} catch {
return null
}
})()
if (snapshot.meta?.codeSha && currentCodeSha && snapshot.meta.codeSha !== currentCodeSha) {
console.warn(
`[${LABEL}] AVISO: snapshot extraído no SHA ${snapshot.meta.codeSha}, builder no ${currentCodeSha} — ` +
'o par deve ser o mesmo; regenere o snapshot se o extrator mudou.',
)
}
if (snapshot.municipality?.slug !== research.municipalitySlug) {
die(
`Pesquisa de "${research.municipalitySlug}" não corresponde ao snapshot de "${snapshot.municipality?.slug}" — pare e regenere.`,
)
}
const baseName = `${snapshot.municipality.slug}-${generatedAt.toISOString().slice(0, 10)}`
const cachePath = join(CACHE_DIR, `${baseName}.emendas.json`)
const resolveEmendas = async () => {
if (emendasPath) return readJson(emendasPath, 'emendas')
try {
const cached = JSON.parse(await readFile(resolve(ROOT, cachePath), 'utf8'))
console.log(`[${LABEL}] emendas: cache ${cachePath}`)
return cached
} catch (error) {
if (error?.code !== 'ENOENT') {
die(
`Cache de emendas ilegível (${cachePath}): ${error instanceof Error ? error.message : error}`,
)
}
}
const result = await fetchAuthorEmendas({
years: EMENDAS_YEARS,
municipalityCode: snapshot.municipality.ibgeCode,
authorName,
apiKey: process.env.PORTAL_TRANSPARENCIA_API_KEY ?? null,
consultedAt: generatedAt.toISOString(),
})
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
await writeFile(resolve(ROOT, cachePath), `${JSON.stringify(result, null, 2)}\n`)
console.log(
`[${LABEL}] emendas: ${result.status}${result.status === 'gap' ? ` (${result.reason})` : ` (${result.rows.length} registros)`} → ${cachePath}`,
)
return result
}
const emendas = await resolveEmendas()
const report = buildCityReport({ snapshot, research, emendas, generatedAt })
const html = renderReportHtml(report)
const markdown = renderReportMd(report)
const htmlPath = join(CACHE_DIR, `${baseName}.html`)
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
await writeFile(resolve(ROOT, htmlPath), html)
console.log(`[${LABEL}] HTML intermediário → ${htmlPath}`)
await mkdir(resolve(ROOT, outDir), { recursive: true })
const mdFile = join(outDir, `${baseName}.md`)
const pdfFile = join(outDir, `${baseName}.pdf`)
await writeFile(resolve(ROOT, mdFile), markdown)
console.log(`[${LABEL}] companion → ${mdFile}`)
const browser = await chromium.launch()
try {
const page = await browser.newPage()
await page.setViewportSize({ width: PRINTABLE_WIDTH_PX, height: PRINTABLE_HEIGHT_PX })
await page.setContent(html, { waitUntil: 'load' })
await page.emulateMedia({ media: 'print' })
const summaryScrollHeight = await page.evaluate(() => {
const element = document.querySelector('[data-page="summary"]')
return element ? element.scrollHeight : null
})
if (summaryScrollHeight === null) die('Bloco de resumo ausente no HTML — renderer quebrado.')
if (summaryScrollHeight > PAGE_ONE_BUDGET_PX) {
die(
`A página 1 estourou (${summaryScrollHeight}px > ${PAGE_ONE_BUDGET_PX}px úteis). ` +
'Corte copy/caps — o resumo TEM de caber em uma página.',
)
}
await page.pdf({
path: resolve(ROOT, pdfFile),
format: 'A4',
printBackground: true,
displayHeaderFooter: true,
headerTemplate: '<div></div>',
footerTemplate: `<div style="width:100%;font-family:'Fira Sans','DejaVu Sans',sans-serif;font-size:7pt;color:#a1a1aa;padding:0 12mm;display:flex;justify-content:space-between;">
<span>${report.meta.title} · ${snapshot.municipality.name} · documento interno de campanha</span>
<span>pág. <span class="pageNumber"></span>/<span class="totalPages"></span></span>
</div>`,
margin: { top: '14mm', bottom: '16mm', left: '12mm', right: '12mm' },
})
} finally {
await browser.close()
}
console.log(`[${LABEL}] PDF → ${pdfFile}`)
console.log(
`[${LABEL}] ${snapshot.municipality.name}: 2022=${snapshot.electoral.rank2022?.votes ?? 'sem dado'} ` +
`rank=${snapshot.electoral.rank2022?.rank ?? '—'} emendas=${emendas.status} ` +
`lacunas_pesquisa=${research.gaps.length} html_bytes=${Buffer.byteLength(html)}`,
)
if (isTruthyEnv(process.env.CITY_REPORT_STRICT) && research.gaps.length > 0) {
die(
`CITY_REPORT_STRICT=1 e ${research.gaps.length} lacuna(s) de pesquisa — o relatório saiu, mas o modo estrito falha.`,
)
}
process.exit(0)

View file

@ -0,0 +1,213 @@
/**
* Snapshot composer for the city report (C163).
*
* Runs on the homeserver against production, read-only: composes the same
* loaders the `/campanha` detail/dossier use (no parallel source of truth),
* projects out PII and fields the report does not need, and returns plain
* JSON. The render step never touches the database again.
*
* Integration layer, not a pure lib: it imports Payload-coupled utilities and
* is exercised by `tests/int/cityReportSnapshot.int.spec.ts`.
*/
import { getMunicipalityVoteRank } from '../src/lib/municipalityVoteRank.ts'
import { campaignRoleLabels } from '../src/utilities/campaignUserProfile.ts'
import { loadMunicipalityDossierData } from '../src/utilities/municipality/municipalityDossierData.ts'
import {
getMunicipalityDetailViewModel,
resolveAccessibleMunicipalityContext,
} from '../src/utilities/municipality/municipalityPageData.ts'
import { loadAdvisorSummaries } from '../src/utilities/municipality/municipalityViewModels.ts'
import { buildSpeechListWhere } from '../src/utilities/speech/speechListFilters.ts'
import { loadSegmentsForSpeeches } from '../src/utilities/speech/speechPageData.ts'
const SPEECH_LIMIT = 10
const DEMAND_LIMIT = 20
const projectActivity = (activity) => ({
id: activity.id,
title: activity.title,
status: activity.status,
startAt: activity.startAt,
locality: activity.locality,
})
const loadSpeeches = async (payload, actor, municipalityID) => {
const result = await payload.find({
collection: 'speech',
where: buildSpeechListWhere({ page: 1, municipalities: [municipalityID] }),
depth: 0,
limit: SPEECH_LIMIT,
sort: '-speechAt',
select: {
speechAt: true,
phase: true,
summary: true,
officialTextUrl: true,
},
user: actor,
overrideAccess: false,
})
const segmentsBySpeech = await loadSegmentsForSpeeches(
payload,
actor,
result.docs.map((doc) => doc.id),
)
return {
totalCount: result.totalDocs,
rows: result.docs.map((doc) => {
const first = (segmentsBySpeech.get(doc.id) ?? [])[0]
return {
id: doc.id,
speechAt: doc.speechAt ?? null,
phase: doc.phase ?? null,
officialTextUrl: doc.officialTextUrl ?? null,
excerpt: first?.text ?? doc.summary ?? null,
}
}),
}
}
const loadDemands = async (payload, actor, municipalityID) => {
const result = await payload.find({
collection: 'campaignDemand',
where: { municipality: { equals: municipalityID } },
depth: 0,
limit: DEMAND_LIMIT,
sort: '-updatedAt',
select: { title: true, kind: true, status: true, updatedAt: true },
user: actor,
overrideAccess: false,
})
return {
totalCount: result.totalDocs,
rows: result.docs.map((doc) => ({
id: doc.id,
title: doc.title,
kind: doc.kind,
status: doc.status,
updatedAt: doc.updatedAt,
})),
}
}
/**
* Composes the snapshot. `actor` must be a real `campaignUser` doc (the CLI
* resolves and fails closed); loaders run with `overrideAccess: false` so the
* snapshot is exactly what that role may read.
*
* @param {object} params
* @param {import('payload').Payload} params.payload
* @param {import('../src/payload-types.ts').CampaignUser} params.actor
* @param {string} params.slug
* @param {string} [params.readAt]
* @param {string | null} [params.codeSha]
* @param {string | null} [params.database]
*/
export const composeCityReportSnapshot = async ({
payload,
actor,
slug,
readAt = new Date().toISOString(),
codeSha = null,
database = null,
}) => {
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([
loadAdvisorSummaries(payload, actor, detail.advisorIDs),
loadSpeeches(payload, actor, context.id),
loadDemands(payload, actor, context.id),
])
const strategy = detail.strategy
const rank2022 = getMunicipalityVoteRank(slug)
return {
meta: {
source: 'Base Teqo (produção, leitura read-only)',
readAt,
readOnly: true,
database,
codeSha,
actorId: actor.id,
actorName: actor.name ?? null,
actorRole: actor.role,
actorRoleLabel: campaignRoleLabels[actor.role] ?? actor.role,
},
municipality: {
id: context.id,
slug: context.slug,
name: detail.name,
kind: detail.kind,
region: detail.region,
ibgeCode: detail.ibgeCode,
},
electoral: {
series: dossier.baseline?.series ?? [],
rank2022,
},
goal: dossier.goalAccount
? {
suggestedGoal: dossier.goalAccount.suggestedGoal,
goalCoverage: dossier.goalAccount.goalCoverage,
territorialClass: { class: dossier.goalAccount.territorialClass.class },
}
: null,
pledges: {
declaredTotal: dossier.pledgeAggregate.declaredTotal,
lastPledgeAt: dossier.pledgeAggregate.lastPledgeAt,
},
leaderships: {
totalCount: dossier.leaderships.totalCount,
rows: dossier.leaderships.rows.map((row) => ({
id: row.id,
name: row.name,
supportStatus: row.supportStatus,
organizationNames: row.organizationNames,
hasAppAccess: row.hasAppAccess,
updatedAt: row.updatedAt,
})),
},
advisors: advisors.map((advisor) => ({ id: advisor.id, name: advisor.name })),
signals: {
totalCount: dossier.signals.totalCount,
rows: dossier.signals.rows.map((row) => ({
id: row.id,
createdAt: row.createdAt,
body: row.body,
polarity: row.polarity,
urgent: row.urgent,
adversarySignal: row.adversarySignal,
responsibleName: row.responsibleName,
})),
},
activities: {
upcoming: dossier.upcomingActivities.map(projectActivity),
recent: dossier.recentActivities.map(projectActivity),
},
conjuncture: strategy
? {
priority: strategy.priority,
expectedVotes: strategy.expectedVotes,
politicalTrend: strategy.politicalTrend,
engagementLevel: strategy.engagementLevel,
levelNote: strategy.levelNote,
strengths: strategy.strengths,
risks: strategy.risks,
dobradinhaNotes: strategy.dobradinhaNotes,
nextSteps: strategy.nextSteps,
budgetNotes: strategy.budgetNotes,
stateDeputies: strategy.stateDeputies.map((deputy) => ({
id: deputy.id,
name: deputy.name,
party: deputy.party,
})),
}
: null,
speeches,
demands,
demographics: dossier.demographics,
}
}

View file

@ -0,0 +1,117 @@
/**
* Snapshot extractor for the city report (C163).
*
* Runs where production lives (homeserver), read-only by connection options,
* and writes a JSON snapshot consumed by `scripts/build-city-report.mjs`.
* Nothing is persisted in the database and the script never writes to it.
*
* Usage (from the homeserver, with `~/stack/teqo-1313.env` loaded):
* CITY_REPORT_CONFIRM=1 NODE_OPTIONS="--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs" \
* node scripts/extract-city-report-snapshot.mjs --municipality=<slug> --out=data/relatorios-cidade/<slug>.snapshot.json
*/
import { execFileSync } from 'node:child_process'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
databaseLabel,
READ_ONLY_PG_OPTIONS,
withReadOnlyDatabaseUrl,
} from './lib/cityReportDatabase.mjs'
import {
databaseHostname,
dieWithLabel,
isTruthyEnv,
loadCliEnv,
parseEqualsFlags,
} from './lib/cli.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'extract-city-report-snapshot'
const die = dieWithLabel(LABEL)
const codeSha = () => {
try {
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim()
} catch {
return null
}
}
const resolveActor = async (payload) => {
for (const role of ['candidate', 'coordinator']) {
const result = await payload.find({
collection: 'campaignUser',
where: { role: { equals: role } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
const doc = result.docs[0]
if (doc) return { ...doc, collection: 'campaignUser' }
}
die(
'Nenhum campaignUser com papel candidate/coordinator encontrado — o relatório exige um ator real que enxergue tudo.',
)
}
loadCliEnv()
const { flags } = parseEqualsFlags(process.argv.slice(2))
const slug = typeof flags.municipality === 'string' ? flags.municipality : null
const out = typeof flags.out === 'string' ? flags.out : null
if (!slug || !out) {
die(
'Uso: --municipality=<slug> --out=<arquivo.json>\n' +
' Env: CITY_REPORT_CONFIRM=1 (obrigatório), DATABASE_URL (read-only é forçado), PORTAL da vida real.',
)
}
if (!isTruthyEnv(process.env.CITY_REPORT_CONFIRM)) {
die(
'Opt-in explícito ausente: rode com CITY_REPORT_CONFIRM=1.\n' +
` Alvo: ${databaseHostname(process.env.DATABASE_URL) ?? 'DATABASE_URL ausente'} — a leitura é read-only, mas o comando não adivinha o alvo.`,
)
}
const rawDatabaseUrl = process.env.DATABASE_URL
process.env.DATABASE_URL = withReadOnlyDatabaseUrl(rawDatabaseUrl, READ_ONLY_PG_OPTIONS)
console.log(
`[${LABEL}] alvo ${databaseLabel(rawDatabaseUrl)} · sessão read-only · município ${slug}`,
)
const { default: configPromise } = await import('../src/payload.config.ts')
const { getPayload } = await import('payload')
const { composeCityReportSnapshot } = await import('./cityReportSnapshot.mjs')
const payload = await getPayload({ config: configPromise })
try {
const actor = await resolveActor(payload)
console.log(`[${LABEL}] ator: ${actor.name ?? actor.id} (${actor.role})`)
const snapshot = await composeCityReportSnapshot({
payload,
actor,
slug,
readAt: new Date().toISOString(),
codeSha: codeSha(),
database: databaseLabel(rawDatabaseUrl),
})
const outPath = resolve(ROOT, out)
await mkdir(dirname(outPath), { recursive: true })
await writeFile(outPath, `${JSON.stringify(snapshot, null, 2)}\n`)
console.log(
`[${LABEL}] snapshot de ${snapshot.municipality.name} → ${out} ` +
`(lideranças=${snapshot.leaderships.totalCount} sinais=${snapshot.signals.totalCount} ` +
`falas=${snapshot.speeches.totalCount} demandas=${snapshot.demands.totalCount})`,
)
} catch (error) {
die(error instanceof Error ? error.message : String(error))
}
process.exit(0)

View file

@ -0,0 +1,902 @@
/**
* Content model of the city report (C163). Pure: takes the snapshot (base Teqo,
* read-only), the validated research JSON and the emendas result, and returns
* the block tree both the PDF and the companion `.md` render.
*
* The order of the blocks is the product contract (intention plan, "Estrutura
* do relatório"): page 1 is the one-look summary; sections 1–10 are the deep
* dive. "Sem fonte, não publica": every claim carries its source; what is
* missing becomes an explicit gap.
*/
import { getMunicipalityFederalBaseline } from '../../src/lib/bahiaElectionAggregates.ts'
import { formatEngagementLevelLabel } from '../../src/lib/engagementLevel.ts'
import { getMunicipalityVoteRank } from '../../src/lib/municipalityVoteRank.ts'
import { activityStatusLabels } from '../../src/lib/schemas/activity.ts'
import {
campaignDemandKindLabels,
campaignDemandStatusLabels,
} from '../../src/lib/schemas/campaignDemand.ts'
import { municipalityUpdatePolarityLabels } from '../../src/lib/schemas/municipalityUpdate.ts'
import { voteEstimateScenarioLabels } from '../../src/lib/voteEstimate.ts'
import { supportStatusLabels } from '../../src/utilities/leadership/leadershipLabels.ts'
import {
municipalityPriorityLabels,
politicalTrendLabels,
territorialClassLabels,
} from '../../src/utilities/municipality/municipalityLabels.ts'
import {
formatDateBr,
formatDateTimeBr,
formatFreshness,
formatInteger,
formatMoneyCompact,
formatPercent,
formatRank,
} from './cityReportFormat.mjs'
import { researchItemById } from './cityReportResearch.mjs'
import { buildTerritoryPanorama } from './cityReportTerritory.mjs'
const REPORT_TITLE = 'Relatório de cidade — pré-viagem'
const GAP_COPY = 'Lacuna explícita — não preencher por inferência.'
const ADVERTENCIA_DEFESO =
'Ano eleitoral: empenho não é pagamento. O bloco de emendas mostra a fase (empenhada, liquidada, paga, restos).'
const GAP_CALLOUT_LIMIT = 4
const SIGNAL_EXCERPT_MAX = 180
const SPEECH_EXCERPT_MAX = 220
const sourceTeqo = (label, date) => ({ kind: 'teqo', label, date: date ?? null })
const sourceWeb = (label, url, date) => ({
kind: 'web',
label,
url: url ?? null,
date: date ?? null,
})
const sourceOfficial = (label, url, date) => ({
kind: 'official',
label,
url: url ?? null,
date: date ?? null,
})
const researchAnswerItem = (research, id) => {
const item = researchItemById(research, id)
if (!item) return null
return {
text: item.answer,
source: sourceWeb('Pesquisa web', item.sourceUrl, item.sourceDate),
}
}
const gapCallout = (gaps, title = 'Lacunas desta seção') => {
if (!gaps.length) return null
const shown = gaps.slice(0, GAP_CALLOUT_LIMIT)
const remaining = gaps.length - shown.length
return {
kind: 'callout',
tone: 'gap',
title,
body: [
GAP_COPY,
...shown.map((gap) => `${gap.label ? `${gap.label}: ` : ''}${gap.reason}`),
...(remaining > 0 ? [`e mais ${remaining} ponto(s) sem leitura.`] : []),
],
}
}
const 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 },
{
label: 'Prioridade',
value: conjuncture ? (municipalityPriorityLabels[conjuncture.priority] ?? '—') : '—',
},
{
label: 'Classe',
value: goal ? (territorialClassLabels[goal.territorialClass.class] ?? '—') : '—',
},
{
label: 'Nível',
value: conjuncture?.engagementLevel
? formatEngagementLevelLabel(conjuncture.engagementLevel)
: 'Sem nível',
},
{
label: 'Responsável',
value: advisors.length ? advisors.map((advisor) => advisor.name).join(', ') : 'Sem responsável',
},
]
const buildElectoralKpis = ({ electoral, 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 buildWhoRows = ({ leaderships, advisors, conjuncture }, research, prefeito) => {
const rows = []
const simpleAnswers = [
{ id: 'prefeito', label: 'Prefeito(a)', item: prefeito },
{ id: 'vice', label: 'Vice', item: researchAnswerItem(research, 'vice') },
{
id: 'relacao_campo',
label: 'Relação com o campo',
item: researchAnswerItem(research, 'relacao_campo'),
},
]
for (const { label, item } of simpleAnswers) {
if (item) rows.push({ label, value: item.text, source: item.source })
}
rows.push({
label: 'Lideranças',
value: `${formatInteger(leaderships.totalCount)} na base`,
hint: advisors.length ? null : 'sem responsável vinculado',
})
const dobradinhas = conjuncture?.stateDeputies?.length
? conjuncture.stateDeputies.map((deputy) => deputy.name).join(', ')
: null
rows.push({ label: 'Dobradinhas', value: dobradinhas ?? 'sem registro na base' })
const vereadores = researchAnswerItem(research, 'vereadores')
if (vereadores) {
rows.push({ label: 'Vereadores/dobradas', value: vereadores.text, source: vereadores.source })
}
return rows
}
const buildDeliveredKpis = ({ speeches }, research, emendas) => {
const emendasOk = emendas?.status === 'ok'
return [
{
label: 'Emendas aportadas',
value: emendasOk ? formatMoneyCompact(emendas.totals.empenhado) : 'Sem fonte oficial',
hint: emendasOk
? `Pago: ${formatMoneyCompact(emendas.totals.pago)} · ${emendas.rows.length} emenda(s)`
: (emendas?.reason ?? null),
},
{
label: 'Falas no acervo',
value: `${formatInteger(speeches.totalCount)} trecho(s)`,
hint: 'Busca por município no acervo (C153/C155)',
},
{
label: 'Notícias (≤90 dias)',
value: `${formatInteger(research.news.length)} matéria(s)`,
hint: 'Pesquisa web datada',
},
]
}
const buildAnnouncePair = (research) => {
const relacao = researchAnswerItem(research, 'relacao_campo')
const items = []
if (relacao) {
items.push({ text: `Relação local confirmada: ${relacao.text}`, source: relacao.source })
}
items.push(
{ text: 'Pauta ou entrega com fonte e data ao lado da frase.' },
{ text: 'Agradecimento institucional com papel confirmado.' },
{ text: 'Compromisso novo com escopo que a campanha cumpre.' },
)
return {
kind: 'pair',
left: { title: 'O que anunciar agora', tone: 'decision', items },
right: {
title: 'O que NÃO anunciar',
tone: 'risk',
items: [
{ text: 'Empenho tratado como pagamento (defeso).' },
{ text: 'Nada sem fonte ("sem fonte, não publica").' },
{ text: 'Dado staff-only fora da coordenação.' },
{ text: 'PII além do necessário.' },
],
},
note: ADVERTENCIA_DEFESO,
}
}
const buildRiskCards = ({ leaderships, advisors, conjuncture }, research) => {
const opposition =
researchAnswerItem(research, 'disputa_local') ?? researchAnswerItem(research, 'quem_investe')
return [
{
title: 'Oposição local',
body: opposition
? `${opposition.text} (fonte: ${opposition.source.url} · ${formatDateBr(opposition.source.date)})`
: 'Sem leitura suficiente — lacuna',
},
{
title: 'Riscos na base',
body: conjuncture?.risks?.length ? conjuncture.risks.join(' · ') : 'Nenhum risco registrado',
},
{
title: 'Rede',
body: leaderships.totalCount
? `${formatInteger(leaderships.totalCount)} liderança(s)${advisors.length ? '' : ' · sem responsável'}`
: 'Nenhuma liderança na base',
},
]
}
const collectPageOneGaps = ({ electoral, goal, prefeito }, emendas) => {
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 buildPageOne = ({ snapshot, research, emendas, generatedAt }) => {
const prefeito = researchAnswerItem(research, 'prefeito')
const gapCalloutBlock = gapCallout(
collectPageOneGaps({ electoral: snapshot.electoral, goal: snapshot.goal, prefeito }, emendas),
'Pontos sem leitura',
)
return {
blocks: [
{ kind: 'strip', items: buildIdentificationItems(snapshot) },
{
kind: 'grid',
columns: 2,
cells: [
{
title: 'Conta eleitoral 2022',
blocks: [
{ kind: 'kpis', items: buildElectoralKpis(snapshot) },
{
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,
},
],
},
],
},
{
title: 'Quem é quem',
blocks: [{ kind: 'stats', rows: buildWhoRows(snapshot, research, prefeito) }],
},
],
sources: [
sourceTeqo('base Teqo — TSE 2022, conta da cadeira e lideranças', snapshot.meta?.readAt),
],
},
{
kind: 'kpis',
title: 'O que Solla entregou',
items: buildDeliveredKpis(snapshot, research, emendas),
sources: [
sourceOfficial(
'Portal da Transparência — emendas',
emendas?.sourceUrl,
emendas?.consultedAt,
),
sourceTeqo('base Teqo — acervo de falas', snapshot.meta?.readAt),
sourceWeb('pesquisa web — imprensa', null, research.researchedAt),
],
},
buildAnnouncePair(research),
{
kind: 'cards',
title: 'Riscos — oposição/disputa local',
items: buildRiskCards(snapshot, research),
},
gapCalloutBlock,
{
kind: 'footerNote',
text: `Gerado em ${formatDateBr(generatedAt)} · snapshot do momento · fontes item a item nas seções de aprofundamento.`,
},
].filter(Boolean),
}
}
const buildElectoralSection = ({ snapshot }) => {
const { municipality, electoral } = snapshot
const years = [2014, 2018, 2022]
const rows = years.map((year) => {
const votes = electoral.series.find((point) => point.year === year)?.votes ?? null
const rank = getMunicipalityVoteRank(municipality.slug, year)
const validVotes = getMunicipalityFederalBaseline(municipality.slug).validVotesByYear[
String(year)
]
return {
year: String(year),
votes: votes === null ? '—' : formatInteger(votes),
share: rank ? formatPercent(rank.share) : '—',
rankLabel: formatRank(rank),
validVotes: validVotes ? formatInteger(validVotes) : '—',
}
})
const gap = electoral.series.length
? null
: {
id: 'serie',
label: 'Série 2014/2018/2022',
reason: 'Sem série na base para este município.',
}
return [
{
kind: 'table',
title: 'Conta eleitoral completa',
columns: [
{ key: 'year', label: 'Ano' },
{ key: 'votes', label: 'Votos', numeric: true },
{ key: 'share', label: '% do próprio voto', numeric: true },
{ key: 'rankLabel', label: 'Rank', numeric: true },
{ key: 'validVotes', label: 'Válidos do cargo', numeric: true },
],
rows,
sources: [sourceTeqo('base Teqo — artefato TSE 2014/2018/2022', snapshot.meta?.readAt)],
},
gap ? gapCallout([gap], 'Lacunas desta seção') : null,
].filter(Boolean)
}
const buildNetworkSection = ({ snapshot }) => {
const { leaderships, advisors } = snapshot
const rows = leaderships.rows.map((row) => ({
name: row.name,
support: row.supportStatus ? supportStatusLabels[row.supportStatus] : '—',
organizations: row.organizationNames.length ? row.organizationNames.join(', ') : '—',
access: row.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app',
freshness: formatFreshness(row.updatedAt),
}))
const blocks = []
if (rows.length) {
blocks.push({
kind: 'table',
title: 'Rede e lideranças',
columns: [
{ key: 'name', label: 'Liderança' },
{ key: 'support', label: 'Apoio' },
{ key: 'organizations', label: 'Organizações' },
{ key: 'access', label: 'Acesso' },
{ key: 'freshness', label: 'Frescor' },
],
rows,
note:
leaderships.totalCount > rows.length
? `Mostrando ${rows.length} de ${leaderships.totalCount} lideranças (mesmo corte do dossiê).`
: null,
sources: [sourceTeqo('base Teqo — lideranças e pledges', snapshot.meta?.readAt)],
})
} else {
blocks.push({
kind: 'callout',
tone: 'gap',
title: 'Rede e lideranças',
body: ['Nenhuma liderança vinculada a este município na base.', GAP_COPY],
})
}
blocks.push({
kind: 'stats',
title: 'Responsáveis',
rows: [
{
label: 'Assessores do município',
value: advisors.length
? advisors.map((advisor) => advisor.name).join(', ')
: 'Sem responsável',
},
{ label: 'Pledges declarados', value: formatInteger(snapshot.pledges.declaredTotal) },
{
label: 'Última movimentação de pledge',
value: snapshot.pledges.lastPledgeAt
? formatFreshness(snapshot.pledges.lastPledgeAt)
: 'sem registro',
},
],
sources: [sourceTeqo('base Teqo — pledges', snapshot.meta?.readAt)],
})
return blocks
}
const buildConjunctureSection = ({ snapshot }) => {
const conjuncture = snapshot.conjuncture
const trend = conjuncture?.politicalTrend
const rows = [
{
label: 'Tendência política',
value: trend?.status ? (politicalTrendLabels[trend.status] ?? 'Sem leitura') : 'Sem leitura',
hint: trend?.note ?? null,
},
{
label: 'Registrada por',
value: trend?.recordedByName ?? '—',
hint: trend?.recordedAt ? formatDateBr(trend.recordedAt) : null,
},
{
label: 'Nível de envolvimento',
value: conjuncture?.engagementLevel
? formatEngagementLevelLabel(conjuncture.engagementLevel)
: 'Sem nível',
hint: conjuncture?.levelNote ?? null,
},
{
label: 'Expectativa de votos',
value: conjuncture?.expectedVotes
? ['pessimistic', 'central', 'optimistic']
.map(
(scenario) =>
`${voteEstimateScenarioLabels[scenario]} ${formatInteger(conjuncture.expectedVotes[scenario])}`,
)
.join(' · ')
: '—',
hint: 'Cenários da base',
},
]
const blocks = [
{
kind: 'stats',
title: 'Conjuntura',
rows,
sources: [sourceTeqo('base Teqo — estratégia do município', snapshot.meta?.readAt)],
},
]
if (conjuncture?.strengths?.length) {
blocks.push({
kind: 'bullets',
title: 'Forças',
items: conjuncture.strengths.map((text) => ({ text })),
})
}
if (conjuncture?.risks?.length) {
blocks.push({
kind: 'bullets',
title: 'Riscos',
items: conjuncture.risks.map((text) => ({ text })),
})
}
if (conjuncture?.dobradinhaNotes) {
blocks.push({ kind: 'prose', title: 'Dobradinhas', text: conjuncture.dobradinhaNotes })
}
if (conjuncture?.nextSteps) {
blocks.push({ kind: 'prose', title: 'Próximos passos', text: conjuncture.nextSteps })
}
if (conjuncture?.budgetNotes) {
blocks.push({
kind: 'prose',
title: 'Emendas aportadas (nota da base)',
text: conjuncture.budgetNotes,
sources: [sourceTeqo('base Teqo — nota manual', snapshot.meta?.readAt)],
})
}
return blocks
}
const buildSignalsSection = ({ snapshot }) => {
const { signals } = snapshot
if (!signals.rows.length) {
return [
{
kind: 'callout',
tone: 'gap',
title: 'Sinais recentes',
body: ['Nenhum sinal registrado para o município na base.', GAP_COPY],
},
]
}
return [
{
kind: 'table',
title: 'Sinais recentes',
columns: [
{ key: 'date', label: 'Data' },
{ key: 'polarity', label: 'Polaridade' },
{ key: 'flags', label: 'Sinal' },
{ key: 'responsible', label: 'Responsável' },
{ key: 'excerpt', label: 'Resumo' },
],
rows: signals.rows.map((row) => ({
date: formatDateBr(row.createdAt),
polarity: municipalityUpdatePolarityLabels[row.polarity] ?? '—',
flags: [row.urgent ? 'Urgente' : null, row.adversarySignal ? 'Adversário' : null]
.filter(Boolean)
.join(' · '),
responsible: row.responsibleName ?? '—',
excerpt: row.body ? row.body.slice(0, SIGNAL_EXCERPT_MAX) : '—',
})),
note:
signals.totalCount > signals.rows.length
? `Mostrando ${signals.rows.length} de ${signals.totalCount} sinais (mesmo corte do dossiê).`
: null,
sources: [sourceTeqo('base Teqo — sinais do município', snapshot.meta?.readAt)],
},
]
}
const buildDemandsVisitsSection = ({ snapshot }) => {
const { demands, activities } = snapshot
const blocks = []
if (demands.rows.length) {
blocks.push({
kind: 'table',
title: 'Demandas',
columns: [
{ key: 'title', label: 'Demanda' },
{ key: 'kind', label: 'Tipo' },
{ key: 'status', label: 'Status' },
{ key: 'updatedAt', label: 'Última atualização' },
],
rows: demands.rows.map((row) => ({
title: row.title,
kind: campaignDemandKindLabels[row.kind] ?? row.kind,
status: campaignDemandStatusLabels[row.status] ?? row.status,
updatedAt: formatDateBr(row.updatedAt),
})),
note:
demands.totalCount > demands.rows.length
? `Mostrando ${demands.rows.length} de ${demands.totalCount} demandas.`
: null,
sources: [sourceTeqo('base Teqo — demandas', snapshot.meta?.readAt)],
})
} else {
blocks.push({
kind: 'callout',
tone: 'gap',
title: 'Demandas',
body: ['Nenhuma demanda vinculada ao município na base.', GAP_COPY],
})
}
const activityRows = [
...activities.upcoming.map((activity) => ({ ...activity, bucket: 'Agendada' })),
...activities.recent.map((activity) => ({ ...activity, bucket: 'Realizada' })),
]
if (activityRows.length) {
blocks.push({
kind: 'table',
title: 'Visitas e agenda',
columns: [
{ key: 'bucket', label: 'Situação' },
{ key: 'date', label: 'Data' },
{ key: 'title', label: 'Atividade' },
{ key: 'locality', label: 'Local' },
{ key: 'status', label: 'Status' },
],
rows: activityRows.map((activity) => ({
bucket: activity.bucket,
date: activity.startAt ? formatDateTimeBr(activity.startAt) : '—',
title: activity.title,
locality: activity.locality ?? '—',
status: activityStatusLabels[activity.status] ?? activity.status,
})),
sources: [sourceTeqo('base Teqo — atividades', snapshot.meta?.readAt)],
})
} else {
blocks.push({
kind: 'callout',
tone: 'gap',
title: 'Visitas e agenda',
body: ['Nenhuma atividade vinculada ao município na base.', GAP_COPY],
})
}
return blocks
}
const buildDemographicsSection = ({ snapshot }) => {
const demographics = snapshot.demographics
if (!demographics) {
return [
{
kind: 'callout',
tone: 'gap',
title: 'Demografia',
body: ['Sem demografia para o código IBGE deste município.', GAP_COPY],
},
]
}
return [
{
kind: 'stats',
title: 'Demografia',
rows: [
{ label: 'População', value: formatInteger(demographics.population) },
{ label: '0–17 anos', value: formatInteger(demographics.ageBands['0-17']) },
{ label: '18–29 anos', value: formatInteger(demographics.ageBands['18-29']) },
{ label: '30–59 anos', value: formatInteger(demographics.ageBands['30-59']) },
{ label: '60+ anos', value: formatInteger(demographics.ageBands['60+']) },
{ label: 'Mulheres', value: formatPercent(demographics.sexShareFemale) },
{
label: 'Idade mediana',
value:
demographics.medianAge === null ? '—' : `${formatInteger(demographics.medianAge)} anos`,
},
],
sources: [sourceTeqo('IBGE Censo 2022 (artefato commitado)', null)],
},
]
}
const buildSpeechesSection = ({ snapshot }) => {
const { speeches } = snapshot
if (!speeches.rows.length) {
return [
{
kind: 'callout',
tone: 'gap',
title: 'Acervo de falas',
body: [
`Busca por município no acervo: nenhum trecho encontrado (${formatInteger(speeches.totalCount)} no total).`,
GAP_COPY,
],
},
]
}
return [
{
kind: 'table',
title: 'Acervo de falas',
columns: [
{ key: 'date', label: 'Data' },
{ key: 'phase', label: 'Fase' },
{ key: 'excerpt', label: 'Trecho' },
{ 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) : '—',
link: row.officialTextUrl ?? '—',
})),
note:
speeches.totalCount > speeches.rows.length
? `Mostrando ${speeches.rows.length} de ${speeches.totalCount} falas.`
: null,
sources: [sourceTeqo('base Teqo — acervo C153/C155', snapshot.meta?.readAt)],
},
]
}
const buildNewsSection = ({ research }) => {
const blocks = []
if (research.news.length) {
blocks.push({
kind: 'table',
title: 'Notícias internas e imprensa local',
columns: [
{ key: 'date', label: 'Data' },
{ key: 'outlet', label: 'Veículo' },
{ key: 'title', label: 'Título' },
{ key: 'link', label: 'Link' },
],
rows: research.news.map((item) => ({
date: formatDateBr(item.publishedAt),
outlet: item.outlet ?? '—',
title: item.title,
link: item.url,
})),
note: 'Janela de 90 dias; cada item com URL.',
sources: [sourceWeb('pesquisa web datada', null, research.researchedAt)],
})
} else {
blocks.push({
kind: 'callout',
tone: 'gap',
title: 'Notícias internas e imprensa local',
body: [
'Nenhuma matéria com fonte e data dentro da janela de 90 dias.',
'Nenhum post interno relacionado ao município (a base não liga Post a município).',
GAP_COPY,
],
})
}
const newsGaps = research.gaps.filter((gap) => gap.id.startsWith('noticia_'))
const callout = gapCallout(newsGaps, 'Notícias descartadas')
if (callout) blocks.push(callout)
return blocks
}
const buildRegionSection = ({ snapshot }) => {
const panorama = buildTerritoryPanorama(snapshot.municipality.slug)
if (!panorama) {
return [
{
kind: 'callout',
tone: 'gap',
title: 'Panorama regional',
body: ['Município fora do catálogo/artefato TSE.', GAP_COPY],
},
]
}
return [
{
kind: 'stats',
title: 'Panorama regional (Território de Identidade)',
rows: [
{ label: 'Território', value: panorama.region },
{ label: 'Municípios no TI', value: formatInteger(panorama.municipalityCount) },
{ label: `Votos do TI em ${panorama.year}`, value: formatInteger(panorama.regionVotes) },
{
label: '% do voto do candidato no estado',
value: formatPercent(panorama.regionShare),
},
{
label: 'Posição do município no TI',
value: `${formatInteger(panorama.municipalityRank)}º de ${formatInteger(panorama.municipalityTotalUnits)}`,
},
{
label: 'Votos do município no TI',
value: formatInteger(panorama.municipalityVotes),
},
],
sources: [sourceTeqo('artefato TSE commitado — leitura relativa', null)],
},
]
}
const buildSourcesSection = ({ snapshot, research, emendas }) => {
const items = []
items.push({
kind: 'teqo',
label: `Base Teqo — leitura read-only em ${formatDateTimeBr(snapshot.meta?.readAt)} (${snapshot.meta?.database ?? 'produção'})`,
date: snapshot.meta?.readAt,
})
for (const item of research.items) {
items.push({
kind: 'web',
label: item.label,
url: item.sourceUrl,
date: item.sourceDate,
})
}
if (emendas?.status === 'ok') {
items.push({
kind: 'official',
label: `Portal da Transparência — emendas de ${emendas.authorName ?? 'autor'} (${emendas.rows.length} registro(s))`,
url: emendas.sourceUrl,
date: emendas.consultedAt,
})
} else if (emendas) {
items.push({
kind: 'official',
label: `Portal da Transparência — sem resultado utilizável (${emendas.reason})`,
url: emendas.sourceUrl,
date: emendas.consultedAt,
})
}
for (const item of research.news) {
items.push({ kind: 'web', label: item.title, url: item.url, date: item.publishedAt })
}
return [
{
kind: 'sources',
title: 'Fontes e limites',
items,
limits: [
`Base Teqo: leitura read-only em ${formatDateTimeBr(snapshot.meta?.readAt)} — o PDF é datado e o dado envelhece (snapshot do momento).`,
'Web: cada item carrega data + URL; o que não veio com fonte virou lacuna.',
'Não existe voto por bairro/seção — o relatório não inventa granularidade.',
'Efeito eleitoral de emenda é condicional ao crédito local — o PDF não promete voto.',
'Emendas são lidas da fonte oficial em tempo de geração e não são persistidas na base.',
],
},
]
}
const SNAPSHOT_REQUIRED_GROUPS = [
'electoral',
'pledges',
'leaderships',
'advisors',
'signals',
'activities',
'speeches',
'demands',
]
const assertSnapshotShape = (snapshot) => {
if (!snapshot?.municipality?.slug || !snapshot?.municipality?.name) {
throw new Error('Snapshot inválido: falta municipality.slug/name — regenere com o extrator.')
}
for (const group of SNAPSHOT_REQUIRED_GROUPS) {
if (!(group in snapshot)) {
throw new Error(
`Snapshot inválido: falta "${group}" — extraia de novo com o mesmo SHA do builder.`,
)
}
}
if (!Array.isArray(snapshot.electoral?.series)) {
throw new Error('Snapshot inválido: electoral.series ausente — extraia de novo.')
}
}
export const buildCityReport = ({ snapshot, research, emendas, generatedAt = new Date() }) => {
assertSnapshotShape(snapshot)
const generatedAtIso = generatedAt instanceof Date ? generatedAt.toISOString() : generatedAt
const sections = [
{
id: 'conta-eleitoral',
title: '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: 'demandas',
title: '5. 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: 'noticias',
title: '8. Notícias internas e imprensa local',
blocks: buildNewsSection({ research }),
},
{
id: 'regiao',
title: '9. Panorama regional (Território de Identidade)',
blocks: buildRegionSection({ snapshot }),
},
{
id: 'fontes',
title: '10. Fontes e limites',
blocks: buildSourcesSection({ snapshot, research, emendas }),
},
]
return {
meta: {
title: REPORT_TITLE,
municipalityName: snapshot.municipality.name,
municipalitySlug: snapshot.municipality.slug,
region: snapshot.municipality.region,
generatedAt: generatedAtIso,
generatedAtLabel: formatDateBr(generatedAtIso),
readAt: snapshot.meta?.readAt ?? null,
readAtLabel: formatDateTimeBr(snapshot.meta?.readAt),
database: snapshot.meta?.database ?? null,
codeSha: snapshot.meta?.codeSha ?? null,
actorRole: snapshot.meta?.actorRole ?? null,
researchedAt: research.researchedAt,
},
page1: buildPageOne({ snapshot, research, emendas, generatedAt: generatedAtIso }),
sections,
}
}

View file

@ -0,0 +1,35 @@
/**
* Read-only database plumbing for the city report extraction (C163).
*
* The extraction runs against production on the homeserver. The read-only
* guarantee is set at the connection level (Postgres `options` startup
* parameter): every statement of the session fails if it tries to write — the
* guard does not depend on the script being careful.
*/
export const READ_ONLY_PG_OPTIONS = '-c default_transaction_read_only=on -c statement_timeout=30000'
export const withReadOnlyDatabaseUrl = (databaseUrl, options = READ_ONLY_PG_OPTIONS) => {
if (typeof databaseUrl !== 'string' || databaseUrl.trim() === '') {
throw new Error('DATABASE_URL ausente — a extração read-only precisa do alvo.')
}
let url
try {
url = new URL(databaseUrl)
} catch {
throw new Error('DATABASE_URL inválida.')
}
const existing = url.searchParams.get('options')
url.searchParams.set('options', existing ? `${existing} ${options}` : options)
return url.toString()
}
/** `host:porta/banco` — connection label without credentials, for the report footer. */
export const databaseLabel = (databaseUrl) => {
try {
const url = new URL(databaseUrl)
return `${url.hostname}:${url.port || '5432'}${url.pathname}`
} catch {
return 'desconhecida'
}
}

View file

@ -0,0 +1,88 @@
/**
* pt-BR formatters shared by the city report blocks and renderers (C163).
* Pure and unit-tested: the PDF and the companion `.md` must read identically.
*/
import { municipalitySignalAgeInDays } from '../../src/utilities/municipality/municipalitySignal.ts'
const integerFormatter = new Intl.NumberFormat('pt-BR')
const decimalFormatters = new Map()
const decimalFormatter = (digits) => {
const cached = decimalFormatters.get(digits)
if (cached) return cached
const formatter = new Intl.NumberFormat('pt-BR', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})
decimalFormatters.set(digits, formatter)
return formatter
}
const toFinite = (value) => {
if (value === null || value === undefined || value === '') return null
const number = Number(value)
return Number.isFinite(number) ? number : null
}
export const formatInteger = (value) => {
const number = toFinite(value)
return number === null ? '—' : integerFormatter.format(Math.round(number))
}
const formatDecimal = (value, digits = 1) => {
const number = toFinite(value)
return number === null ? '—' : decimalFormatter(digits).format(number)
}
export const formatPercent = (ratio, digits = 1) => {
if (ratio === null || ratio === undefined || !Number.isFinite(Number(ratio))) return '—'
return `${formatDecimal(Number(ratio) * 100, digits)}%`
}
export const formatMoneyCompact = (value) => {
const number = toFinite(value)
if (number === null) return '—'
if (Math.abs(number) < 1_000_000) {
const thousands = number / 1000
return `R$ ${formatDecimal(thousands, Math.abs(thousands) >= 100 ? 0 : 1)} mil`
}
const millions = number / 1_000_000
if (Math.abs(millions) >= 1000) return `R$ ${formatDecimal(millions / 1000)} bi`
return `R$ ${formatDecimal(millions)} mi`
}
export const formatDateBr = (value) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('pt-BR', { timeZone: 'America/Bahia' }).format(date)
}
export const formatDateTimeBr = (value) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('pt-BR', {
timeZone: 'America/Bahia',
dateStyle: 'short',
timeStyle: 'short',
}).format(date)
}
export const formatFreshness = (value, now = new Date()) => {
const days = municipalitySignalAgeInDays(value, now)
if (days === null) return 'sem registro'
if (days <= 0) return 'atualizado hoje'
if (days === 1) return 'atualizado ontem'
if (days < 14) return `atualizado há ${days} dias`
const weeks = Math.floor(days / 7)
if (weeks < 9) return `atualizado há ${weeks} semanas`
const months = Math.floor(days / 30)
return `atualizado há ${months} ${months === 1 ? 'mês' : 'meses'}`
}
export const formatRank = (entry) => {
if (!entry || !Number.isFinite(Number(entry.rank))) return '—'
return `${formatInteger(entry.rank)}º de ${formatInteger(entry.totalUnits)}`
}

View file

@ -0,0 +1,471 @@
/**
* Renderers for the city report (C163): the block tree goes to print HTML
* (Chromium `page.pdf`, A4) and to the companion `.md` with the same reading.
* Pure and unit-tested — the two outputs come from one content model, so the
* companion cannot drift from the PDF.
*/
import { formatDateBr, formatDateTimeBr } from './cityReportFormat.mjs'
const htmlEscape = (value) =>
String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
const sourceKindLabels = {
teqo: 'base Teqo',
web: 'pesquisa web',
official: 'fonte oficial',
}
const sourceLabel = (source) => {
const parts = [sourceKindLabels[source.kind] ?? 'fonte']
if (source.label) parts.push(source.label)
if (source.date) parts.push(formatDateBr(source.date))
if (source.url) parts.push(source.url)
return parts.join(' · ')
}
const PRINT_CSS = `
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: 'Fira Sans', 'DejaVu Sans', sans-serif;
color: #18181b;
font-size: 9.4pt;
line-height: 1.35;
}
.summary {
min-height: 250mm;
display: flex;
flex-direction: column;
gap: 3mm;
}
.summary > .summary-body { display: flex; flex-direction: column; gap: 2.2mm; }
.summary > * { flex-shrink: 0; }
.summary .block + .block { margin-top: 2mm; }
.summary .stats .stat { padding: .7mm 0; }
.summary .callout { padding: 1.8mm 2.4mm; }
.summary .pair .panel { padding: 1.8mm 2.2mm; }
header.report-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 2px solid #18181b;
padding-bottom: 2.5mm;
}
header.report-head .kicker {
font-size: 7.6pt;
font-weight: 600;
letter-spacing: .14em;
text-transform: uppercase;
color: #52525b;
margin: 0;
}
header.report-head h1 { font-size: 16pt; margin: .8mm 0 0; }
header.report-head h2 { font-size: 13pt; margin: .8mm 0 0; }
header.report-head .sub { color: #52525b; font-size: 8.4pt; margin: .6mm 0 0; }
header.report-head .meta { text-align: right; color: #52525b; font-size: 7.8pt; margin: 0; }
header.report-head .meta p { margin: 0 0 .4mm; }
.block-title {
font-size: 7.6pt;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: #52525b;
margin: 0 0 1.2mm;
}
.strip { display: flex; flex-wrap: wrap; gap: 1.5mm 6mm; border: .8px solid #d4d4d8; border-radius: 1.6mm; padding: 1.8mm 2.4mm; background: #fafafa; }
.strip .strip-item { display: flex; gap: 1.4mm; align-items: baseline; }
.strip .strip-label { color: #71717a; font-size: 7pt; text-transform: uppercase; letter-spacing: .05em; }
.strip .strip-value { font-weight: 600; font-size: 8.4pt; }
.grid { display: grid; gap: 3mm; }
.grid-2 { grid-template-columns: 1fr 1fr; }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid .cell { border: .8px solid #d4d4d8; border-radius: 1.6mm; padding: 2.4mm; }
.grid .cell > h4.cell-title { margin: 0 0 1.6mm; font-size: 8.6pt; }
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2.5mm; }
.cards .card { border: .8px solid #d4d4d8; border-radius: 1.6mm; padding: 2.2mm; }
.cards .card h5 { margin: 0 0 .8mm; font-size: 8pt; }
.cards .card p { margin: 0; color: #3f3f46; font-size: 7.8pt; }
.kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2.5mm; }
.grid .kpis { gap: 1.6mm; }
.grid .kpi { padding: 1.8mm; }
.grid .kpi-value { font-size: 11pt; }
.grid .stats { grid-template-columns: 1fr; }
.kpi { border: .8px solid #d4d4d8; border-radius: 1.6mm; padding: 2.4mm; background: #fafafa; }
.kpi-value { display: block; font-size: 13pt; font-weight: 700; }
.kpi-label { display: block; font-size: 7.4pt; color: #52525b; }
.kpi-hint { display: block; font-size: 7.2pt; color: #71717a; margin-top: .8mm; }
.stats { display: grid; grid-template-columns: 1fr 1fr; gap: 1mm 6mm; }
.stats .stat { display: flex; justify-content: space-between; gap: 3mm; border-bottom: .6px solid #e4e4e7; padding: 1mm 0; }
.stats .stat-label { color: #52525b; }
.stats .stat-value { font-weight: 600; text-align: right; }
.stats .stat-hint { display: block; font-weight: 400; color: #71717a; font-size: 7.4pt; }
.source { color: #71717a; font-size: 7.2pt; margin: 1.4mm 0 0; }
.source a { color: #1d4ed8; word-break: break-all; }
.callout { border-left: 2.2mm solid #b45309; background: #fffbeb; padding: 2.4mm 3mm; border-radius: 0 1.6mm 1.6mm 0; }
.callout.gap { border-left-color: #71717a; background: #f4f4f5; }
.callout.decision { border-left-color: #15803d; background: #f0fdf4; }
.callout.risk { border-left-color: #b45309; background: #fffbeb; }
.callout h4 { margin: 0 0 1mm; font-size: 8.6pt; }
.callout p { margin: 0 0 .8mm; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; }
.pair .panel { border: .8px solid #d4d4d8; border-radius: 1.6mm; padding: 2.4mm; }
.pair .panel.decision { border-color: #15803d; }
.pair .panel.risk { border-color: #b45309; background: #fffbeb; }
.pair .panel h4 { margin: 0 0 1.2mm; font-size: 8.4pt; }
.pair .panel ul, .bullets { margin: 0; padding-left: 4mm; }
.pair .panel li, .bullets li { margin-bottom: .8mm; }
.pair-note { color: #92400e; font-size: 7.6pt; margin: 1.2mm 0 0; }
table { width: 100%; border-collapse: collapse; font-size: 8pt; }
caption { caption-side: top; text-align: left; font-weight: 600; font-size: 8.6pt; padding-bottom: 1.2mm; color: #18181b; }
th { text-align: left; background: #f4f4f5; border-bottom: .8px solid #d4d4d8; padding: 1.4mm 1.4mm; font-size: 7pt; text-transform: uppercase; letter-spacing: .03em; color: #52525b; }
td { border-bottom: .6px solid #e4e4e7; padding: 1.4mm 1.4mm; vertical-align: top; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.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; }
.report-section { margin-bottom: 6mm; }
.report-section > h3 { font-size: 11pt; margin: 0 0 2mm; break-after: avoid; }
.block + .block { margin-top: 3mm; }
.block { break-inside: avoid; }
.block.table-block { break-inside: auto; }
.footer-note { margin-top: auto; border-top: .8px solid #d4d4d8; padding-top: 1.6mm; color: #71717a; font-size: 7.2pt; }
.row-source { display: block; font-weight: 400; color: #71717a; font-size: 7pt; }
.row-source a { color: #1d4ed8; word-break: break-all; }
`
const renderStrip = (block) =>
`<div class="strip">${block.items
.map(
(item) =>
`<span class="strip-item"><span class="strip-label">${htmlEscape(item.label)}</span><span class="strip-value">${htmlEscape(item.value)}</span></span>`,
)
.join('')}</div>`
const renderCards = (block) =>
`<div class="cards">${block.items
.map(
(item) =>
`<div class="card"><h5>${htmlEscape(item.title)}</h5><p>${htmlEscape(item.body)}</p></div>`,
)
.join('')}</div>`
const renderGrid = (block) =>
`<div class="grid grid-${block.columns ?? 2}">${block.cells
.map(
(cell) =>
`<div class="cell">${cell.title ? `<h4 class="cell-title">${htmlEscape(cell.title)}</h4>` : ''}${cell.blocks.map(renderBlockHtml).join('')}</div>`,
)
.join('')}</div>`
const renderKpis = (block) => {
const items = block.items
.map(
(item) => `<div class="kpi">
<span class="kpi-label">${htmlEscape(item.label)}</span>
<span class="kpi-value">${htmlEscape(item.value)}</span>
${item.hint ? `<span class="kpi-hint">${htmlEscape(item.hint)}</span>` : ''}
</div>`,
)
.join('')
return `<div class="kpis">${items}</div>`
}
const renderStats = (block) => {
const rows = block.rows
.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>
</div>`,
)
.join('')
return `<div class="stats">${rows}</div>`
}
const renderBullets = (block) => {
const items = block.items
.map((item) => {
const source = item.source
? ` <span class="row-source">fonte: ${htmlEscape(sourceLabel(item.source))}</span>`
: ''
return `<li>${htmlEscape(item.text)}${source}</li>`
})
.join('')
return `<ul class="bullets">${items}</ul>`
}
const renderPair = (block) => {
const panel = (side) => `<div class="panel ${side.tone}">
<h4>${htmlEscape(side.title)}</h4>
<ul>${side.items.map((item) => `<li>${htmlEscape(item.text)}</li>`).join('')}</ul>
</div>`
return `<div class="pair-wrap"><div class="pair">${panel(block.left)}${panel(block.right)}</div>${
block.note ? `<p class="pair-note">${htmlEscape(block.note)}</p>` : ''
}</div>`
}
const renderTable = (block) => {
const head = block.columns
.map((column) => `<th${column.numeric ? ' class="num"' : ''}>${htmlEscape(column.label)}</th>`)
.join('')
const rows = block.rows
.map(
(row) =>
`<tr>${block.columns
.map(
(column) =>
`<td${column.numeric ? ' class="num"' : ''}>${htmlEscape(row[column.key])}</td>`,
)
.join('')}</tr>`,
)
.join('')
return `<table><caption>${htmlEscape(block.title)}</caption><thead><tr>${head}</tr></thead><tbody>${rows}</tbody></table>${
block.note ? `<p class="table-note">${htmlEscape(block.note)}</p>` : ''
}`
}
const renderCallout = (block) =>
`<div class="callout ${block.tone}"><h4>${htmlEscape(block.title)}</h4>${block.body
.map((paragraph) => `<p>${htmlEscape(paragraph)}</p>`)
.join('')}</div>`
const renderSources = (block) => {
const items = block.items
.map((item) => {
const link = item.url ? ` <a href="${htmlEscape(item.url)}">${htmlEscape(item.url)}</a>` : ''
return `<li>${htmlEscape(sourceKindLabels[item.kind] ?? 'fonte')} — ${htmlEscape(item.label)}${
item.date ? ` · ${htmlEscape(formatDateBr(item.date))}` : ''
}${link}</li>`
})
.join('')
const limits = block.limits.map((limit) => `<li>${htmlEscape(limit)}</li>`).join('')
return `<ul class="bullets">${items}</ul><h4 class="block-title">Limites</h4><ul class="bullets">${limits}</ul>`
}
const renderBlockHtml = (block) => {
const title = block.title ? `<h4 class="block-title">${htmlEscape(block.title)}</h4>` : ''
let body
switch (block.kind) {
case 'kpis':
body = renderKpis(block)
break
case 'strip':
body = renderStrip(block)
break
case 'cards':
body = renderCards(block)
break
case 'grid':
body = renderGrid(block)
break
case 'stats':
body = renderStats(block)
break
case 'bullets':
body = renderBullets(block)
break
case 'pair':
body = renderPair(block)
break
case 'table':
body = renderTable(block)
break
case 'callout':
body = renderCallout(block)
break
case 'prose':
body = `<p>${htmlEscape(block.text)}</p>`
break
case 'sources':
body = renderSources(block)
break
case 'footerNote':
return `<p class="footer-note">${htmlEscape(block.text)}</p>`
default:
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>`
: ''
const className = block.kind === 'table' ? 'block table-block' : 'block'
return `<div class="${className}">${title}${body}${sources}</div>`
}
const indexItems = (report) =>
report.sections.map((section) => `<li>${htmlEscape(section.title)}</li>`).join('')
export const renderReportHtml = (report) => {
const pageOne = report.page1.blocks.map((block) =>
block.kind === 'footerNote'
? renderBlockHtml(block)
: `<div class="block">${renderBlockHtml(block)}</div>`,
)
const summaryHeader = `<header class="report-head">
<div>
<p class="kicker">${htmlEscape(report.meta.title)}</p>
<h1>${htmlEscape(report.meta.municipalityName)}</h1>
<p class="sub">${htmlEscape(report.meta.region)} · prioridade e classe na leitura da base</p>
</div>
<div class="meta">
<p>Gerado em ${htmlEscape(report.meta.generatedAtLabel)}</p>
<p>Base lida em ${htmlEscape(report.meta.readAtLabel)} · snapshot do momento</p>
<p>pág. 1</p>
</div>
</header>`
const deepHeader = `<header class="report-head">
<div>
<p class="kicker">${htmlEscape(report.meta.title)} — aprofundamento</p>
<h2>${htmlEscape(report.meta.municipalityName)}</h2>
</div>
<div class="meta">
<p>Gerado em ${htmlEscape(report.meta.generatedAtLabel)}</p>
<p>Base lida em ${htmlEscape(report.meta.readAtLabel)}</p>
</div>
</header>`
const sections = report.sections
.map((section) => {
const blocks = section.blocks.map(renderBlockHtml).join('')
return `<section class="report-section">${`<h3>${htmlEscape(section.title)}</h3>`}${blocks}</section>`
})
.join('')
return `<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.municipalityName)}</title><style>${PRINT_CSS}</style></head>
<body>
<section class="summary" data-page="summary">
${summaryHeader}
<div class="summary-body">${pageOne.join('')}</div>
</section>
<section class="deep-dive page-break">
${deepHeader}
<div class="index"><h3>Índice</h3><ol>${indexItems(report)}</ol></div>
${sections}
</section>
</body>
</html>`
}
const mdBlock = (block) => {
const lines = []
const title = block.title ? `### ${block.title}\n` : ''
switch (block.kind) {
case 'kpis':
lines.push(title)
for (const item of block.items) {
lines.push(`- **${item.label}:** ${item.value}${item.hint ? ` — ${item.hint}` : ''}`)
}
break
case 'stats':
lines.push(title)
for (const row of block.rows) {
const hover = row.hint ? ` — ${row.hint}` : ''
const source = row.source ? ` _(fonte: ${sourceLabel(row.source)})_` : ''
lines.push(`- **${row.label}:** ${row.value}${hover}${source}`)
}
break
case 'bullets':
lines.push(title)
for (const item of block.items) {
lines.push(`- ${item.text}${item.source ? ` _(fonte: ${sourceLabel(item.source)})_` : ''}`)
}
break
case 'strip':
lines.push(block.items.map((item) => `**${item.label}:** ${item.value}`).join(' · '))
break
case 'cards':
lines.push(title)
for (const item of block.items) lines.push(`- **${item.title}:** ${item.body}`)
break
case 'grid':
for (const cell of block.cells) {
if (cell.title) lines.push(`### ${cell.title}\n`)
for (const inner of cell.blocks) lines.push(mdBlock(inner))
}
break
case 'pair':
lines.push(`### ${block.left.title}\n`)
for (const item of block.left.items) lines.push(`- ${item.text}`)
lines.push(`\n### ${block.right.title}\n`)
for (const item of block.right.items) lines.push(`- ${item.text}`)
if (block.note) lines.push(`\n> ${block.note}`)
break
case 'table': {
lines.push(title)
lines.push(`| ${block.columns.map((column) => column.label).join(' | ')} |`)
lines.push(`| ${block.columns.map(() => '---').join(' | ')} |`)
for (const row of block.rows) {
lines.push(
`| ${block.columns.map((column) => String(row[column.key] ?? '')).join(' | ')} |`,
)
}
if (block.note) lines.push(`\n_${block.note}_`)
break
}
case 'callout':
lines.push(`> **${block.title}**\n>`)
for (const paragraph of block.body) lines.push(`> ${paragraph}`)
break
case 'prose':
lines.push(title)
lines.push(block.text)
break
case 'sources': {
lines.push(title)
for (const item of block.items) {
const link = item.url ? ` — ${item.url}` : ''
lines.push(
`- ${sourceKindLabels[item.kind] ?? 'fonte'} — ${item.label}${item.date ? ` · ${formatDateBr(item.date)}` : ''}${link}`,
)
}
lines.push('\n**Limites**\n')
for (const limit of block.limits) lines.push(`- ${limit}`)
break
}
case 'footerNote':
lines.push(`_${block.text}_`)
break
default:
throw new Error(`Bloco desconhecido no render MD: ${block.kind}`)
}
if (block.sources?.length && block.kind !== 'footerNote') {
lines.push(`\n> Fontes: ${block.sources.map(sourceLabel).join(' · ')}`)
}
return lines.join('\n')
}
export const renderReportMd = (report) => {
const lines = [
`# ${report.meta.title} — ${report.meta.municipalityName}`,
'',
`**Território de identidade:** ${report.meta.region}`,
`**Gerado em:** ${report.meta.generatedAtLabel} · **Base Teqo (read-only) lida em:** ${report.meta.readAtLabel} · snapshot do momento`,
report.meta.codeSha
? `**Código:** ${report.meta.codeSha} · **Base:** ${report.meta.database}`
: '',
'',
'## Resumo (página 1)',
'',
report.page1.blocks.map(mdBlock).join('\n\n'),
'',
'---',
'',
]
for (const section of report.sections) {
lines.push(`## ${section.title}`, '')
lines.push(section.blocks.map(mdBlock).join('\n\n'))
lines.push('')
}
lines.push(
`_Documento interno de campanha, datado (${formatDateTimeBr(report.meta.generatedAt)}). O dado envelhece: confira a data de leitura da base._`,
)
return `${lines.join('\n')}\n`
}

View file

@ -0,0 +1,156 @@
/**
* Research-contract validation for the city report (C163).
*
* The web layer of the report is written by the agent as a JSON file; this
* module is the guard: every checklist item either carries a date + URL or
* becomes an explicit gap. "Sem fonte, não publica" is enforced here, before
* the renderer ever sees the data.
*/
import { DAY_MS } from '../../src/lib/text.ts'
const RESEARCH_NEWS_WINDOW_DAYS = 90
const RESEARCH_CHECKLIST = [
{ id: 'prefeito', label: 'Prefeito(a) — nome, partido, federação, situação' },
{ id: 'vice', label: 'Vice-prefeito(a) — nome, partido' },
{ id: 'relacao_campo', label: 'Relação com o campo (aliado, oposição, neutro)' },
{ id: 'vereadores', label: 'Vereadores e dobradas' },
{ id: 'disputa_local', label: 'Disputa local' },
{ 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)' },
]
export const RESEARCH_CHECKLIST_IDS = RESEARCH_CHECKLIST.map((item) => item.id)
const checklistById = new Map(RESEARCH_CHECKLIST.map((item) => [item.id, item]))
const isNonEmptyString = (value) => typeof value === 'string' && value.trim() !== ''
const isValidDate = (value) => isNonEmptyString(value) && !Number.isNaN(Date.parse(value))
const inNewsWindow = (publishedAt, reference) =>
Date.parse(publishedAt) >= reference.getTime() - RESEARCH_NEWS_WINDOW_DAYS * DAY_MS &&
Date.parse(publishedAt) <= reference.getTime() + DAY_MS
/**
* Validates/normalizes the agent-written research JSON.
*
* Structural failures (no slug, no research date) throw — the operator has to
* fix the file. Content failures (missing source, stale news, unknown item)
* degrade to explicit gaps, which the report prints as such.
*/
export const normalizeResearchInput = (raw, { now = new Date() } = {}) => {
if (!raw || typeof raw !== 'object') {
throw new Error('Pesquisa inválida: o arquivo precisa ser um objeto JSON.')
}
if (!isNonEmptyString(raw.municipalitySlug)) {
throw new Error('Pesquisa inválida: falta "municipalitySlug".')
}
if (!isValidDate(raw.researchedAt)) {
throw new Error(
'Pesquisa inválida: falta "researchedAt" (ISO) — a pesquisa precisa ser datada.',
)
}
const researchedAt = new Date(raw.researchedAt)
const reference = now instanceof Date ? now : new Date(now)
const gaps = []
const items = []
const seen = new Set()
for (const entry of Array.isArray(raw.items) ? raw.items : []) {
const id = typeof entry?.id === 'string' ? entry.id : null
if (!id || !checklistById.has(id)) {
gaps.push({
id: id ?? 'desconhecido',
label: id && checklistById.get(id)?.label,
reason: 'Item fora do checklist de pesquisa.',
})
continue
}
seen.add(id)
const answer = isNonEmptyString(entry.answer) ? entry.answer.trim() : null
const sourceUrl = isNonEmptyString(entry.sourceUrl) ? entry.sourceUrl.trim() : null
const sourceDate = isValidDate(entry.sourceDate) ? entry.sourceDate : null
if (!answer) {
gaps.push({ id, label: checklistById.get(id).label, reason: 'Não pesquisado.' })
continue
}
if (!sourceUrl || !sourceDate) {
gaps.push({
id,
label: checklistById.get(id).label,
reason: 'Sem fonte: cada item exige URL e data.',
})
continue
}
items.push({
id,
label: checklistById.get(id).label,
answer,
details: isNonEmptyString(entry.details) ? entry.details.trim() : null,
sourceUrl,
sourceDate,
consultedAt: isValidDate(entry.consultedAt) ? entry.consultedAt : null,
})
}
for (const checklistItem of RESEARCH_CHECKLIST) {
if (!seen.has(checklistItem.id)) {
gaps.push({ id: checklistItem.id, label: checklistItem.label, reason: 'Não pesquisado.' })
}
}
const news = []
for (const entry of Array.isArray(raw.news) ? raw.news : []) {
const title = isNonEmptyString(entry?.title) ? entry.title.trim() : null
const url = isNonEmptyString(entry?.url) ? entry.url.trim() : null
const publishedAt = isValidDate(entry?.publishedAt) ? entry.publishedAt : null
if (!title || !url || !publishedAt) {
gaps.push({
id: 'noticia_sem_fonte',
reason: 'Notícia sem título, URL ou data — descartada.',
})
continue
}
if (!inNewsWindow(publishedAt, reference)) {
gaps.push({
id: 'noticia_fora_da_janela',
label: title,
reason: `Fora da janela de ${RESEARCH_NEWS_WINDOW_DAYS} dias — descartada.`,
})
continue
}
news.push({
title,
outlet: isNonEmptyString(entry.outlet) ? entry.outlet.trim() : null,
publishedAt,
url,
summary: isNonEmptyString(entry.summary) ? entry.summary.trim() : null,
})
}
for (const entry of Array.isArray(raw.gaps) ? raw.gaps : []) {
if (isNonEmptyString(entry?.reason)) {
gaps.push({
id: isNonEmptyString(entry.id) ? entry.id : 'lacuna',
label: isNonEmptyString(entry.label) ? entry.label : null,
reason: entry.reason.trim(),
})
}
}
return {
municipalitySlug: raw.municipalitySlug.trim(),
researchedAt: researchedAt.toISOString(),
items,
news,
gaps,
}
}
export const researchItemById = (research, id) =>
research.items.find((item) => item.id === id) ?? null

View file

@ -0,0 +1,68 @@
/**
* Territory panorama for the city report (C163) — where the município sits
* inside its Território de Identidade, read from the committed TSE artifact.
*
* Pure by construction: no Payload, no network. The artifact is the same source
* the app's rank/class cards read, so the PDF can never drift from the base.
*/
import {
getMunicipalityFederalBaseline,
getStatewideFederalTotals,
} from '../../src/lib/bahiaElectionAggregates.ts'
import {
getMunicipalityCatalogEntry,
municipalityCatalog,
} from '../../src/lib/municipalityCatalog.ts'
const DEFAULT_PANORAMA_YEAR = 2022
const votesFor = (slug, year) =>
Number(getMunicipalityFederalBaseline(slug).votesByYear[String(year)] ?? 0)
/**
* Region rollup + the município's dense rank inside it, for one year.
* Returns null for a slug outside the catalog (never invent a region).
*/
export const buildTerritoryPanorama = (slug, year = DEFAULT_PANORAMA_YEAR) => {
const entry = getMunicipalityCatalogEntry(slug)
if (!entry) return null
const regionSlugs = municipalityCatalog
.filter((row) => row.region === entry.region)
.map((row) => row.slug)
const regionVotes = regionSlugs.reduce(
(total, regionSlug) => total + votesFor(regionSlug, year),
0,
)
const statewide = getStatewideFederalTotals(year)
const rows = regionSlugs
.map((regionSlug) => ({ slug: regionSlug, votes: votesFor(regionSlug, year) }))
.sort((left, right) => {
if (right.votes !== left.votes) return right.votes - left.votes
return left.slug.localeCompare(right.slug)
})
let rank = 0
let previousVotes
let municipalityRank = null
for (const row of rows) {
if (row.votes !== previousVotes) {
rank += 1
previousVotes = row.votes
}
if (row.slug === slug) municipalityRank = rank
}
return {
region: entry.region,
year,
municipalityCount: regionSlugs.length,
regionVotes,
regionShare: statewide.ownVotes > 0 ? regionVotes / statewide.ownVotes : null,
municipalityVotes: votesFor(slug, year),
municipalityRank,
municipalityTotalUnits: rows.length,
}
}

View file

@ -0,0 +1,252 @@
/**
* Emendas oficiais em tempo de geração (C163) — Portal da Transparência.
*
* Read-only by design: the report fetches the author's amendments for the
* município, prints the phase (empenhada/liquidada/paga/restos) and never
* persists anything. Any failure degrades to an explicit gap; it never prints
* a silent zero.
*/
const EMENDAS_API_URL = 'https://api.portaldatransparencia.gov.br/api-de-dados/emendas'
export const DEFAULT_AUTHOR_NAME = 'JORGE SOLLA'
export const EMENDAS_YEARS = [2023, 2024, 2025, 2026]
const normalizeAuthorName = (value) =>
String(value ?? '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.replace(/\s+/g, ' ')
.trim()
export const authorNameMatches = (candidate, expected) =>
normalizeAuthorName(candidate) === normalizeAuthorName(expected)
const number = (value) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
/** The API row shape the report actually uses — the rest is discarded. */
export const normalizeEmendaRow = (row) => ({
code: typeof row?.codigoEmenda === 'string' ? row.codigoEmenda : null,
year: Number(row?.ano) || null,
type: typeof row?.tipoEmenda === 'string' ? row.tipoEmenda : null,
authorCode: row?.codigoAutor !== undefined ? String(row.codigoAutor) : null,
authorName: typeof row?.nomeAutor === 'string' ? row.nomeAutor : null,
functionName: typeof row?.nomeFuncao === 'string' ? row.nomeFuncao : null,
empenhado: number(row?.valorEmpenhado),
liquidado: number(row?.valorLiquidado),
pago: number(row?.valorPago),
restoPago: number(row?.valorRestoPago),
})
const rowMunicipalityCode = (row) => {
const candidate = row?.codigoMunicipio ?? row?.codigoMunicipioIbge ?? row?.municipio?.codigoIbge
return candidate === undefined || candidate === null ? null : String(candidate)
}
export const sumEmendas = (rows) =>
rows.reduce(
(totals, row) => ({
empenhado: totals.empenhado + number(row.empenhado),
liquidado: totals.liquidado + number(row.liquidado),
pago: totals.pago + number(row.pago),
restoPago: totals.restoPago + number(row.restoPago),
}),
{ empenhado: 0, liquidado: 0, pago: 0, restoPago: 0 },
)
const gapResult = ({ reason, detail = null, sourceUrl, consultedAt, requestCount, years }) => ({
status: 'gap',
reason,
detail,
sourceUrl,
consultedAt,
requestCount,
years,
rows: [],
totals: sumEmendas([]),
})
const fetchAuthorYear = async ({
fetchImpl,
apiKey,
authorName,
municipalityCode,
year,
maxPages,
}) => {
const rows = []
let requests = 0
for (let page = 1; page <= maxPages; page += 1) {
const params = new URLSearchParams({
ano: String(year),
nomeAutor: authorName,
pagina: String(page),
})
if (municipalityCode) params.set('codigoMunicipio', String(municipalityCode))
const url = `${EMENDAS_API_URL}?${params.toString()}`
requests += 1
const response = await fetchImpl(url, {
headers: { 'chave-api-dados': apiKey, Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(`HTTP ${response.status} em ${year} (página ${page})`)
}
const payload = await response.json()
const pageRows = Array.isArray(payload) ? payload : []
rows.push(...pageRows)
if (pageRows.length === 0) break
}
return { rows, requests }
}
/**
* @typedef {Object} EmendaRow
* @property {string | null} code
* @property {number | null} year
* @property {string | null} type
* @property {string | null} authorCode
* @property {string | null} authorName
* @property {string | null} functionName
* @property {number} empenhado
* @property {number} liquidado
* @property {number} pago
* @property {number} restoPago
*/
/**
* @typedef {Object} EmendasResult
* @property {'ok' | 'gap'} status
* @property {string | null} reason
* @property {string | null} detail
* @property {string | null} sourceUrl
* @property {string} consultedAt
* @property {number} requestCount
* @property {number[]} years
* @property {EmendaRow[]} rows
* @property {{ empenhado: number, liquidado: number, pago: number, restoPago: number }} totals
* @property {string} [authorName]
* @property {string | null} [authorCode]
*/
/**
* Fetches the author's amendments for the município across the mandate years.
* `fetchImpl` is injectable so the unit tests never touch the network.
*
* @param {{
* years?: number[],
* municipalityCode?: string | null,
* authorName?: string,
* apiKey?: string | null,
* fetchImpl?: (url: string, init?: { headers?: Record<string, string> }) => Promise<{ ok: boolean, status: number, json: () => Promise<unknown> }>,
* consultedAt?: string,
* maxPages?: number,
* }} [options]
* @returns {Promise<EmendasResult>}
*/
export const fetchAuthorEmendas = async ({
years = EMENDAS_YEARS,
municipalityCode = null,
authorName = DEFAULT_AUTHOR_NAME,
apiKey = null,
fetchImpl = fetch,
consultedAt = new Date().toISOString(),
maxPages = 20,
} = {}) => {
if (!apiKey) {
return gapResult({
reason: 'Chave da API do Portal da Transparência ausente.',
detail: 'Defina PORTAL_TRANSPARENCIA_API_KEY no ambiente do gerador.',
sourceUrl: EMENDAS_API_URL,
consultedAt,
requestCount: 0,
years,
})
}
const requestsByYear = []
const collected = []
try {
for (const year of years) {
const result = await fetchAuthorYear({
fetchImpl,
apiKey,
authorName,
municipalityCode,
year,
maxPages,
})
requestsByYear.push({ year, requests: result.requests })
collected.push(...result.rows)
}
} catch (error) {
return gapResult({
reason: 'Falha ao consultar a API de emendas.',
detail: String(error?.message ?? error),
sourceUrl: EMENDAS_API_URL,
consultedAt,
requestCount: requestsByYear.reduce((total, entry) => total + entry.requests, 0),
years,
})
}
const sourceUrl = `${EMENDAS_API_URL}?nomeAutor=${encodeURIComponent(authorName)}`
const requestCount = requestsByYear.reduce((total, entry) => total + entry.requests, 0)
const matched = collected.filter((row) => authorNameMatches(row?.nomeAutor, authorName))
const distinctAuthorCodes = [
...new Set(matched.map((row) => String(row?.codigoAutor ?? ''))),
].filter((code) => code !== '')
if (matched.length === 0) {
return {
...gapResult({
reason: 'Nenhuma emenda do autor encontrada para o município na janela consultada.',
sourceUrl,
consultedAt,
requestCount,
years,
}),
}
}
if (distinctAuthorCodes.length > 1) {
return {
...gapResult({
reason: 'Mais de um código de autor corresponde ao nome pesquisado (homônimo).',
detail: `códigos: ${distinctAuthorCodes.join(', ')}`,
sourceUrl,
consultedAt,
requestCount,
years,
}),
}
}
const rows = matched
.filter((row) => {
const code = rowMunicipalityCode(row)
return code === null || municipalityCode === null || code === String(municipalityCode)
})
.map(normalizeEmendaRow)
.sort(
(left, right) =>
(right.year ?? 0) - (left.year ?? 0) || (left.code ?? '').localeCompare(right.code ?? ''),
)
return {
status: 'ok',
reason: null,
detail: null,
sourceUrl,
consultedAt,
requestCount,
years,
authorName,
authorCode: distinctAuthorCodes[0] ?? null,
rows,
totals: sumEmendas(rows),
}
}

View file

@ -0,0 +1,82 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it, vi } from 'vitest'
// `loadMunicipalityElectoralBaseline` is wrapped in Next's `unstable_cache`
// (needs the Next server runtime). The snapshot only forwards its result, so
// stub the module exactly like `municipalityDossierData.int.spec.ts` does.
vi.mock('@/utilities/municipality/municipalityElectoralBaseline', () => ({
loadMunicipalityElectoralBaseline: vi.fn().mockResolvedValue(null),
}))
import config from '@/payload.config'
import { composeCityReportSnapshot } from '../../scripts/cityReportSnapshot.mjs'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('city report snapshot (C163)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('composes a read-only snapshot without contact PII', { timeout: 30_000 }, async () => {
const fixtures = campaignFixtures()
const candidate = await fixtures.createCampaignUser('candidate')
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
})
await fixtures.createMunicipalityUpdate({
municipality: municipality.id,
author: candidate.id,
polarity: 'boa',
body: 'Sinal de teste do snapshot',
})
const actor = { ...candidate, collection: 'campaignUser' as const }
const snapshot = await composeCityReportSnapshot({
payload,
actor,
slug: municipality.slug,
codeSha: 'test-sha',
database: 'localhost:5432/teqo_wt_test',
})
expect(snapshot.meta).toEqual(
expect.objectContaining({
readOnly: true,
codeSha: 'test-sha',
actorRole: 'candidate',
}),
)
expect(snapshot.municipality.slug).toBe(municipality.slug)
expect(snapshot.advisors.map((advisorRow: { name: string }) => advisorRow.name)).toContain(
advisor.name,
)
expect(snapshot.leaderships.totalCount).toBeGreaterThan(0)
const row = snapshot.leaderships.rows.find(
(leadershipRow) => leadershipRow.id === leadership.id,
)!
expect(row.name).toBe(contact.name)
expect('phone' in row).toBe(false)
expect('email' in row).toBe(false)
expect('contactID' in row).toBe(false)
expect(snapshot.signals.totalCount).toBeGreaterThan(0)
expect(JSON.stringify(snapshot)).not.toContain(contact.phone)
})
})

View file

@ -0,0 +1,329 @@
import { describe, expect, it } from 'vitest'
import { buildCityReport } from '../../scripts/lib/cityReportBlocks.mjs'
import {
normalizeResearchInput,
RESEARCH_CHECKLIST_IDS,
} from '../../scripts/lib/cityReportResearch.mjs'
const generatedAt = new Date('2026-09-15T12:00:00.000Z')
const validItem = (id: string) => ({
id,
answer: `Resposta de ${id}`,
sourceUrl: `https://exemplo.test/${id}`,
sourceDate: '2026-09-10',
})
const research = normalizeResearchInput(
{
municipalitySlug: 'feira-de-santana',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map(validItem),
news: [
{
title: 'Matéria local',
url: 'https://jornal.test/materia',
publishedAt: '2026-09-01T00:00:00.000Z',
outlet: 'Jornal Local',
},
],
gaps: [],
},
{ now: generatedAt },
)
const emendas = {
status: 'ok',
reason: null,
detail: null,
sourceUrl: 'https://api.portaldatransparencia.gov.br/api-de-dados/emendas?nomeAutor=JORGE+SOLLA',
consultedAt: '2026-09-15T11:00:00.000Z',
requestCount: 4,
years: [2023, 2024, 2025, 2026],
authorName: 'JORGE SOLLA',
authorCode: '1234',
rows: [
{
code: '202312340001',
year: 2023,
type: 'Individual',
authorCode: '1234',
empenhado: 1_000_000,
liquidado: 500_000,
pago: 250_000,
restoPago: 10_000,
},
],
totals: { empenhado: 1_000_000, liquidado: 500_000, pago: 250_000, restoPago: 10_000 },
}
const snapshot = {
meta: {
source: 'Base Teqo (produção, leitura read-only)',
readAt: '2026-09-15T10:30:00.000Z',
readOnly: true,
database: '127.0.0.1:5433/teqo_1313',
codeSha: 'abc1234',
actorId: 3,
actorName: 'Seed Candidato',
actorRole: 'candidate',
actorRoleLabel: 'Candidato',
},
municipality: {
id: 42,
slug: 'feira-de-santana',
name: 'Feira de Santana',
kind: 'municipio',
city: 'Feira de Santana',
region: 'Portal do Sertão',
ibgeCode: '2910800',
tseCityCode: '31270',
zoneNumber: null,
tseZones: [12, 13],
lastUpdateAt: '2026-09-01T10:00:00.000Z',
},
electoral: {
candidateName: 'Jorge Solla',
candidateParty: 'PT',
series: [
{ year: 2014, votes: 30_000 },
{ year: 2018, votes: 40_000 },
{ year: 2022, votes: 50_000 },
],
tally2022: { aptos: 400_000, comparecimento: 300_000, votosValidos: 280_000 },
ticket2022: { president: 100_000, governor: 90_000 },
rank2022: { rank: 3, votes: 50_000, share: 0.05, totalUnits: 435 },
},
goal: {
suggestedGoal: 60_000,
goalCoverage: { goal: 60_000, committed: 15_000, coverageRatio: 0.25, deficit: 45_000 },
territorialClass: {
class: 'expansao',
factors: [],
lq: 1.2,
ownShare: 0.05,
inCoreBlock: true,
},
potential: {},
territoryCaptureBenchmark: {},
},
pledges: {
declaredTotal: 15_000,
effectiveByScenario: { pessimistic: 10_000, central: 15_000, optimistic: 20_000 },
pledgeCount: 7,
missingEstimateCount: 1,
lastPledgeAt: '2026-08-30T10:00:00.000Z',
},
leaderships: {
totalCount: 9,
rows: [
{
id: 1,
name: 'Liderança Um',
supportStatus: 'engajado',
exclusive: true,
organizationNames: ['Associação X'],
stateDeputies: [{ name: 'Deputada Estadual' }],
hasAppAccess: true,
updatedAt: '2026-09-10T10:00:00.000Z',
},
],
},
advisors: [{ id: 2, name: 'Seed Assessor' }],
signals: {
totalCount: 1,
rows: [
{
id: 10,
createdAt: '2026-09-12T10:00:00.000Z',
body: 'Sinal recente sobre a cidade',
polarity: 'boa',
urgent: false,
adversarySignal: false,
responsibleName: 'Seed Assessor',
activeVolunteers: 10,
newSupports: 2,
resolvedAt: null,
},
],
},
activities: {
upcoming: [
{
id: 20,
title: 'Visita ao centro',
status: 'confirmado',
startAt: '2026-09-20T13:00:00.000Z',
endAt: null,
locality: 'Centro',
deputyPresent: true,
taskProgress: { done: 1, total: 2 },
},
],
recent: [],
},
conjuncture: {
priority: 'alta',
expectedVotes: { pessimistic: 40_000, central: 50_000, optimistic: 70_000 },
politicalTrend: {
status: 'favoravel',
note: 'Prefeito sinaliza apoio',
recordedByName: 'Seed Coordenador',
recordedAt: '2026-08-20T10:00:00.000Z',
},
engagementLevel: 'n3',
levelNote: 'Rede ativa',
levelChangedAt: '2026-08-01T10:00:00.000Z',
strengths: ['Rede de saúde forte'],
risks: ['Oposição com rádio local'],
dobradinhaNotes: 'Dobradinha com a deputada estadual',
nextSteps: 'Agendar reunião com o prefeito',
budgetNotes: 'Emenda de custeio aportada em 2025',
stateDeputies: [{ id: 5, name: 'Deputada Estadual', party: 'PT' }],
},
speeches: {
totalCount: 1,
rows: [
{
id: 30,
speechAt: '2026-07-01T10:00:00.000Z',
year: 2026,
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',
},
],
},
demands: {
totalCount: 1,
rows: [
{
id: 40,
title: 'Demanda de saúde',
kind: 'saude',
status: 'recebida',
createdAt: '2026-09-01T10:00:00.000Z',
updatedAt: '2026-09-05T10:00:00.000Z',
},
],
},
demographics: {
population: 600_000,
ageBands: { '0-17': 150_000, '18-29': 120_000, '30-59': 250_000, '60+': 80_000 },
sexShareFemale: 0.52,
medianAge: 34,
},
}
type ReportItem = { label?: string; text?: string; value?: string; hint?: string | null }
type ReportRow = { label: string; value: string; source?: { url?: string } }
type ReportBlock = {
kind: string
title?: string
items?: ReportItem[]
rows?: ReportRow[]
cells?: Array<{ title: string; blocks: ReportBlock[] }>
body?: string[]
sources?: Array<{ kind: string; url?: string | null }>
left?: { title: string; items: ReportItem[] }
right?: { title: string; items: ReportItem[] }
}
type ReportShape = {
page1: { blocks: ReportBlock[] }
sections: Array<{ id: string; title: string; blocks: ReportBlock[] }>
}
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)
expect(report.sections[0].title).toMatch(/Conta eleitoral completa/)
expect(report.sections[9].title).toMatch(/Fontes e limites/)
})
it('keeps page 1 in the contract order (six blocks + footer)', () => {
const kinds = report.page1.blocks
.map((block) => block.kind)
.filter((kind) => kind !== 'callout')
expect(kinds).toEqual(['strip', 'grid', 'kpis', 'pair', 'cards', 'footerNote'])
const titles = report.page1.blocks.map((block) => block.title).filter(Boolean)
expect(titles).toEqual(
expect.arrayContaining(['O que Solla entregou', 'Riscos — oposição/disputa local']),
)
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
expect(grid.cells!.map((cell) => cell.title)).toEqual(['Conta eleitoral 2022', 'Quem é quem'])
const pair = report.page1.blocks.find((block) => block.kind === 'pair')!
expect(pair.left!.title).toBe('O que anunciar agora')
expect(pair.right!.items.some((item) => item.text?.includes('Empenho'))).toBe(true)
})
it('reads the relative lens (share/rank), never the statewide absolute', () => {
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
const kpis = grid.cells![0].blocks.find((block) => block.kind === 'kpis')!
expect(kpis.items![0].value).toBe('50.000')
expect(kpis.items![0].hint).toContain('5,0%')
expect(kpis.items![1].value).toBe('3º de 435')
})
it('shows emendas with phase and the official source', () => {
const delivered = report.page1.blocks.find(
(block) => block.kind === 'kpis' && block.title === 'O que Solla entregou',
)!
expect(delivered.items![0].value).toBe('R$ 1,0 mi')
expect(delivered.items![0].hint).toContain('Pago: R$ 250 mil')
expect(delivered.sources![0]).toEqual(
expect.objectContaining({
kind: 'official',
url: expect.stringContaining('portaldatransparencia'),
}),
)
})
it('degrades missing emendas into an explicit gap, never a zero', () => {
const gapReport = asReport(
buildCityReport({
snapshot,
research,
emendas: {
status: 'gap',
reason: 'Chave ausente',
detail: null,
sourceUrl: null,
consultedAt: null,
},
generatedAt,
}),
)
const delivered = gapReport.page1.blocks.find(
(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/)
})
it('puts research answers in Quem é quem with their source', () => {
const grid = report.page1.blocks.find((block) => block.kind === 'grid')!
const who = grid.cells![1].blocks[0]
const prefeito = who.rows!.find((row) => row.label === 'Prefeito(a)')!
expect(prefeito.value).toBe('Resposta de prefeito')
expect(prefeito.source!.url).toBe('https://exemplo.test/prefeito')
})
it('builds the region panorama from the committed artifact', () => {
const region = report.sections.find((section) => section.id === 'regiao')!.blocks[0]
const rowByLabel = (label: string) => region.rows!.find((row) => row.label === label)!
expect(rowByLabel('Território').value).toBe('Portal do Sertão')
expect(rowByLabel('Municípios no TI').value).toBe('17')
const position = Number(rowByLabel('Posição do município no TI').value.split('º')[0])
expect(position).toBeGreaterThan(0)
})
})

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { databaseLabel, withReadOnlyDatabaseUrl } from '../../scripts/lib/cityReportDatabase.mjs'
describe('cityReportDatabase', () => {
it('appends the read-only session options to the URL', () => {
const url = withReadOnlyDatabaseUrl('postgresql://teqo:senha@127.0.0.1:5433/teqo_1313')
expect(url).toContain('options=')
expect(decodeURIComponent(url)).toContain('default_transaction_read_only=on')
expect(decodeURIComponent(url)).toContain('statement_timeout=30000')
})
it('preserves pre-existing options instead of overwriting them', () => {
const url = withReadOnlyDatabaseUrl(
'postgresql://teqo:senha@127.0.0.1:5433/teqo_1313?options=-c%20search_path%3Dpublic',
)
const decoded = decodeURIComponent(new URL(url).searchParams.get('options') ?? '')
expect(decoded).toContain('search_path=public')
expect(decoded).toContain('default_transaction_read_only=on')
})
it('fails closed without a database url', () => {
expect(() => withReadOnlyDatabaseUrl('')).toThrow(/DATABASE_URL/)
expect(() => withReadOnlyDatabaseUrl('não é url')).toThrow(/inválida/)
})
it('labels the database without credentials', () => {
expect(databaseLabel('postgresql://teqo:senha@127.0.0.1:5433/teqo_1313')).toBe(
'127.0.0.1:5433/teqo_1313',
)
})
})

View file

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import {
formatDateBr,
formatFreshness,
formatInteger,
formatMoneyCompact,
formatPercent,
formatRank,
} from '../../scripts/lib/cityReportFormat.mjs'
describe('cityReportFormat', () => {
it('formats integers in pt-BR', () => {
expect(formatInteger(1234567)).toBe('1.234.567')
expect(formatInteger(null)).toBe('—')
expect(formatInteger(undefined)).toBe('—')
})
it('formats percents with one decimal', () => {
expect(formatPercent(0.0321)).toBe('3,2%')
expect(formatPercent(0)).toBe('0,0%')
expect(formatPercent(null)).toBe('—')
})
it('formats money in millions', () => {
expect(formatMoneyCompact(0)).toBe('R$ 0,0 mil')
expect(formatMoneyCompact(250_000)).toBe('R$ 250 mil')
expect(formatMoneyCompact(2_500_000)).toBe('R$ 2,5 mi')
expect(formatMoneyCompact(1_500_000_000)).toBe('R$ 1,5 bi')
})
it('formats dates in pt-BR', () => {
expect(formatDateBr('2026-09-15T12:00:00.000Z')).toBe('15/09/2026')
expect(formatDateBr(null)).toBe('—')
})
it('formats freshness buckets', () => {
const now = new Date('2026-09-15T12:00:00.000Z')
expect(formatFreshness('2026-09-15T09:00:00.000Z', now)).toBe('atualizado hoje')
expect(formatFreshness('2026-09-13T12:00:00.000Z', now)).toBe('atualizado há 2 dias')
expect(formatFreshness('2026-09-01T12:00:00.000Z', now)).toBe('atualizado há 2 semanas')
expect(formatFreshness(null, now)).toBe('sem registro')
})
it('formats rank', () => {
expect(formatRank({ rank: 7, totalUnits: 435 })).toBe('7º de 435')
expect(formatRank(null)).toBe('—')
})
})

View file

@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { buildCityReport } from '../../scripts/lib/cityReportBlocks.mjs'
import { renderReportHtml, renderReportMd } from '../../scripts/lib/cityReportRender.mjs'
import {
normalizeResearchInput,
RESEARCH_CHECKLIST_IDS,
} from '../../scripts/lib/cityReportResearch.mjs'
const generatedAt = new Date('2026-09-15T12:00:00.000Z')
const research = normalizeResearchInput(
{
municipalitySlug: 'feira-de-santana',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map((id) => ({
id,
answer: id === 'prefeito' ? 'Prefeito <script>alert(1)</script>' : `Resposta de ${id}`,
sourceUrl: `https://exemplo.test/${id}`,
sourceDate: '2026-09-10',
})),
news: [],
gaps: [],
},
{ now: generatedAt },
)
const report = buildCityReport({
snapshot: {
meta: {
readAt: '2026-09-15T10:30:00.000Z',
database: '127.0.0.1:5433/teqo_1313',
codeSha: 'abc1234',
},
municipality: {
id: 42,
slug: 'feira-de-santana',
name: 'Feira de Santana',
kind: 'municipio',
city: 'Feira de Santana',
region: 'Portal do Sertão',
ibgeCode: '2910800',
tseCityCode: '31270',
zoneNumber: null,
tseZones: [12, 13],
lastUpdateAt: null,
},
electoral: {
candidateName: 'Jorge Solla',
candidateParty: 'PT',
series: [],
tally2022: null,
ticket2022: { president: null, governor: null },
rank2022: null,
},
goal: null,
pledges: {
declaredTotal: 0,
effectiveByScenario: {},
pledgeCount: 0,
missingEstimateCount: 0,
lastPledgeAt: null,
},
leaderships: { totalCount: 0, rows: [] },
advisors: [],
signals: { totalCount: 0, rows: [] },
activities: { upcoming: [], recent: [] },
conjuncture: null,
speeches: { totalCount: 0, rows: [] },
demands: { totalCount: 0, rows: [] },
demographics: null,
},
research,
emendas: {
status: 'gap',
reason: 'Chave ausente',
detail: null,
sourceUrl: null,
consultedAt: null,
},
generatedAt,
})
describe('renderReportHtml', () => {
const html = renderReportHtml(report)
it('renders the summary page anchor and both headers', () => {
expect(html).toContain('data-page="summary"')
expect(html).toContain('pág. 1')
expect(html).toContain('Índice')
})
it('escapes research text (no raw injection)', () => {
expect(html).not.toContain('<script>alert(1)</script>')
expect(html).toContain('&lt;script&gt;')
})
it('prints the gaps as callouts and the limits list', () => {
expect(html).toContain('Lacuna explícita')
expect(html).toContain('Limites')
})
})
describe('renderReportMd', () => {
const md = renderReportMd(report)
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('| Ano | Votos |')
})
it('lists sources with URLs', () => {
expect(md).toContain('https://exemplo.test/prefeito')
expect(md).toContain('base Teqo')
})
it('states the base read date (dated snapshot)', () => {
expect(md).toContain('Base Teqo (read-only) lida em')
})
})

View file

@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import {
normalizeResearchInput,
RESEARCH_CHECKLIST_IDS,
} from '../../scripts/lib/cityReportResearch.mjs'
const now = new Date('2026-09-15T12:00:00.000Z')
const validItem = (id: string, overrides: Record<string, unknown> = {}) => ({
id,
answer: `Resposta de ${id}`,
sourceUrl: `https://exemplo.test/${id}`,
sourceDate: '2026-09-10',
...overrides,
})
const baseResearch = (overrides: Record<string, unknown> = {}) => ({
municipalitySlug: 'teixeira-de-freitas',
researchedAt: '2026-09-14T10:00:00.000Z',
items: RESEARCH_CHECKLIST_IDS.map((id) => validItem(id)),
news: [],
gaps: [],
...overrides,
})
describe('normalizeResearchInput', () => {
it('keeps fully sourced items', () => {
const research = normalizeResearchInput(baseResearch(), { now })
expect(research.items).toHaveLength(RESEARCH_CHECKLIST_IDS.length)
expect(research.gaps).toEqual([])
})
it('throws when the research is not dated', () => {
expect(() => normalizeResearchInput(baseResearch({ researchedAt: null }), { now })).toThrow(
/researchedAt/,
)
})
it('throws when the slug is missing', () => {
expect(() => normalizeResearchInput(baseResearch({ municipalitySlug: '' }), { now })).toThrow(
/municipalitySlug/,
)
})
it('degrades an item without source to an explicit gap', () => {
const research = normalizeResearchInput(
baseResearch({
items: [
validItem('prefeito', { sourceUrl: null }),
...RESEARCH_CHECKLIST_IDS.slice(1).map((id) => validItem(id)),
],
}),
{ now },
)
expect(research.items.find((item) => item.id === 'prefeito')).toBeUndefined()
expect(research.gaps).toContainEqual(
expect.objectContaining({ id: 'prefeito', reason: expect.stringMatching(/Sem fonte/) }),
)
})
it('marks checklist items never researched as gaps', () => {
const research = normalizeResearchInput(baseResearch({ items: [validItem('prefeito')] }), {
now,
})
expect(research.gaps.filter((gap) => gap.reason === 'Não pesquisado.')).toHaveLength(
RESEARCH_CHECKLIST_IDS.length - 1,
)
})
it('drops news outside the 90-day window and without source', () => {
const research = normalizeResearchInput(
baseResearch({
news: [
{
title: 'Matéria válida',
url: 'https://jornal.test/materia',
publishedAt: '2026-09-01T00:00:00.000Z',
outlet: 'Jornal Local',
},
{
title: 'Velha',
url: 'https://jornal.test/velha',
publishedAt: '2026-01-01T00:00:00.000Z',
},
{ title: 'Sem URL', publishedAt: '2026-09-01T00:00:00.000Z' },
],
}),
{ now },
)
expect(research.news).toHaveLength(1)
expect(research.news[0].title).toBe('Matéria válida')
expect(research.gaps).toContainEqual(expect.objectContaining({ id: 'noticia_fora_da_janela' }))
expect(research.gaps).toContainEqual(expect.objectContaining({ id: 'noticia_sem_fonte' }))
})
it('rejects unknown checklist ids', () => {
const research = normalizeResearchInput(
baseResearch({
items: [...RESEARCH_CHECKLIST_IDS.map((id) => validItem(id)), validItem('inventado')],
}),
{ now },
)
expect(research.items.find((item) => item.id === 'inventado')).toBeUndefined()
expect(research.gaps).toContainEqual(
expect.objectContaining({ id: 'inventado', reason: expect.stringMatching(/checklist/) }),
)
})
})

View file

@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest'
import {
authorNameMatches,
fetchAuthorEmendas,
normalizeEmendaRow,
sumEmendas,
} from '../../scripts/lib/portalTransparenciaEmendas.mjs'
const jsonResponse = (body: unknown, ok = true, status = 200) => ({
ok,
status,
json: async () => body,
})
const row = (overrides: Record<string, unknown> = {}) => ({
codigoEmenda: '202312340001',
ano: 2023,
tipoEmenda: 'Individual',
codigoAutor: '1234',
nomeAutor: 'JORGE SOLLA',
nomeFuncao: 'Saúde',
valorEmpenhado: 100000,
valorLiquidado: 50000,
valorPago: 25000,
valorRestoPago: 1000,
codigoMunicipio: '2930709',
...overrides,
})
describe('portalTransparenciaEmendas', () => {
it('normalizes author names without accent/case', () => {
expect(authorNameMatches('Jorge Solla', 'JORGE SOLLA')).toBe(true)
expect(authorNameMatches('Jorge Salles', 'JORGE SOLLA')).toBe(false)
})
it('normalizes the API row to the fields the report uses', () => {
const normalized = normalizeEmendaRow(row())
expect(normalized).toEqual(
expect.objectContaining({
code: '202312340001',
year: 2023,
empenhado: 100000,
pago: 25000,
restoPago: 1000,
}),
)
})
it('sums phases', () => {
const totals = sumEmendas([
normalizeEmendaRow(row()),
normalizeEmendaRow(row({ valorPago: 5000 })),
])
expect(totals.empenhado).toBe(200000)
expect(totals.pago).toBe(30000)
})
it('fails into an explicit gap without an API key', async () => {
const result = await fetchAuthorEmendas({ years: [2023], apiKey: null })
expect(result.status).toBe('gap')
expect(result.reason).toMatch(/Chave/)
expect(result.rows).toEqual([])
})
it('fetches only the author exact-match and the municipality', async () => {
const fetchImpl = vi.fn(async (url: string) => {
expect(url).toContain('nomeAutor=JORGE+SOLLA')
expect(url).toContain('codigoMunicipio=2930709')
expect(url).toContain('ano=2023')
if (url.includes('pagina=2')) return jsonResponse([])
return jsonResponse([row(), row({ nomeAutor: 'JORGE SOLLA NETO', codigoAutor: '9999' })])
})
const later = vi.fn(async () => jsonResponse([]))
const result = await fetchAuthorEmendas({
years: [2023, 2024],
municipalityCode: '2930709',
apiKey: 'chave',
fetchImpl: (url: string) => (url.includes('ano=2023') ? fetchImpl(url) : later()),
})
expect(result.status).toBe('ok')
expect(result.rows).toHaveLength(1)
expect(result.authorCode).toBe('1234')
expect(result.requestCount).toBe(3)
})
it('degrades to a gap when two authors share the name', async () => {
const fetchImpl = vi.fn(async () =>
jsonResponse([row(), row({ codigoEmenda: 'x', codigoAutor: '5678' })]),
)
const result = await fetchAuthorEmendas({
years: [2023],
municipalityCode: '2930709',
apiKey: 'chave',
fetchImpl,
})
expect(result.status).toBe('gap')
expect(result.reason).toMatch(/homônimo/i)
})
it('degrades to a gap on HTTP failure', async () => {
const fetchImpl = vi.fn(async () => jsonResponse({}, false, 429))
const result = await fetchAuthorEmendas({
years: [2023],
municipalityCode: '2930709',
apiKey: 'chave',
fetchImpl,
})
expect(result.status).toBe('gap')
expect(result.reason).toMatch(/Falha/)
expect(result.detail).toContain('429')
})
it('degrades to a gap when nothing matches', async () => {
const fetchImpl = vi.fn(async () => jsonResponse([]))
const result = await fetchAuthorEmendas({
years: [2023],
municipalityCode: '2930709',
apiKey: 'chave',
fetchImpl,
})
expect(result.status).toBe('gap')
expect(result.reason).toMatch(/Nenhuma emenda/)
})
})