diff --git a/.agents/skills/relatorio-cidade/SKILL.md b/.agents/skills/relatorio-cidade/SKILL.md new file mode 100644 index 00000000..4a1cdedf --- /dev/null +++ b/.agents/skills/relatorio-cidade/SKILL.md @@ -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 " 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/.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= --out=data/relatorios-cidade/.snapshot.json + ``` + Depois `scp homeserver:~/teqo-report/data/relatorios-cidade/.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/.snapshot.json \ + --research=data/relatorios-cidade/.research.json \ + --out-dir=docs/research/relatorios-cidade + ``` + Saídas: `docs/research/relatorios-cidade/-.pdf` + `.md` + (companion revisável em diff). Intermediários gitignored em + `data/relatorios-cidade/` (HTML, JSONs de emendas). + Flags úteis: `--emendas=` (replay sem rede), `--author=` + (default `JORGE SOLLA`), `--generated-at=` (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/.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. diff --git a/.gitignore b/.gitignore index 86618a15..55002191 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.opencode/agent/relatorio-cidade.md b/.opencode/agent/relatorio-cidade.md new file mode 100644 index 00000000..18157e00 --- /dev/null +++ b/.opencode/agent/relatorio-cidade.md @@ -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/.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. diff --git a/docs/changelog/2026-09-15-c163.md b/docs/changelog/2026-09-15-c163.md new file mode 100644 index 00000000..9743dd29 --- /dev/null +++ b/docs/changelog/2026-09-15-c163.md @@ -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. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 164f4726..00389359 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -691,6 +691,46 @@ anterior de cada fala está em `updates[].previousUrl` no JSON do run. O import (`pnpm camara:import --date `) 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= --out=data/relatorios-cidade/.snapshot.json +``` + +Depois, na workstation: `scp homeserver:~/teqo-report/data/relatorios-cidade/.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`) diff --git a/docs/plans/relatorio-cidade-viagem-impl.md b/docs/plans/relatorio-cidade-viagem-impl.md new file mode 100644 index 00000000..8f4c9faf --- /dev/null +++ b/docs/plans/relatorio-cidade-viagem-impl.md @@ -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= --out=`; 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= --research= [--emendas=] --out-dir=docs/research/relatorios-cidade`; sem `--emendas` faz o fetch oficial (D5) e cacheia o resultado em `data/relatorios-cidade/-.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/-.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= --out=data/relatorios-cidade/prova.snapshot.json` → `node scripts/build-city-report.mjs --snapshot=… --research= --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). diff --git a/scripts/build-city-report.mjs b/scripts/build-city-report.mjs new file mode 100644 index 00000000..146ae760 --- /dev/null +++ b/scripts/build-city-report.mjs @@ -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=` 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/.snapshot.json \ + * --research=data/relatorios-cidade/.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= --research= [--emendas=] [--out-dir=] [--author=]', + ) +} +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: '
', + footerTemplate: `
+ ${report.meta.title} · ${snapshot.municipality.name} · documento interno de campanha + pág. / +
`, + 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) diff --git a/scripts/cityReportSnapshot.mjs b/scripts/cityReportSnapshot.mjs new file mode 100644 index 00000000..8a855acc --- /dev/null +++ b/scripts/cityReportSnapshot.mjs @@ -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, + } +} diff --git a/scripts/extract-city-report-snapshot.mjs b/scripts/extract-city-report-snapshot.mjs new file mode 100644 index 00000000..7924f14b --- /dev/null +++ b/scripts/extract-city-report-snapshot.mjs @@ -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= --out=data/relatorios-cidade/.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= --out=\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) diff --git a/scripts/lib/cityReportBlocks.mjs b/scripts/lib/cityReportBlocks.mjs new file mode 100644 index 00000000..60d513a9 --- /dev/null +++ b/scripts/lib/cityReportBlocks.mjs @@ -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, + } +} diff --git a/scripts/lib/cityReportDatabase.mjs b/scripts/lib/cityReportDatabase.mjs new file mode 100644 index 00000000..6887b45b --- /dev/null +++ b/scripts/lib/cityReportDatabase.mjs @@ -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' + } +} diff --git a/scripts/lib/cityReportFormat.mjs b/scripts/lib/cityReportFormat.mjs new file mode 100644 index 00000000..8c8418fc --- /dev/null +++ b/scripts/lib/cityReportFormat.mjs @@ -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)}` +} diff --git a/scripts/lib/cityReportRender.mjs b/scripts/lib/cityReportRender.mjs new file mode 100644 index 00000000..a21f7456 --- /dev/null +++ b/scripts/lib/cityReportRender.mjs @@ -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) => + `
${block.items + .map( + (item) => + `${htmlEscape(item.label)}${htmlEscape(item.value)}`, + ) + .join('')}
` + +const renderCards = (block) => + `
${block.items + .map( + (item) => + `
${htmlEscape(item.title)}

${htmlEscape(item.body)}

`, + ) + .join('')}
` + +const renderGrid = (block) => + `
${block.cells + .map( + (cell) => + `
${cell.title ? `

${htmlEscape(cell.title)}

` : ''}${cell.blocks.map(renderBlockHtml).join('')}
`, + ) + .join('')}
` + +const renderKpis = (block) => { + const items = block.items + .map( + (item) => `
+ ${htmlEscape(item.label)} + ${htmlEscape(item.value)} + ${item.hint ? `${htmlEscape(item.hint)}` : ''} +
`, + ) + .join('') + return `
${items}
` +} + +const renderStats = (block) => { + const rows = block.rows + .map( + (row) => `
+ ${htmlEscape(row.label)} + ${htmlEscape(row.value)}${row.hint ? `${htmlEscape(row.hint)}` : ''}${row.source ? `fonte: ${htmlEscape(sourceLabel(row.source))}` : ''} +
`, + ) + .join('') + return `
${rows}
` +} + +const renderBullets = (block) => { + const items = block.items + .map((item) => { + const source = item.source + ? ` fonte: ${htmlEscape(sourceLabel(item.source))}` + : '' + return `
  • ${htmlEscape(item.text)}${source}
  • ` + }) + .join('') + return `
      ${items}
    ` +} + +const renderPair = (block) => { + const panel = (side) => `
    +

    ${htmlEscape(side.title)}

    +
      ${side.items.map((item) => `
    • ${htmlEscape(item.text)}
    • `).join('')}
    +
    ` + return `
    ${panel(block.left)}${panel(block.right)}
    ${ + block.note ? `

    ${htmlEscape(block.note)}

    ` : '' + }
    ` +} + +const renderTable = (block) => { + const head = block.columns + .map((column) => `${htmlEscape(column.label)}`) + .join('') + const rows = block.rows + .map( + (row) => + `${block.columns + .map( + (column) => + `${htmlEscape(row[column.key])}`, + ) + .join('')}`, + ) + .join('') + return `${head}${rows}
    ${htmlEscape(block.title)}
    ${ + block.note ? `

    ${htmlEscape(block.note)}

    ` : '' + }` +} + +const renderCallout = (block) => + `

    ${htmlEscape(block.title)}

    ${block.body + .map((paragraph) => `

    ${htmlEscape(paragraph)}

    `) + .join('')}
    ` + +const renderSources = (block) => { + const items = block.items + .map((item) => { + const link = item.url ? ` ${htmlEscape(item.url)}` : '' + return `
  • ${htmlEscape(sourceKindLabels[item.kind] ?? 'fonte')} — ${htmlEscape(item.label)}${ + item.date ? ` · ${htmlEscape(formatDateBr(item.date))}` : '' + }${link}
  • ` + }) + .join('') + const limits = block.limits.map((limit) => `
  • ${htmlEscape(limit)}
  • `).join('') + return `
      ${items}

    Limites

      ${limits}
    ` +} + +const renderBlockHtml = (block) => { + const title = block.title ? `

    ${htmlEscape(block.title)}

    ` : '' + 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 = `

    ${htmlEscape(block.text)}

    ` + break + case 'sources': + body = renderSources(block) + break + case 'footerNote': + return `` + default: + throw new Error(`Bloco desconhecido no render HTML: ${block.kind}`) + } + const sources = block.sources?.length + ? `

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

    ` + : '' + const className = block.kind === 'table' ? 'block table-block' : 'block' + return `
    ${title}${body}${sources}
    ` +} + +const indexItems = (report) => + report.sections.map((section) => `
  • ${htmlEscape(section.title)}
  • `).join('') + +export const renderReportHtml = (report) => { + const pageOne = report.page1.blocks.map((block) => + block.kind === 'footerNote' + ? renderBlockHtml(block) + : `
    ${renderBlockHtml(block)}
    `, + ) + const summaryHeader = `
    +
    +

    ${htmlEscape(report.meta.title)}

    +

    ${htmlEscape(report.meta.municipalityName)}

    +

    ${htmlEscape(report.meta.region)} · prioridade e classe na leitura da base

    +
    +
    +

    Gerado em ${htmlEscape(report.meta.generatedAtLabel)}

    +

    Base lida em ${htmlEscape(report.meta.readAtLabel)} · snapshot do momento

    +

    pág. 1

    +
    +
    ` + + const deepHeader = `
    +
    +

    ${htmlEscape(report.meta.title)} — aprofundamento

    +

    ${htmlEscape(report.meta.municipalityName)}

    +
    +
    +

    Gerado em ${htmlEscape(report.meta.generatedAtLabel)}

    +

    Base lida em ${htmlEscape(report.meta.readAtLabel)}

    +
    +
    ` + + const sections = report.sections + .map((section) => { + const blocks = section.blocks.map(renderBlockHtml).join('') + return `
    ${`

    ${htmlEscape(section.title)}

    `}${blocks}
    ` + }) + .join('') + + return ` + +${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.municipalityName)} + +
    + ${summaryHeader} +
    ${pageOne.join('')}
    +
    +
    + ${deepHeader} +

    Índice

      ${indexItems(report)}
    + ${sections} +
    + +` +} + +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` +} diff --git a/scripts/lib/cityReportResearch.mjs b/scripts/lib/cityReportResearch.mjs new file mode 100644 index 00000000..1be2432e --- /dev/null +++ b/scripts/lib/cityReportResearch.mjs @@ -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 diff --git a/scripts/lib/cityReportTerritory.mjs b/scripts/lib/cityReportTerritory.mjs new file mode 100644 index 00000000..1a7052e7 --- /dev/null +++ b/scripts/lib/cityReportTerritory.mjs @@ -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, + } +} diff --git a/scripts/lib/portalTransparenciaEmendas.mjs b/scripts/lib/portalTransparenciaEmendas.mjs new file mode 100644 index 00000000..2ed8df1d --- /dev/null +++ b/scripts/lib/portalTransparenciaEmendas.mjs @@ -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 }) => Promise<{ ok: boolean, status: number, json: () => Promise }>, + * consultedAt?: string, + * maxPages?: number, + * }} [options] + * @returns {Promise} + */ +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), + } +} diff --git a/tests/int/cityReportSnapshot.int.spec.ts b/tests/int/cityReportSnapshot.int.spec.ts new file mode 100644 index 00000000..a988d71f --- /dev/null +++ b/tests/int/cityReportSnapshot.int.spec.ts @@ -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) + }) +}) diff --git a/tests/unit/cityReportBlocks.unit.spec.ts b/tests/unit/cityReportBlocks.unit.spec.ts new file mode 100644 index 00000000..e10ce257 --- /dev/null +++ b/tests/unit/cityReportBlocks.unit.spec.ts @@ -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) + }) +}) diff --git a/tests/unit/cityReportDatabase.unit.spec.ts b/tests/unit/cityReportDatabase.unit.spec.ts new file mode 100644 index 00000000..4007e09c --- /dev/null +++ b/tests/unit/cityReportDatabase.unit.spec.ts @@ -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', + ) + }) +}) diff --git a/tests/unit/cityReportFormat.unit.spec.ts b/tests/unit/cityReportFormat.unit.spec.ts new file mode 100644 index 00000000..33088523 --- /dev/null +++ b/tests/unit/cityReportFormat.unit.spec.ts @@ -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('—') + }) +}) diff --git a/tests/unit/cityReportRender.unit.spec.ts b/tests/unit/cityReportRender.unit.spec.ts new file mode 100644 index 00000000..89b0a51a --- /dev/null +++ b/tests/unit/cityReportRender.unit.spec.ts @@ -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 ' : `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('') + 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') + }) +}) diff --git a/tests/unit/cityReportResearch.unit.spec.ts b/tests/unit/cityReportResearch.unit.spec.ts new file mode 100644 index 00000000..da323572 --- /dev/null +++ b/tests/unit/cityReportResearch.unit.spec.ts @@ -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 = {}) => ({ + id, + answer: `Resposta de ${id}`, + sourceUrl: `https://exemplo.test/${id}`, + sourceDate: '2026-09-10', + ...overrides, +}) + +const baseResearch = (overrides: Record = {}) => ({ + 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/) }), + ) + }) +}) diff --git a/tests/unit/portalTransparenciaEmendas.unit.spec.ts b/tests/unit/portalTransparenciaEmendas.unit.spec.ts new file mode 100644 index 00000000..339b16ad --- /dev/null +++ b/tests/unit/portalTransparenciaEmendas.unit.spec.ts @@ -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 = {}) => ({ + 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/) + }) +})