mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C163): relatório de cidade pré-viagem (agente + skill → PDF A4) (Closes #1007)
This commit is contained in:
parent
f41ef92473
commit
e579406b1f
23 changed files with 3741 additions and 0 deletions
129
.agents/skills/relatorio-cidade/SKILL.md
Normal file
129
.agents/skills/relatorio-cidade/SKILL.md
Normal 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
5
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
31
.opencode/agent/relatorio-cidade.md
Normal file
31
.opencode/agent/relatorio-cidade.md
Normal 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.
|
||||
1
docs/changelog/2026-09-15-c163.md
Normal file
1
docs/changelog/2026-09-15-c163.md
Normal 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.
|
||||
|
|
@ -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`)
|
||||
|
|
|
|||
191
docs/plans/relatorio-cidade-viagem-impl.md
Normal file
191
docs/plans/relatorio-cidade-viagem-impl.md
Normal 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).
|
||||
195
scripts/build-city-report.mjs
Normal file
195
scripts/build-city-report.mjs
Normal 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)
|
||||
213
scripts/cityReportSnapshot.mjs
Normal file
213
scripts/cityReportSnapshot.mjs
Normal 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,
|
||||
}
|
||||
}
|
||||
117
scripts/extract-city-report-snapshot.mjs
Normal file
117
scripts/extract-city-report-snapshot.mjs
Normal 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)
|
||||
902
scripts/lib/cityReportBlocks.mjs
Normal file
902
scripts/lib/cityReportBlocks.mjs
Normal 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,
|
||||
}
|
||||
}
|
||||
35
scripts/lib/cityReportDatabase.mjs
Normal file
35
scripts/lib/cityReportDatabase.mjs
Normal 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'
|
||||
}
|
||||
}
|
||||
88
scripts/lib/cityReportFormat.mjs
Normal file
88
scripts/lib/cityReportFormat.mjs
Normal 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)}`
|
||||
}
|
||||
471
scripts/lib/cityReportRender.mjs
Normal file
471
scripts/lib/cityReportRender.mjs
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
|
||||
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`
|
||||
}
|
||||
156
scripts/lib/cityReportResearch.mjs
Normal file
156
scripts/lib/cityReportResearch.mjs
Normal 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
|
||||
68
scripts/lib/cityReportTerritory.mjs
Normal file
68
scripts/lib/cityReportTerritory.mjs
Normal 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,
|
||||
}
|
||||
}
|
||||
252
scripts/lib/portalTransparenciaEmendas.mjs
Normal file
252
scripts/lib/portalTransparenciaEmendas.mjs
Normal 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),
|
||||
}
|
||||
}
|
||||
82
tests/int/cityReportSnapshot.int.spec.ts
Normal file
82
tests/int/cityReportSnapshot.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
329
tests/unit/cityReportBlocks.unit.spec.ts
Normal file
329
tests/unit/cityReportBlocks.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
32
tests/unit/cityReportDatabase.unit.spec.ts
Normal file
32
tests/unit/cityReportDatabase.unit.spec.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
49
tests/unit/cityReportFormat.unit.spec.ts
Normal file
49
tests/unit/cityReportFormat.unit.spec.ts
Normal 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('—')
|
||||
})
|
||||
})
|
||||
121
tests/unit/cityReportRender.unit.spec.ts
Normal file
121
tests/unit/cityReportRender.unit.spec.ts
Normal 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('<script>')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
109
tests/unit/cityReportResearch.unit.spec.ts
Normal file
109
tests/unit/cityReportResearch.unit.spec.ts
Normal 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/) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
125
tests/unit/portalTransparenciaEmendas.unit.spec.ts
Normal file
125
tests/unit/portalTransparenciaEmendas.unit.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue