From c1b22d1b29a2dc0b30cab53b5e6cf08723901432 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 17 Sep 2026 16:09:31 -0300 Subject: [PATCH] =?UTF-8?q?feat(C186):=20dossi=C3=AA=20Solla=20por=20cidad?= =?UTF-8?q?e=20+=20boletim=20modelo=20=E2=80=94=20skill=20+=20builder=20(C?= =?UTF-8?q?loses=20#1132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/dossie-solla-cidade/SKILL.md | 258 +++++++ .gitignore | 5 + .opencode/agent/dossie-solla-cidade.md | 41 ++ .opencode/commands/dossie-solla-cidade.md | 9 + docs/changelog/2026-09-17-c186.md | 1 + docs/plans/dossie-solla-cidade-impl.md | 183 +++++ scripts/build-dossie-solla-cidade.mjs | 266 ++++++++ scripts/lib/camaraFetch.mjs | 15 +- scripts/lib/cityReportRender.mjs | 44 +- scripts/lib/cityReportResearch.mjs | 6 +- scripts/lib/dossieBlocks.mjs | 414 ++++++++++++ scripts/lib/dossieBulletin.mjs | 71 ++ scripts/lib/dossieBulletinRender.mjs | 194 ++++++ scripts/lib/dossieCamara.mjs | 146 ++++ scripts/lib/dossieCareer.mjs | 151 +++++ scripts/lib/dossieHealthData.mjs | 94 +++ scripts/lib/dossieRender.mjs | 708 ++++++++++++++++++++ scripts/lib/dossieResearch.mjs | 324 +++++++++ scripts/lib/reportText.mjs | 53 ++ scripts/lib/test-affected-core.mjs | 10 + tests/unit/dossieBatchSkill.unit.spec.ts | 91 +++ tests/unit/dossieBlocks.unit.spec.ts | 180 +++++ tests/unit/dossieBulletin.unit.spec.ts | 67 ++ tests/unit/dossieCamara.unit.spec.ts | 108 +++ tests/unit/dossieCareer.unit.spec.ts | 54 ++ tests/unit/dossieHealthData.unit.spec.ts | 56 ++ tests/unit/dossieRender.unit.spec.ts | 175 +++++ tests/unit/dossieResearch.unit.spec.ts | 140 ++++ tests/unit/opencodeCommands.unit.spec.ts | 1 + 29 files changed, 3818 insertions(+), 47 deletions(-) create mode 100644 .agents/skills/dossie-solla-cidade/SKILL.md create mode 100644 .opencode/agent/dossie-solla-cidade.md create mode 100644 .opencode/commands/dossie-solla-cidade.md create mode 100644 docs/changelog/2026-09-17-c186.md create mode 100644 docs/plans/dossie-solla-cidade-impl.md create mode 100644 scripts/build-dossie-solla-cidade.mjs create mode 100644 scripts/lib/dossieBlocks.mjs create mode 100644 scripts/lib/dossieBulletin.mjs create mode 100644 scripts/lib/dossieBulletinRender.mjs create mode 100644 scripts/lib/dossieCamara.mjs create mode 100644 scripts/lib/dossieCareer.mjs create mode 100644 scripts/lib/dossieHealthData.mjs create mode 100644 scripts/lib/dossieRender.mjs create mode 100644 scripts/lib/dossieResearch.mjs create mode 100644 scripts/lib/reportText.mjs create mode 100644 tests/unit/dossieBatchSkill.unit.spec.ts create mode 100644 tests/unit/dossieBlocks.unit.spec.ts create mode 100644 tests/unit/dossieBulletin.unit.spec.ts create mode 100644 tests/unit/dossieCamara.unit.spec.ts create mode 100644 tests/unit/dossieCareer.unit.spec.ts create mode 100644 tests/unit/dossieHealthData.unit.spec.ts create mode 100644 tests/unit/dossieRender.unit.spec.ts create mode 100644 tests/unit/dossieResearch.unit.spec.ts diff --git a/.agents/skills/dossie-solla-cidade/SKILL.md b/.agents/skills/dossie-solla-cidade/SKILL.md new file mode 100644 index 00000000..535d1a8c --- /dev/null +++ b/.agents/skills/dossie-solla-cidade/SKILL.md @@ -0,0 +1,258 @@ +--- +name: dossie-solla-cidade +description: 'Gera o dossiê Solla por cidade (PDF A4 + .md, tudo o que Solla fez pela cidade/região por era) e o Boletim Informativo modelo de 1 página, a partir da base Teqo read-only + pesquisa web datada + fontes oficiais; aceita um município ou um lote separado por vírgula.' +--- + +# Dossiê Solla por cidade + boletim modelo (C186) + +Entrega, por município, um **par PDF A4 datado + companion `.md`** com tudo o que +**Jorge Solla fez pela cidade e por seu recorte regional ao longo da carreira** +(epidemiologista/SESAB, Secretaria Municipal de Saúde de Vitória da Conquista, +Secretaria de Atenção à Saúde do MS, SESAB 2007–2014 e Câmara 2015–2027) — toda +afirmação não trivial com **URL + data**, tudo sem fonte vira **lacuna +explícita** — **e** o **Boletim Informativo modelo** (1 página A4, linguagem de +eleitor, sem declaração de fontes). Os dois layouts vêm dos artefatos hi-fi +aprovados pelo `designer`. + +## Quando usar + +- A comunicação pede "o dossiê de " e/ou o "boletim modelo de " + — ou várias numa invocação (`/dossie-solla-cidade Ilheus, Itacare, Una`). +- Quem executa são o **orquestrador** (agente principal) + um sub-agente + **researcher por era, por cidade**, em paralelo + os scripts (`extract` no + homeserver, `build` local). O detalhe está em "Pipeline (etapas)". + +## Lote (várias cidades) + +A invocação aceita **um** município ou **vários**, separados por **vírgula**: + +```text +/dossie-solla-cidade Ilheus # N=1 +/dossie-solla-cidade Ilheus, Itacare, Una # lote +``` + +Parsing (orquestrador, **antes** de qualquer pesquisa): + +- separa por `,`; aplica `trim`; descarta vazio; +- cada token é aceito como **slug canônico** (`isMunicipalitySlug`) **ou** como + **nome** (fold acento-insensível `resolveMunicipalityName`; `null` = nome + desconhecido → token inválido) → `municipalityCatalogEntriesForCity`; +- **0 entradas** = token inválido; **>1 entrada** = ambíguo (ex.: `Salvador` + resolve para 19 zonas `salvador-ze-N` — peça o slug da zona explícito); +- **dedupe após a resolução**, preservando a ordem; +- token inválido/ambíguo vira **falha isolada** com o motivo no summary final — + **nunca** aborta o lote nem inventa slug. + +Uma cidade = um dossiê (PDF+MD) + **um** boletim PDF; **sem** índice/PDF +agregado. Falha de uma cidade/era **não** cancela as demais (**sucesso +parcial** explícito). + +## Pipeline (etapas) + +1. **Orquestrador (agente principal).** Parseia a lista (seção "Lote"), resolve + os slugs no catálogo (`src/lib/municipalityCatalog.ts`, 435 unidades; + Salvador = `salvador-ze-N`) e dispara **um researcher por era (A/B/C), por + cidade** (Task), em paralelo, com guarda de concorrência + (`MAX_RESEARCHERS_IN_FLIGHT = 6`). Não invente slug: confirme no catálogo. + Nunca retém pesquisa web nem o corpo de um `research.json` — só os recibos. +2. **Researcher (sub-agente, ×3 por cidade, em paralelo).** + `.opencode/agent/dossie-solla-cidade.md` — o **único** passo pesado de + contexto. Faz a pesquisa web datada da sua era e escreve + `data/dossie-solla-cidade/..research.json` (cada item com + `sourceUrl` + `sourceDate`; sem fonte, **não escreva o item** — ele vira + lacuna). Devolve **apenas o recibo curto** (seção "Recibo do researcher"). + Não roda ssh nem build. +3. **Extração read-only no homeserver — serializada, 1 cidade por vez.** Reusa + o extrator do relatório de cidade (`scripts/extract-city-report-snapshot.mjs`, + `CITY_REPORT_CONFIRM=1`, `withReadOnlyDatabaseUrl`) e a mesma receita do + runbook `docs/ops/teqo-1313-deploy.md` §"C163 — relatório de cidade" + (`~/teqo-report`). O recorte do dossiê que vive na base Teqo é o **acervo de + falas (2011+)**; o resto é API pública em tempo de build. +4. **Fontes oficiais em tempo de build (por cidade).** O builder busca emendas + do autor (`--emendas=` para replay), a atividade da Câmara (proposições e + discursos — janela sempre explícita) e o contexto IBGE/SIDRA; qualquer falha + **degrada para lacuna datada**, nunca zero silencioso. +5. **Render local (por cidade):** + ```bash + NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-cidade.mjs \ + --snapshot=data/dossie-solla-cidade/.snapshot.json \ + --research-dir=data/dossie-solla-cidade \ + --out-dir=docs/research/dossie-solla-cidade + ``` + Saídas: `docs/research/dossie-solla-cidade/--dossie.pdf` + + `-dossie.md` + `-boletim.pdf`. Intermediários gitignored em + `data/dossie-solla-cidade/` (HTML, JSONs, emendas, logs). Flags úteis: + `--emendas=` (replay sem rede), `--author=` (default + `JORGE SOLLA`), `--generated-at=`, `DOSSIER_STRICT=1` (falha o run se + houver lacuna — conferência, não entrega). +6. **Summary final.** Uma linha por entrada, na ordem da lista: `entrada · status + (ok|failed) · dossiê/boletim (quando ok) · motivo (quando falha)`. + +## Briefing por era (A/B/C) + +O researcher recebe **uma era** e usa o checklist da era (ver "Contrato dos +JSONs"). Recorte fixo do produto: + +- **Era A (até 2006)** — formação/residência; SESAB (epidemiologista/sanitarista); + consultoria no MS; Secretaria Municipal de Saúde de Vitória da Conquista; + Secretaria de Atenção à Saúde do MS. O acervo interno de falas cobre 2011+: + **pré-2011 é lacuna explícita**, não invenção. +- **Era B (2007–2014)** — gestão da SESAB; hospitais, UPAs e equipamentos; + programas estaduais; obras e investimentos. Fontes: DOE-BA (DOOL), SESAB, + Transparência Bahia, SIOPS/DATASUS. +- **Era C (2015–2027)** — pronunciamentos, proposições/relatorias, emendas, + títulos/honrarias e vínculos locais, atuação regional. Fontes: API Câmara + (deputy id `178857`; `/discursos` **default 7 dias** — sempre passe a janela; + relatorias saem das tramitações), Portal da Transparência, Siga Brasil. + +Templates de busca: `"Jorge Solla" ` + ``; recorte por data +`after:YYYY-MM-DD before:YYYY-MM-DD`; `site:` por veículo +(atarde.com.br, correio24horas.com.br, bahianoticias.com.br, metro1.com.br, +bnews.com.br, bahia.ba, acordacidade.com.br, g1.globo.com/ba, +camara.leg.br, ptnacamara.org.br, ptbahia.org.br, saude.ba.gov.br, +jorgesolla.com.br); excluir ruído de campanha 2026 (`-eleição -candidato -voto +-coligação -pesquisa`) e preferir verbos de entrega (emenda, recurso, +repassado, hospital, UPA, SAMU, obra, inaugura, audiência, relator). + +## Recibo do researcher + +O sub-agente devolve **só** este recibo curto (≤ ~15 linhas) — **nunca** o corpo +do `research.json` (`items[].answer/details`): + +```jsonc +{ + "slug": "ilheus", + "era": "C", + "status": "ok", // ou "failed" + "researchPath": "data/dossie-solla-cidade/ilheus.c.research.json", + "researchedAt": "2026-09-17T10:00:00.000Z", + "itemCount": 5, // itens da era com fonte + "gapCount": 2, // == gaps.length + "newsCount": 3, + "gaps": ["era_c_titulos", "…"], + "failureReason": "…" // opcional (só quando status = failed) +} +``` + +`status: "failed"` carrega o motivo e **não** escreve corpo parcial; o +orquestrador agrega os recibos no summary final — é o único dado de pesquisa que +cruza para ele. + +## Contrato dos JSONs + +`..research.json` (um arquivo por era; `era` ∈ `A|B|C`): + +```jsonc +{ + "municipalitySlug": "ilheus", // obrigatório + "era": "C", // obrigatório: A | B | C + "researchedAt": "2026-09-17T10:00:00.000Z", // obrigatório + "items": [ + { + "id": "era_c_emendas", // id do checklist da era + "answer": "R$ 1,2 milhão para equipamentos de saúde", + "details": "opcional; aceita {{fonte}} / {{fonte:N}}", + "sphere": "municipio", // municipio | regiao | polo (default municipio) + "numbers": [ // opcional + { "label": "Saúde", "value": "R$ 1,2 mi", "year": "2024", "phase": "empenhado" } + ], + "sourceUrl": "https://…", // obrigatório no item publicado + "sourceDate": "2026-09-15", // obrigatório + "extraSources": [{ "label": "…", "url": "https://…", "date": "2026-09-01" }], + "consultedAt": "2026-09-17T09:00:00.000Z" + } + ], + "news": [ + { "title": "…", "outlet": "…", "publishedAt": "2024-06-01", "url": "https://…", "summary": "…" } + ], + "gaps": [{ "id": "…", "label": "…", "reason": "…" }] +} +``` + +Checklist por era (ids): + +- **A:** `era_a_formacao`, `era_a_sesab`, `era_a_consultor_ms`, `era_a_conquista`, + `era_a_sas_ms`. +- **B:** `era_b_sesab`, `era_b_equipamentos`, `era_b_programas`, `era_b_obras`. +- **C:** `era_c_discursos`, `era_c_proposicoes`, `era_c_emendas`, + `era_c_titulos`, `era_c_atuacao`. + +Item sem `sourceUrl`/`sourceDate`, item ausente ou item de outra era vira +**lacuna** (`Não pesquisado.` / `Sem fonte: …`); `sphere` inválida vira lacuna +(`Esfera inválida`). `phase` ∈ `autorizado|empenhado|liquidado|pago|restos` +(default `nao_informado`). O `bulletinFacts` (ledger do boletim) só é populado +por item **com fonte** — o boletim não introduz fato novo. + +## Conteúdo do dossiê + +- **Capa** — série municipal, "INSUMO INTERNO", território, região/polo + adotado, data, "como usar", escopo/versão. +- **Resumo de uma olhada** — linha do tempo da carreira; principais entregas + localizadas (badge de esfera + valor + badge de fase + fonte); ganchos para o + boletim; o que falta. Guarda: **autorizado ≠ empenhado ≠ liquidado ≠ pago**. +- **Seção por era (A/B/C)** — recorte e método + trilha de recuperação; tabela + **Objeto/Valor/Ano/Fase/Esfera/Fonte**; cards "Atuação registrada"; títulos, + honrarias e vínculos locais. Seções vazias são omitidas. +- **Região / polo** — painel "Região não é cidade. Não some os dois recortes."; + duas listas lado a lado (município ≠ região/polo); itens regionais com + evidência de alcance; gancho e lacuna prioritária. +- **Fontes e limites** — tabela de lacunas; notícias/documentos consultados + (larguras fixas 9/14/49/28%); limites de cobertura; regras para uso editorial; + nota de defeso eleitoral 2026. + +## Conteúdo do Boletim modelo (1 página A4) + +- Cabeçalho com município + rótulo **"Modelo — insumo interno"**; abertura + "O que Jorge Solla fez por "; **≤6 destaques** (eyebrow "Área · + cidade/região", número em destaque, título curto, nota); timeline de 4 passos + da trajetória; **"E mais" com ≤14 itens** em duas colunas; rodapé com defeso. +- **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem + CTA de campanha; herda **apenas** fatos com fonte do dossiê. + +## Guardrails de produto (não negociáveis) + +- **Sem fonte, não publica**: afirmação não trivial sem URL+data vira lacuna. +- **Empenho ≠ pagamento**: cada valor acompanha sua fase + (autorizado/empenhado/liquidado/pago/restos); nunca consolidar como + equivalentes. +- **Esfera explícita** (`município`/`região`/`polo`): região/polo **nunca** é + somado ao município. +- **Leitura relativa**: nunca % estadual absoluto. +- **Atribuição de emenda** de bancada/relator só com autoria checada; sem + atribuição → lacuna. +- **Lacuna explícita** em vez de inferência; o acervo interno cobre 2011+ + (pré-2011 é lacuna). +- **PII mínima**: telefone/e-mail nunca entram. +- **Falha isolada** por era/cidade não cancela o lote (**sucesso parcial**). +- **Artefato gitignored** (repo público): PDF/MD/JSON nunca são commitados. +- **Defeso**: dossiê e boletim saem como insumo interno/modelo, sem CTA; a peça + final publicável é da comunicação/campanha. + +## Troubleshooting + +- **Página 1 do dossiê / boletim estourou**: o builder aborta com a altura + medida. **Aperte o teto de conteúdo/corte copy — nunca mexa no layout para + espremer.** +- **Era sem pesquisa**: o builder sintetiza o arquivo da era com lacunas + explícitas — não invente itens. +- **Câmara 429/500**: `getJsonWithBackoff` + paginação com teto; falha degrada + para lacuna datada. Os discursos sempre passam janela (`dataInicio`/`dataFim`). +- **Sem chave do Portal da Transparência**: emendas viram lacuna (o dossiê sai). +- **Slug de pesquisa ≠ snapshot**: o builder recusa o par — regenere, nunca + edite o JSON à mão para casar. +- **Token inválido/ambíguo no lote**: vira linha `failed` no summary com o + motivo; `Salvador` é ambíguo (19 zonas). +- **Chromium**: vem do `@playwright/test`; se faltar binário, + `pnpm exec playwright install chromium`. + +## Referências + +- Intenção: `docs/plans/dossie-solla-cidade.md`; impl: + `docs/plans/dossie-solla-cidade-impl.md`. +- Designs hi-fi (fonte de verdade do port): `docs/plans/dossie-solla-cidade-ui-design.html` + e `docs/plans/dossie-solla-cidade-boletim-ui-design.html`. +- Precedente: `.agents/skills/relatorio-cidade/SKILL.md`, + `.opencode/agent/relatorio-cidade.md`, `scripts/build-city-report.mjs`. +- Scripts: `scripts/build-dossie-solla-cidade.mjs`, `scripts/lib/dossie*.mjs`. +- Bio: `.opencode/skills/solla-comunicacao/referencia/perfil-e-posicoes.md`. +- Runbook de extração read-only: `docs/ops/teqo-1313-deploy.md` §C163. diff --git a/.gitignore b/.gitignore index 55002191..aa0e3ae3 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,11 @@ next-env.d.ts /data/relatorios-cidade/ /docs/research/relatorios-cidade/ +# C186 — dossiê Solla por cidade + boletim modelo: same rule (internal, dated, +# carry campaign-internal data). Only the skill/scripts/changelog are committed. +/data/dossie-solla-cidade/ +/docs/research/dossie-solla-cidade/ + # Local-only sensitive material (agents may read; never commit content) /private/** !/private/README.md diff --git a/.opencode/agent/dossie-solla-cidade.md b/.opencode/agent/dossie-solla-cidade.md new file mode 100644 index 00000000..090f5db1 --- /dev/null +++ b/.opencode/agent/dossie-solla-cidade.md @@ -0,0 +1,41 @@ +--- +description: Researcher por era — pesquisa web datada e escreve o research.json de uma era (A/B/C) do dossiê Solla por cidade; devolve só o recibo curto (etapa do lote /dossie-solla-cidade) +mode: subagent +--- + +# Dossiê Solla por cidade — researcher (por era) + +Você é a etapa **researcher** da skill `.agents/skills/dossie-solla-cidade/SKILL.md` +(fonte canônica: contrato dos JSONs, checklist por era, guardrails, recibo). +Leia-a antes de rodar qualquer comando. Você é invocado **uma vez por era** (o +orquestrador diz qual: `A`, `B` ou `C`) pelo agente principal, que já resolveu o +slug e coordena as demais eras/cidades em paralelo. + +## Papel + +1. Confirmar o slug canônico do município (catálogo; Salvador = `salvador-ze-N`) + e a era atribuída. +2. Fazer a **pesquisa web datada** da era e escrever + `data/dossie-solla-cidade/..research.json` no contrato da skill + (ex.: `ilheus.c.research.json`) — item sem `sourceUrl`/`sourceDate` não vai + para o arquivo (vira lacuna explícita). Use o checklist da era e marque + `sphere` (`municipio`/`regiao`/`polo`); região/polo nunca é somado ao + município. +3. Devolver **apenas o recibo curto** (seção "Recibo do researcher" da skill): + `slug`, `era`, `status`, `researchPath`, `researchedAt`, `itemCount`, + `gapCount`, `newsCount`, `gaps`, `failureReason?`. **Nunca** devolva o corpo + do `research.json` (`items[].answer/details`, …). + +## Limites + +- **Não** conduza a extração nem o build — são etapas determinísticas do + orquestrador (`scripts/extract-city-report-snapshot.mjs` no homeserver, + `scripts/build-dossie-solla-cidade.mjs` local). Não faça `ssh`, `scp` nem + render. +- **Nunca** escreva na base de produção: não use `db:pull`/snapshot de banco. +- **Nunca** commite o `research.json`/PDF/MD/snapshot (dado interno; repo público + — artefato gitignored). +- Não invente fato sem fonte; não trate empenho como pagamento (informe a fase); + não inclua telefone/e-mail de liderança; não reconstrua a carreira pré-2011 + fora do acervo (é lacuna). +- Não edite os scripts nem o layout para "caber". diff --git a/.opencode/commands/dossie-solla-cidade.md b/.opencode/commands/dossie-solla-cidade.md new file mode 100644 index 00000000..06a96f99 --- /dev/null +++ b/.opencode/commands/dossie-solla-cidade.md @@ -0,0 +1,9 @@ +--- +description: Gera o dossiê Solla por cidade (PDF A4 + .md) e o Boletim Informativo modelo de 1 página para um município ou um lote separado por vírgula +--- + +Carregue a skill `dossie-solla-cidade` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os slugs canônicos, dispara um researcher por era (A/B/C) por cidade, roda a extração read-only, busca as fontes oficiais e o build local (dossiê + boletim) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-cidade/SKILL.md` — não a transcreva nem recrie o fluxo. + +Uso: `/dossie-solla-cidade [, ...]` — ex.: `/dossie-solla-cidade Ilhéus, Itacaré, Una`. Uma cidade = dossiê (`--dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). + +$ARGUMENTS diff --git a/docs/changelog/2026-09-17-c186.md b/docs/changelog/2026-09-17-c186.md new file mode 100644 index 00000000..f3738d9a --- /dev/null +++ b/docs/changelog/2026-09-17-c186.md @@ -0,0 +1 @@ +**C186 — Dossiê Solla por cidade + Boletim Informativo modelo (2026-09-17):** nova skill `/dossie-solla-cidade` (command + subagente researcher por era A/B/C) que reúne, com fonte e data, tudo o que Jorge Solla fez pela cidade/região ao longo da carreira e entrega dois artefatos internos por município: o **dossiê** (PDF A4 de 7 páginas + companion `.md` — capa/resumo/timeline/seções por era/região-polo/lacunas/fontes/defeso) e o **Boletim modelo** (1 página A4, linguagem de eleitor, sem fontes). Arquitetura: um único builder (`scripts/build-dossie-solla-cidade.mjs`) lê o snapshot read-only (mesmo extrator C163, serializado por cidade), a pesquisa por era (`dossieResearch.mjs`, checklist por era, esfera explícita `município|região|polo`, `phase` por valor) e as fontes oficiais em tempo de build (emendas 2015–2026 via Portal da Transparência, Câmara com janela ≤4 anos por limitação da API, contexto IBGE/SIDRA), monta o dossiê e deriva dele o ledger `bulletinFacts` — o boletim herda **apenas** fato com fonte, por construção. O contrato sólido de escape/`{{fonte}}` foi extraído de `cityReportRender.mjs` para o dono único `scripts/lib/reportText.mjs` (export aditivo equivalente em `cityReportResearch.mjs`; specs C163 seguem verdes). O builder emite os dois PDFs num Chromium só, cada um com sua guarda de fit (`data-page="resumo"` e `data-page="boletim"`), e cada era/cidade falha isolada (sucesso parcial). Sem schema, migration, escrita no DB, Consent ou URL pública — skills/scripts/testes apenas; PDF/MD/JSON gitignored (repo público). Novos specs: `dossieResearch`, `dossieBlocks`, `dossieRender`, `dossieBulletin`, `dossieCamara`, `dossieHealthData`, `dossieCareer`, `dossieBatchSkill` (+ `opencodeCommands` e `test-affected-core` atualizados). `pnpm gate:fast` verde (346 arquivos / 3607 testes). Issue: #1132. diff --git a/docs/plans/dossie-solla-cidade-impl.md b/docs/plans/dossie-solla-cidade-impl.md new file mode 100644 index 00000000..062a0ecf --- /dev/null +++ b/docs/plans/dossie-solla-cidade-impl.md @@ -0,0 +1,183 @@ +# Impl: Dossiê Solla por cidade — o que ele fez pela cidade e pela região (skill → dossiê + boletim modelo) + +Status: aprovado +Atualizado em: 2026-09-17 +Issue: #1132 +Intenção: docs/plans/dossie-solla-cidade.md +Appetite restante: ~3–4 dias eng (herdado) + +## Leitura da intenção (Outcome / O que NÃO negociar / O que reavaliar) + +- **Outcome:** `/dossie-solla-cidade ` entrega, numa invocação, um par **PDF A4 datado + `.md`** (capa, resumo de uma olhada, seções por era A/B/C, região/polo, títulos/vínculos, lacunas, fontes, defeso) **e** o **Boletim Informativo modelo** (1 página A4, linguagem de eleitor, sem fontes). Toda afirmação não trivial tem URL+data; o que falta é lacuna explícita; números com fase; esfera rotulada e nunca somada; o boletim herda só fatos já com fonte do dossiê. +- **O que NÃO negociar:** defeso (dossiê e boletim são "insumo interno/modelo", sem CTA); "sem fonte, não publica"; **empenho ≠ pagamento** (autorizado/empenhado/liquidado/pago/restos por valor); esfera explícita (`município`/`região`/`polo`, nunca somada); leitura relativa (nunca % estadual absoluto); atribuição de emenda de bancada/relator só com autoria checada; PII mínima; artefato **gitignored** (repo público); falha isolada no lote não cancela as demais; **não alterar `/relatorio-cidade`**; nenhum schema/migration, nenhuma escrita no DB, nenhum segundo cadastro de pessoa. +- **O que reavaliar (hipóteses da "Direção no codebase"):** + - "build reusando `scripts/build-city-report.mjs`" — o _page shell_, o CSS e o contrato de página 1 do C163 são do relatório eleitoral; do builder só se reusa a **técnica** (Chromium `page.pdf`, guarda de overflow), não o arquivo. O que se compartilha de verdade é `cityReportFormat`, `cityReportResearch` (primitivas), `cityReportTerritory` e `camaraFetch`/`camaraSpeeches`/`portalTransparenciaEmendas`. + - "extrair Câmara/SIOPS/DATASUS/IBGE no snapshot" — o snapshot só carrega o que existe no DB; desses, só o **acervo de falas** (2011+) está na base. O resto é API pública em tempo de build (como as emendas já são no C163). Logo **não** se estende o extrator. + - "boletim herda fatos do dossiê" — a herança precisa ser **estrutural** (um ledger de fatos com fonte produzido na montagem do dossiê), não uma segunda pesquisa. +- **Design (fonte de verdade do port — portar classe a classe):** + - **Dossiê** (`docs/plans/dossie-solla-cidade-ui-design.html`), 5 cenas/`.sheet`: **01 Capa** (barra accent, kicker, caixa "INSUMO INTERNO", título, `
` Território/Região-polo/Data, "Como usar" + NEEDS ASSET selo, rodapé escopo/versão); **02 Página 1 — resumo** (01 trajetória = grid-3 com 6 cartões de período; 02 principais entregas localizadas = linhas com badge `scope`, era, valor + badge `phase`, `(fonte)`; guarda "autorizado ≠ empenhado ≠ liquidado ≠ pago"; 03 ganchos para o boletim + 04 o que falta em `warning`); **03 Seção por era (Era C representativa)** = "Recorte e método" + trilha, tabela `document-table` **Objeto/Valor/Ano/Fase/Esfera/Fonte**, cards "Atuação registrada" com `scope`+ano+`(fonte)`, "Títulos, honrarias e vínculos locais" + NEEDS ASSET; **04 Região/polo** = painel de aviso "Região não é cidade. Não some os dois recortes.", comparação lado a lado `município ≠ região/polo`, tabela de itens regionais (Item/Esfera/Evidência de alcance/Fonte), gancho + lacuna prioritária; **05 Fontes e limites** = tabela de **lacunas** (Lacuna/Onde foi buscado/Limite editorial/Próximo passo), tabela de notícias com larguras fixas **9/14/49/28%**, blocos "Limites de cobertura" + "Regras para uso editorial", aside **defeso**. + - **Boletim** (`docs/plans/dossie-solla-cidade-boletim-ui-design.html`), 1 `.sheet`: header (kicker + município + aviso "Dados ilustrativos" + `.model-label` "Modelo — insumo interno"); abertura (h1 "O que Jorge Solla fez por " + lead + NEEDS ASSET foto); **6 `.highlight-card`** (grid-3) com eyebrow "Área · cidade/região", número grande, título curto, nota; **timeline de 4 passos** (1999–2002 / 2003–2005 / 2007–2014 / DESDE 2015); **"E mais"** com 14 itens ✓ em 2 colunas + NEEDS ASSET selo/recorte; rodapé "Fatos selecionados do dossiê da cidade." + defeso + "MODELO · INSUMO INTERNO · A4 · página 1/1". + +## Abordagem recomendada + +```mermaid +flowchart LR + A["/dossie-solla-cidade "] --> B["orquestrador: resolve slug
isMunicipalitySlug / resolveMunicipalityName"] + B --> C["N researchers por era
(A|B|C × cidade, paralelo, teto 6)"] + C --> R["..research.json
+ recibo curto (só status/paths/gaps)"] + B --> E["extração read-only, 1 cidade por vez
extract-city-report-snapshot.mjs
composeCityReportSnapshot"] + E --> S[".snapshot.json"] + R --> G["build-dossie-solla-cidade.mjs"] + S --> G + H["APIs públicas em tempo de build
Câmara · emendas 2015–2026 · IBGE/DATASUS/SIOPS"] --> G + G --> I["dossieBlocks.mjs: capa+resumo+eras+região+fontes
→ bulletinFacts ledger (só fato com fonte)"] + I --> J["dossieRender.mjs → PDF A4 multi-página + .md"] + I --> K["dossieBulletin.mjs (só ledger) → dossieBulletinRender.mjs"] + K --> L["PDF 1×A4 boletim modelo"] + J --> M["docs/research/dossie-solla-cidade/-.pdf/.md"] + L --> M +``` + +**Opções consideradas:** (a) estender o pipeline C163 com um "modo dossiê"; (b) uma skill/command/agent novos + libs de conteúdo/render próprias compartilhando os donos puros existentes. +**Recomendação:** (b) — o recorte de conteúdo e a linguagem visual são outros; forçar o C163 a carregar dois produtos reabre o contrato de página 1 e o `assertSnapshotShape` que os testes dele pinam. A forma nova reusa o que é genuinamente comum (formatters, primitivas de pesquisa, território, fetchers) e mantém **um único dono** para cada concern. +**Rejeitadas:** (a) porque o custo de reverter é alto (contrato C163 em produção + specs pinadas) e a sobreposição de conteúdo é mínima; forkar tudo sem reusar (formatters/`{{fonte}}`) twin-a guardas que já existem. + +### Componentes / mudanças + +**Skill / command / subagente** + +- **`.agents/skills/dossie-solla-cidade/SKILL.md`** (novo) — fonte canônica. Seções: `Lote`, `Pipeline (etapas)`, `Briefing por era (A/B/C)` + `Recibo do researcher`, `Contrato dos JSONs`, `Conteúdo` (dossiê + boletim), `Guardrails`, `Troubleshooting`, `Referências`. Aponta os dois HTMLs hi-fi. +- **`.opencode/commands/dossie-solla-cidade.md`** (novo) — frontmatter só `description:` (sem `model`, pin OPS101); corpo carrega a skill pelo nome exato, cita `.agents/skills/dossie-solla-cidade/SKILL.md` e termina em `$ARGUMENTS`. +- **`.opencode/agent/dossie-solla-cidade.md`** (novo) — `description:` + `mode: subagent`; researcher **por era**; escreve `data/dossie-solla-cidade/..research.json`; devolve só o recibo; proibido `ssh`/`scp`/build/`--municipality=`/`--snapshot=`/commitar. + +**Pesquisa / libs puras** + +- **`scripts/lib/dossieResearch.mjs`** (novo) — dono do contrato do dossiê: `DOSSIER_RESEARCH_CHECKLIST` (itens com `era: 'A'|'B'|'C'`), `normalizeDossierResearchInput(raw, { now })` (lança sem `municipalitySlug`/`researchedAt`; item sem URL+data → lacuna; preserva `numbers[]`), `dossierItemById`, `dossierGapCount`. Reusa `isNonEmptyString`, `isValidDate`, `collectExtraSources`, `inNewsWindow`, `RESEARCH_NEWS_WINDOW_DAYS`. +- **`scripts/lib/cityReportResearch.mjs`** (editar, **aditivo**) — exportar as 5 primitivas acima; nenhuma mudança de comportamento (specs C163 seguem verdes). +- **`scripts/lib/dossieCareer.mjs`** (novo) — dono dos literais de carreira (período/papel/como recuperar/flag `uncertain`, ex.: `[INCERTO: 1989 vs 1990]`, `2005 vs 2006`) + os 4 passos condensados do boletim + constantes (`SOLLA_DEPUTY_ID=178857`, nascimento 11/04/1961, "nunca foi deputado estadual", Comenda Dois de Julho). Fonte única dos dois artefatos. +- **`scripts/lib/dossieCamara.mjs`** (novo) — `fetchCamaraActivity({ deputyId, from, to, fetchImpl })`: proposições (`/proposicoes?idDeputadoAutor=178857&itens=100`), discursos (`/deputados/178857/discursos?dataInicio=&dataFim=` — **default 7 dias**, sempre passar janela), relatorias via `/proposicoes/{id}/tramitacoes` (`/relatorias` é 405). Reusa `getJson`/`getJsonWithBackoff` (`camaraFetch.mjs`), `SOLLA_DEPUTY_ID`/`LEGISLATURE_RANGES` (`camaraSpeeches.mjs`); paginação + teto + `e mais N` + cache em `/data/dossie-solla-cidade/`; falha → lacuna. +- **`scripts/lib/dossieHealthData.mjs`** (novo) — séries SIOPS/DATASUS/IBGE (SIDRA `/values/t/{tabela}/n6/{ibge}/v/{var}/p/{ano}`, OpenDataSUS CKAN), `fetchImpl` injetável, número-only e falha-fechado → lacuna. +- **Reusos sem edição:** `cityReportFormat.mjs` (formatadores), `cityReportTerritory.mjs` (`buildTerritoryPanorama`), `cli.mjs` (`dieWithLabel`/`parseEqualsFlags`/`loadCliEnv`/`isTruthyEnv`), `portalTransparenciaEmendas.mjs` (`fetchAuthorEmendas` — o builder passa `years` explícito; **`EMENDAS_YEARS` default 2023–2026 fica intocado**). +- **`scripts/lib/reportText.mjs`** (novo) — dono único de `htmlEscape`, `htmlWithLinks`, `INLINE_SOURCE_PATTERN`, `renderInlineSourcesHtml`/`Md` (contrato `{{fonte}}`/`{{fonte:N}}`); `cityReportRender.mjs` passa a importar daqui. + +**Conteúdo / render** + +- **`scripts/lib/dossieBlocks.mjs`** (novo) — `buildDossierReport({ snapshot, research, sources, emendas, generatedAt })` → `{ meta, cover, page1, sections, bulletinFacts, gaps }`; ids `capa`, `resumo`, `era-a|era-b|era-c`, `regiao`, `fontes`; badges `scope`/`phase`; tabela de lacunas; **omite** seções vazias sem gastar página; **nunca soma** região/polo; constrói o `bulletinFacts` (`{ id, era, sphere, topic, headline, number, sourceUrl, sourceDate }`) **só** a partir de blocos com `sourceUrl`. +- **`scripts/lib/dossieRender.mjs`** (novo) — `renderDossierHtml`/`renderDossierMd`; porta as classes do hi-fi (`.sheet`, `.report-header/-kicker/-meta/-footer`, `.section-title`, `.eyebrow`, `.scope`/`.scope-region`, `.phase`, `.document-table`, `.placeholder-bar`, `.asset-box`) e usa `reportText.mjs` para inline sources. +- **`scripts/lib/dossieBulletin.mjs`** (novo) — `buildBulletin({ facts, municipality, generatedAt })`: seleciona **≤6 highlights** (determinístico: `municipio` antes de `regiao/polo`, com número antes de sem número, era C primeiro) + **≤14 "e mais"** + timeline de 4 passos; sem acesso a research/snapshot crus. +- **`scripts/lib/dossieBulletinRender.mjs`** (novo) — `renderBulletinHtml`; porta `.model-label`, `.highlight-card`, `.highlight-number`, `.timeline-step`, `.more-item`, `.asset-box`; **sem** seção de fontes; NEEDS ASSET foto/selo/recorte estáticos. +- **`scripts/build-dossie-solla-cidade.mjs`** (novo) — CLI `--snapshot= --research-dir= --out-dir= [--emendas=] [--author=] [--generated-at=]`; lê `.{a,b,c}.research.json` do `--research-dir` (o orquestrador não agrega corpo), exige `municipalitySlug` igual ao snapshot, busca emendas 2015–2026 + Câmara + dados de saúde, monta dossiê e boletim, grava HTML cache + `.md`, e num único Chromium emite **dois PDFs** com **duas guardas de fit** separadas: `[data-page="resumo"]` vs `DOSSIER_PAGE_ONE_BUDGET_PX` e `[data-page="boletim"]` vs `BOLETIM_PAGE_BUDGET_PX` (reusa a técnica de `build-city-report.mjs:36-45,155-165`, não o código). `DOSSIER_STRICT=1` falha em lacuna (conferência, não entrega). +- **Extração read-only:** reusa `scripts/extract-city-report-snapshot.mjs` e `composeCityReportSnapshot` **sem alteração** (mesma receita `~/teqo-report`, `CITY_REPORT_CONFIRM=1`, `withReadOnlyDatabaseUrl`, serializada 1 cidade). + +**Testes** + +- `tests/unit/dossieResearch.unit.spec.ts` (novo) — normalizador: lança sem slug/`researchedAt`; item/item de lista sem fonte vira lacuna; `numbers` preservados; checklist por era. +- `tests/unit/dossieBlocks.unit.spec.ts` (novo) — conteúdo: capa/resumo/timeline/eras/região/títulos/lacunas; região/polo nunca somados; seção vazia omitida; `bulletinFacts` só de fato com fonte. +- `tests/unit/dossieRender.unit.spec.ts` (novo) — espelha `cityReportRender.unit.spec.ts`: escape, âncoras de URL, `{{fonte}}`, paridade HTML↔MD. +- `tests/unit/dossieBulletin.unit.spec.ts` (novo) — boletim herda só o ledger (todo highlight/"e mais" mapeia um fato com fonte); sem linha de fontes; rótulo "modelo — insumo interno"; tetos 6/14. +- `tests/unit/dossieCamara.unit.spec.ts` / `tests/unit/dossieHealthData.unit.spec.ts` (novos) — `fetchImpl` injetado; falha/HTTP ruim → lacuna; janela de discursos sempre explícita. +- `tests/unit/dossieBatchSkill.unit.spec.ts` (novo) — espelha `cityReportBatchSkill.unit.spec.ts`: lote por vírgula, resolução canônica (`isMunicipalitySlug`/`resolveMunicipalityName`/`salvador-ze-N`), pipeline serializado na extração, fan-out por era + guarda de concorrência, recibo curto, subagente researcher-only. +- `tests/unit/opencodeCommands.unit.spec.ts` (editar) — incluir `'dossie-solla-cidade'` na lista hardcoded (senão o command fica sem guarda). +- `tests/unit/cityReportResearch.unit.spec.ts` (manter verde) — prova que os exports aditivos não mudaram o C163. + +**Config / docs** + +- `.gitignore` (editar) — `/data/dossie-solla-cidade/` e `/docs/research/dossie-solla-cidade/` (hoje só os dirs do relatório estão ignorados). +- `docs/changelog/2026-09-17-c186.md` (novo) — uma entrada curta (convenção OPS85). +- **Migration:** sem migration. **Access / Consent:** N/A (sem DB/schema/PII nova). **UI:** Impeccable C já aprovado nos dois HTMLs; o implementer **porta classe a classe** — o gatilho do `designer` (a/b) só reabre se o port achar superfície ausente no design. + +### Dados → forma (pergunta 3 — data-presentation) + +- **Dossiê — forma escolhida:** **tabelas e listas com fonte por linha** (tabela de números com colunas Objeto/Valor/Ano/**Fase**/**Esfera**/Fonte; linhas de entrega com badge `scope` + badge `phase` + `(fonte)`; tabela de lacunas com "onde foi buscado/limite/próximo passo"). É a **forma mais pobre que ainda desbloqueia** a decisão (escolher tema/ângulo e saber o que falta): número sempre colado ao seu contexto e à sua fase, e a esfera visível em cada linha. **Rejeitadas:** dashboards/KPIs agregados (perdem a fonte item a item e convidam a somar); gráficos de série (não há série comparável entre eras/cargos); mapa (sem geografia no escopo); % estadual absoluto (anti-goal de produto). +- **Boletim — forma escolhida:** **número destacado + rótulo curto** (≤6 `.highlight-card`, um por área/esfera) + **lista densa de fatos curtos** (≤14 itens ✓) + timeline de 4 passos. Densidade > destaque grande, como decidiu o produto. **Rejeitadas:** tabela/planilha (documento de gestor, não de eleitor); parágrafos longos (estouram a 1 página); fontes no boletim (vivem só no dossiê); CTA de campanha (defeso). + +## Decisões de engenharia (A–G) + +**A. Contrato de pesquisa do dossiê.** +Opções: A) parametrizar `cityReportResearch.mjs` (`normalizeResearchInput(raw, { profile, checklist })`); B) novo `scripts/lib/dossieResearch.mjs` reusando as primitivas de validação exportadas do dono. +**Recomendação: B** — o checklist é **por era** e a entrada adiciona `numbers`; a forma do relatório (checklist plano + validadores por lista) é o contrato de produção do C163, pinado por `cityReportResearch.unit.spec.ts`/`cityReportBatchSkill.unit.spec.ts`. Exportar 5 helpers puros de forma **aditiva** e dar ao dossiê o próprio checklist/normalizador mantém um dono por concern e não reabre o C163. +**Rejeitadas:** A — um switch de profile dentro de cada validador reabre o contrato pinado e os formatos divergem (plano × por era); reescrever `isValidDate`/`inNewsWindow` no módulo novo — twin da guarda de origem. + +**B. Reuso do renderer.** +Opções: A) estender `cityReportRender.mjs`; B) fork dos renderers do dossiê/boletim; C) fork visual + extrair o contrato puro compartilhado para um dono próprio. +**Recomendação: C** — o _page shell_, o CSS e as classes do dossiê (`.sheet`, `.document-table`, `.scope`, `.phase`) e do boletim (`.highlight-card`, `.timeline-step`, `.more-item`) não existem no renderer C163 e misturá-los incha um módulo em produção. Mas `{{fonte}}`/`{{fonte:N}}`, escape e ancoragem de URL são um **contrato sutil** — vão para `scripts/lib/reportText.mjs`, que passa a ser o dono; `cityReportRender.mjs` importa de lá (saída idêntica, specs verdes) e os renderers novos também. +**Rejeitadas:** A — dois idiomas visuais no mesmo shell e o risco de tocar a guarda de página 1 do C163; B sem extração — twin-a a resolução de `{{fonte}}`, exatamente a drift que o repo proíbe. + +**C. Builders de seção.** +Opções: A) parametrizar `cityReportBlocks.mjs`; B) novo `scripts/lib/dossieBlocks.mjs`. +**Recomendação: B** — a arquitetura de informação é outra (capa, eras A/B/C, região/polo não somável, honrarias, tabela de lacunas) e reusa `cityReportFormat.mjs`, `dossieCareer.mjs`, `buildTerritoryPanorama` e as primitivas de pesquisa. Parametrizar A exigiria um modo em ~14 builders e reabriria os caps da página 1 que o C163 pina. +**Rejeitadas:** A pelas razões acima; duplicar `buildSourcedListBlocks` genericamente — os builders do dossiê têm colunas e badges próprios (`scope`/`phase`), então um helper local é mais honesto que um parâmetro a mais no dono do relatório. + +**D. Dois builders ou um.** +Opções: A) dois scripts (dossiê + boletim); B) um `scripts/build-dossie-solla-cidade.mjs` emitindo os dois, com `buildBulletin` puro em módulo próprio. +**Recomendação: B** — uma execução lê snapshot/pesquisa/APIs **uma vez**, monta o dossiê, deriva o ledger `bulletinFacts` e monta o boletim a partir dele; um único Chromium gera os dois PDFs. As guardas de fit são **separadas** (seletores e orçamentos distintos), o que o prompt exige. Dois scripts duplicariam o I/O e tornariam "o boletim só herda fato do dossiê" um invariante entre processos. +**Rejeitadas:** A por duplicar leitura/rede e enfraquecer o invariante; um único render com `--mode` — flag gêmea de duas saídas obrigatórias, sem ganho. + +**E. Herança de fatos pelo boletim.** +Opções: A) segunda saída derivada na mesma execução, a partir do ledger `bulletinFacts`; B) segunda passada de pesquisa. +**Recomendação: A** — garantia **estrutural**: `buildBulletin` recebe apenas o ledger (`{ era, sphere, topic, headline, number, sourceUrl, sourceDate }`), populado exclusivamente por blocos do dossiê que carregam `sourceUrl`. O renderer do boletim não tem acesso a research/snapshot crus, então não há como introduzir fato novo. Teste unitário afirma que todo highlight/"e mais" mapeia um fato com fonte. +**Rejeitadas:** B — uma segunda pesquisa reintroduz a síntese sem lastro (o rabbit hole "boletim vira fact-oide sem fonte") e quebra o "nunca introduz fato novo". + +**F. Extração.** +Opções: A) estender grupos do snapshot em `cityReportSnapshot.mjs`/`extract-city-report-snapshot.mjs`; B) extrator específico do dossiê reusando `withReadOnlyDatabaseUrl` + a receita ssh; C) reusar o extrator atual sem mudanças e buscar os fatos não-DB em tempo de build. +**Recomendação: C** — o único recorte do dossiê que vive na base Teqo é o **acervo de falas (2011+)**, que `composeCityReportSnapshot` já projeta (cidade/região/tema). Proposições/discursos/votações da Câmara, emendas, SIOPS/DATASUS/IBGE **não são linhas do DB** — são API pública, e o C163 já busca emendas assim em tempo de geração. Reusar a extração read-only como está preserva o contrato do C163 e mantém a leitura serializada (1 cidade por vez). Se um dia faltar um recorte que exista no DB, estende-se o dono **aditivamente**. +**Rejeitadas:** A — mexe no `assertSnapshotShape` e no formato de produção do C163 por dados que o DB não tem; B — twin da plumbing ssh/read-only e da receita do checkout `~/teqo-report`. **Emendas:** o builder passa `years` explícito (`2015–2026`) a `fetchAuthorEmendas`; `EMENDAS_YEARS` (2023–2026) fica intocado para o `/relatorio-cidade`. + +**G. Onde vivem os prompts de era e quantos researchers.** +Opções: A) prompts na prosa do SKILL + um subagente único, fan-out pelo orquestrador; B) um arquivo de agente por era; C) um researcher fazendo as três eras. +**Recomendação: A** — `SKILL.md` é dona do `Briefing por era` (checklist/ids por era + templates de fonte por período + o limite "acervo cobre 2011+, pré-2011 é lacuna") e do contrato de fan-out; `.opencode/agent/dossie-solla-cidade.md` é o researcher **por era**. O orquestrador dispara `3 × N` tasks com `MAX_RESEARCHERS_IN_FLIGHT = 6` (2 cidades por vez) — a guarda de concorrência — e serializa a extração do homeserver. +**Rejeitadas:** B — três arquivos quase idênticos para o mesmo papel (drift garantido; a era é argumento); C — serializa o passo mais caro de contexto e perde o paralelismo que justifica o appetite. + +## Fases verificáveis (tracer bullet first) + +1. **Tracer (contrato + conteúdo + render, sem PDF) — quota de meio dia.** `dossieCareer.mjs` + `dossieResearch.mjs` + (`dossieBlocks.mjs`/`dossieRender.mjs` com capa + resumo + Era C + fontes) produzindo HTML e `.md` válidos para uma cidade, com `dossieResearch`/`dossieBlocks`/`dossieRender` unit verdes. Prova o encadeamento mais arriscado cedo. Inclui o export aditivo em `cityReportResearch.mjs` e a extração de `reportText.mjs` (specs C163 verdes). +2. **Pipeline agêntico.** `SKILL.md` + command + subagente + `dossieBatchSkill.unit.spec.ts` + `opencodeCommands.unit.spec.ts` atualizado: lote, resolução de slug, recibos, fan-out por era e guarda de concorrência. Reuso do extrator read-only na extração serializada. +3. **Fontes e build.** `dossieCamara.mjs` + `dossieHealthData.mjs` + emendas 2015–2026; `build-dossie-solla-cidade.mjs` gera HTML cache, `.md` e o **PDF multi-página** com a guarda `[data-page="resumo"]`. Unit dos fetchers (fetch injetado). +4. **Boletim + port visual.** `bulletinFacts` → `dossieBulletin.mjs` + `dossieBulletinRender.mjs` → PDF 1×A4 com a guarda `[data-page="boletim"]`; port classe a classe dos dois hi-fi; `.gitignore` dos dois dirs; changelog. +5. **Gates.** `pnpm gate:fast` (lint 0 warnings, `tsc --noEmit`, unit) → rodar o build local de 1–2 cidades reais conferindo PDF/MD (aceite manual) → `pnpm push` (PR CI roda o restante). Sem `test:int` novo (nenhuma fronteira Payload/DB tocada; o `tests/int/cityReportSnapshot.int.spec.ts` segue como está). Sem e2e — não há superfície de runtime no app. + +## Rabbit holes / Não escopo (engenharia) + +- Forkar a plumbing de extração read-only ou a receita ssh/`~/teqo-report` (twins); estender grupos do snapshot para dados que não estão no DB. +- Alterar `/relatorio-cidade` além dos exports aditivos; mexer no default `EMENDAS_YEARS`; tocar na `assertSnapshotShape`/contrato de página 1 do C163. +- Segunda passada de pesquisa para o boletim; qualquer caminho em que o boletim leia research/snapshot crus. +- Schema/migration/collection nova/segundo cadastro de pessoa; writes no DB. +- Índice ou PDF agregado de lote (uma cidade = um par dossiê + 1 boletim). +- Reconstruir a carreira pré-2011 fora do acervo — vira **lacuna**, não invenção. +- Reabrir o design: os hi-fi estão aprovados; só port. Se faltar superfície, aciona-se o `designer` — não se redesenha na implementação. +- Sumir/consolidar esfera, tratar empenho como pago, % estadual absoluto, CTA de campanha no boletim. + +## Riscos e mitigação + +- **Regressão do C163 pelos exports/extração de `reportText`:** mudanças aditivas/realocação sem alterar saída; `cityReport*.unit.spec.ts` no `gate:fast`; nenhuma edição em `build-city-report.mjs`/`cityReportSnapshot.mjs`/extrator. +- **Overflow de página com conteúdo real:** orçamentos explícitos (`DOSSIER_PAGE_ONE_BUDGET_PX`, `BOLETIM_PAGE_BUDGET_PX`) e tetos de conteúdo no `dossieBlocks`/`bulletin`; se estourar, **aperta-se o teto/corta copy — nunca se espreme o layout**. +- **Câmara com API pesada (~3.338 proposições) e 429/500:** `getJsonWithBackoff`, paginação com teto, `e mais N`, cache em `/data/dossie-solla-cidade/`, falha → lacuna datada. +- **Acervo só 2011+:** eras A/B saem como lacuna explícita; o SKILL declara o limite (o design já traz "O acervo interno de falas cobre 2011+"). +- **Autoria de emenda ambígua (bancada/relator/homônimo):** mantém o match de autor do `fetchAuthorEmendas` (`authorNameMatches` + código distinto) e a esfera explícita; sem atribuição → lacuna. +- **Drift entre o SKILL (prosa da timeline) e `dossieCareer.mjs`:** teste cruza os literais-chave (períodos das eras, `178857`, 11/04/1961) entre os dois. +- **Vazamento de artefato no repo público:** `.gitignore` para os dois dirs; subagente proibido de commitar; nada em `docs/research/`/`data/` entra no git. +- **Fan-out estourando limite:** `MAX_RESEARCHERS_IN_FLIGHT = 6` + extração serializada; falha isolada por era/cidade vira `failed` no summary, sem cancelar o lote. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: dossiê PDF A4 + `.md` por cidade (capa/resumo/timeline/eras/região/honrarias/lacunas/fontes/defeso) **e** boletim modelo 1×A4 (6 destaques + timeline + "e mais", sem fontes). +- [ ] "Sem fonte, não publica"; empenho ≠ pagamento (fase por valor); esfera explícita e nunca somada; lacuna explícita; PII mínima; boletim sem CTA e sem fonte. +- [ ] Boletim herda **apenas** `bulletinFacts` (fato com fonte do dossiê) — proibido ler research/snapshot crus. +- [ ] Identificadores em inglês; português só em strings/labels/slugs visíveis; nenhum schema/migration/DB write/segundo cadastro. +- [ ] `{{fonte}}` com **um dono** (`reportText.mjs`); `/relatorio-cidade` intocado (só exports aditivos) e suas specs verdes. +- [ ] Skill/command/subagente acoplados pelo nome exato; `opencodeCommands.unit.spec.ts` atualizado; `dossieBatchSkill.unit.spec.ts` cobrindo lote/recibo/fan-out/guarda. +- [ ] Artefatos gitignored (`/data/dossie-solla-cidade/`, `/docs/research/dossie-solla-cidade/`); changelog `docs/changelog/2026-09-17-c186.md`. +- [ ] Unit novos verdes: `dossieResearch`, `dossieBlocks`, `dossieRender`, `dossieBulletin`, `dossieCamara`, `dossieHealthData`, `dossieBatchSkill`. +- [ ] `pnpm gate:fast` verde; build local aceito em 1–2 cidades; `pnpm push`. + +--- + +### Self-score (decision-quality, gate ≥4/5) + +1. **Decisões caras têm rejeitadas?** A–G todas com Opções/Recomendação/Rejeitadas explícitas (contrato de pesquisa, renderer, builders, nº de builders, herança, extração, fan-out). — **5/5** +2. **Abordagem cabe no appetite (3–4 dias)?** Sim: skill+scripts+tests only, sem schema, tracer bullet no dia 1, reuso máximo de libs do C163. — **5/5** +3. **Rabbit holes nomeados?** Sim: twin da extração, edição do C163, segunda pesquisa, pré-2011 inventado, reabertura de design, soma de esfera. — **5/5** +4. **Depth check: reusa shells/helpers existentes?** Sim — `cityReportFormat`, `cityReportResearch` (primitivas), `cityReportTerritory`, `camaraFetch`/`camaraSpeeches`, `fetchAuthorEmendas`, `reportText`, `cli`; o que é novo tem responsabilidade própria. — **4/5** (a extração de `reportText` é a única mudança em módulo existente e por isso fica no radar de regressão). +5. **Intenção (aceite de produto) permanece satisfeita?** Sim — os dois artefatos, os literais de produto (1 página, sem fontes, rótulo de modelo, densidade) e os guardrails estão no aceite acima; a engenharia não reescreveu o outcome. — **5/5** + +**Total 24/25 (média 4,8/5) — passa o gate ≥4/5.** diff --git a/scripts/build-dossie-solla-cidade.mjs b/scripts/build-dossie-solla-cidade.mjs new file mode 100644 index 00000000..132f51be --- /dev/null +++ b/scripts/build-dossie-solla-cidade.mjs @@ -0,0 +1,266 @@ +/** + * Dossiê + boletim builder (C186): snapshot + per-era research (+ emendas, + * Câmara, IBGE) → dossiê PDF A4 + `.md` and the one-page boletim PDF. + * + * Runs on the workstation: no database access, no persistence. Public sources + * are read at generation time (or replayed via `--emendas=`). One browser + * run produces both PDFs, each with its own page-fit guard. + * + * Usage: + * NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-cidade.mjs \ + * --snapshot=data/dossie-solla-cidade/.snapshot.json \ + * --research-dir=data/dossie-solla-cidade \ + * --out-dir=docs/research/dossie-solla-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 { dieWithLabel, isTruthyEnv, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs' +import { buildDossierReport } from './lib/dossieBlocks.mjs' +import { buildBulletin } from './lib/dossieBulletin.mjs' +import { renderBulletinHtml } from './lib/dossieBulletinRender.mjs' +import { CAMARA_DEFAULT_FROM, fetchCamaraActivity } from './lib/dossieCamara.mjs' +import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs' +import { fetchHealthData } from './lib/dossieHealthData.mjs' +import { renderDossierHtml, renderDossierMd } from './lib/dossieRender.mjs' +import { mergeDossierResearch, normalizeDossierResearchInput } from './lib/dossieResearch.mjs' +import { DEFAULT_AUTHOR_NAME, fetchAuthorEmendas } from './lib/portalTransparenciaEmendas.mjs' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const LABEL = 'build-dossie-solla-cidade' +const die = dieWithLabel(LABEL) +const DEFAULT_OUT_DIR = 'docs/research/dossie-solla-cidade' +const CACHE_DIR = 'data/dossie-solla-cidade' +/** The whole federal mandate, not just the C163 default window. */ +const DOSSIER_EMENDAS_YEARS = Array.from({ length: 12 }, (_value, index) => 2015 + index) + +const MM_TO_PX = 96 / 25.4 +const A4_HEIGHT_PX = Math.round(297 * MM_TO_PX) +const A4_WIDTH_PX = Math.round(210 * MM_TO_PX) +/** Rounding slack of the mm→px conversion; content must still fit the page. */ +const PAGE_FIT_TOLERANCE_PX = 10 +const DOSSIER_PAGE_ONE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX +const BOLETIM_PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX + +loadCliEnv() + +const { flags } = parseEqualsFlags(process.argv.slice(2)) +const snapshotPath = typeof flags.snapshot === 'string' ? flags.snapshot : null +const researchDir = typeof flags['research-dir'] === 'string' ? flags['research-dir'] : CACHE_DIR +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) { + die( + 'Uso: --snapshot= [--research-dir=] [--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 slug = snapshot.municipality?.slug +if (!slug) die('Snapshot sem "municipality.slug" — extração inválida.') + +const readEraResearch = async (era) => { + const path = join(researchDir, `${slug}.${era.toLowerCase()}.research.json`) + try { + const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8')) + return normalizeDossierResearchInput(raw, { now: generatedAt }) + } catch (error) { + if (error?.code === 'ENOENT') { + console.log(`[${LABEL}] Era ${era}: sem pesquisa (${path}) — vira lacuna explícita.`) + return normalizeDossierResearchInput( + { + municipalitySlug: slug, + era, + researchedAt: generatedAt.toISOString(), + items: [], + }, + { now: generatedAt }, + ) + } + die( + `Pesquisa da Era ${era} inválida (${path}): ${error instanceof Error ? error.message : error}`, + ) + } +} + +const researches = [] +for (const era of DOSSIER_ERA_IDS) researches.push(await readEraResearch(era)) +const research = mergeDossierResearch(researches) + +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} — regenere se o extrator mudou.`, + ) +} + +const baseName = `${slug}-${generatedAt.toISOString().slice(0, 10)}` +const emendasCachePath = join(CACHE_DIR, `${baseName}.emendas.json`) + +const resolveEmendas = async () => { + if (emendasPath) return readJson(emendasPath, 'emendas') + try { + const cached = JSON.parse(await readFile(resolve(ROOT, emendasCachePath), 'utf8')) + console.log(`[${LABEL}] emendas: cache ${emendasCachePath}`) + return cached + } catch (error) { + if (error?.code !== 'ENOENT') { + die( + `Cache de emendas ilegível (${emendasCachePath}): ${error instanceof Error ? error.message : error}`, + ) + } + } + const result = await fetchAuthorEmendas({ + years: DOSSIER_EMENDAS_YEARS, + municipalityCode: snapshot.municipality.ibgeCode, + municipalityName: snapshot.municipality.name, + authorName, + apiKey: process.env.PORTAL_TRANSPARENCIA_API_KEY ?? null, + consultedAt: generatedAt.toISOString(), + }) + await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true }) + await writeFile(resolve(ROOT, emendasCachePath), `${JSON.stringify(result, null, 2)}\n`) + console.log( + `[${LABEL}] emendas: ${result.status}${result.status === 'gap' ? ` (${result.reason})` : ` (${result.rows.length} registros)`} → ${emendasCachePath}`, + ) + return result +} + +const [emendas, camara, health] = await Promise.all([ + resolveEmendas(), + fetchCamaraActivity({ + from: CAMARA_DEFAULT_FROM, + to: generatedAt.toISOString().slice(0, 10), + }), + fetchHealthData({ ibgeCode: snapshot.municipality.ibgeCode ?? null, now: generatedAt }), +]) +console.log( + `[${LABEL}] câmara=${camara.status} ibge=${health.status} lacunas_pesquisa=${research.gaps.length}`, +) + +const report = buildDossierReport({ snapshot, research, emendas, camara, health, generatedAt }) +const bulletin = buildBulletin({ + facts: report.bulletinFacts, + municipality: snapshot.municipality.name, + region: snapshot.municipality.region ?? null, + generatedAt, +}) + +const dossierHtml = renderDossierHtml(report) +const dossierMd = renderDossierMd(report) +const bulletinHtml = renderBulletinHtml(bulletin) + +await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true }) +await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml) +await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml) + +await mkdir(resolve(ROOT, outDir), { recursive: true }) +const dossierMdFile = join(outDir, `${baseName}-dossie.md`) +const dossierPdfFile = join(outDir, `${baseName}-dossie.pdf`) +const bulletinPdfFile = join(outDir, `${baseName}-boletim.pdf`) +await writeFile(resolve(ROOT, dossierMdFile), dossierMd) +console.log(`[${LABEL}] companion → ${dossierMdFile}`) + +const measureOverflows = async (page, budget) => + page.evaluate( + (max) => + [...document.querySelectorAll('[data-page]')] + .map((element) => ({ + page: element.getAttribute('data-page'), + height: element.scrollHeight, + })) + .filter((row) => row.height > max), + budget, + ) + +const browser = await chromium.launch() +try { + const page = await browser.newPage() + await page.setViewportSize({ width: A4_WIDTH_PX, height: A4_HEIGHT_PX }) + + await page.setContent(dossierHtml, { waitUntil: 'load' }) + await page.emulateMedia({ media: 'print' }) + const hasSummary = await page.evaluate(() => + Boolean(document.querySelector('[data-page="resumo"]')), + ) + if (!hasSummary) die('Página de resumo ausente no HTML do dossiê — renderer quebrado.') + const dossierOverflows = await measureOverflows(page, DOSSIER_PAGE_ONE_BUDGET_PX) + if (dossierOverflows.length > 0) { + die( + `Página(s) do dossiê estouraram o A4 (${dossierOverflows + .map((row) => `${row.page}: ${row.height}px`) + .join(', ')} > ${DOSSIER_PAGE_ONE_BUDGET_PX}px úteis). ` + + 'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.', + ) + } + await page.pdf({ + path: resolve(ROOT, dossierPdfFile), + printBackground: true, + preferCSSPageSize: true, + margin: { top: '0', bottom: '0', left: '0', right: '0' }, + }) + + await page.setContent(bulletinHtml, { waitUntil: 'load' }) + await page.emulateMedia({ media: 'print' }) + const hasBulletin = await page.evaluate(() => + Boolean(document.querySelector('[data-page="boletim"]')), + ) + if (!hasBulletin) die('Bloco do boletim ausente no HTML — renderer quebrado.') + const bulletinOverflows = await measureOverflows(page, BOLETIM_PAGE_BUDGET_PX) + if (bulletinOverflows.length > 0) { + die( + `O boletim estourou (${bulletinOverflows + .map((row) => `${row.height}px`) + .join(', ')} > ${BOLETIM_PAGE_BUDGET_PX}px úteis). ` + + 'Corte itens/caps — o boletim TEM de caber em uma página.', + ) + } + await page.pdf({ + path: resolve(ROOT, bulletinPdfFile), + printBackground: true, + preferCSSPageSize: true, + margin: { top: '0', bottom: '0', left: '0', right: '0' }, + }) +} finally { + await browser.close() +} + +console.log(`[${LABEL}] PDF → ${dossierPdfFile}`) +console.log(`[${LABEL}] boletim PDF → ${bulletinPdfFile}`) +console.log( + `[${LABEL}] ${snapshot.municipality.name}: eras=${report.eras.length} entregas=${report.page1.deliveries.length} ` + + `highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} html_bytes=${Buffer.byteLength(dossierHtml)}`, +) + +if (isTruthyEnv(process.env.DOSSIER_STRICT) && research.gaps.length > 0) { + die( + `DOSSIER_STRICT=1 e ${research.gaps.length} lacuna(s) de pesquisa — o dossiê saiu, mas o modo estrito falha.`, + ) +} + +process.exit(0) diff --git a/scripts/lib/camaraFetch.mjs b/scripts/lib/camaraFetch.mjs index 97a6cf97..df72084b 100644 --- a/scripts/lib/camaraFetch.mjs +++ b/scripts/lib/camaraFetch.mjs @@ -22,18 +22,21 @@ const camaraHeaders = { 'User-Agent': CAMARA_USER_AGENT, Accept: 'application/js const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) -async function fetchOnce(url, { timeoutMs = 30_000, headers = camaraHeaders } = {}) { - const response = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) }) +async function fetchOnce( + url, + { timeoutMs = 30_000, headers = camaraHeaders, fetchImpl = fetch } = {}, +) { + const response = await fetchImpl(url, { headers, signal: AbortSignal.timeout(timeoutMs) }) if (!response.ok) throw new Error(`HTTP ${response.status} em ${url}`) return response } /** The Câmara API drops connections now and then — bounded retry for GETs. */ -export async function getText(url, { attempts = 3, ...options } = {}) { +export async function getText(url, { attempts = 3, fetchImpl = fetch, ...options } = {}) { let lastError for (let attempt = 1; attempt <= attempts; attempt += 1) { try { - return await (await fetchOnce(url, options)).text() + return await (await fetchOnce(url, { ...options, fetchImpl })).text() } catch (error) { lastError = error if (attempt < attempts) await sleep(2_000) @@ -55,12 +58,12 @@ export const getJson = async (url, options) => JSON.parse(await getText(url, opt */ export async function getJsonWithBackoff( url, - { attempts = 5, timeoutMs = 45_000, label = 'camara' } = {}, + { attempts = 5, timeoutMs = 45_000, label = 'camara', fetchImpl = fetch } = {}, ) { let lastError for (let attempt = 1; attempt <= attempts; attempt += 1) { try { - return JSON.parse(await getText(url, { attempts: 1, timeoutMs })) + return JSON.parse(await getText(url, { attempts: 1, timeoutMs, fetchImpl })) } catch (error) { lastError = error if (attempt < attempts) { diff --git a/scripts/lib/cityReportRender.mjs b/scripts/lib/cityReportRender.mjs index 30cfaf96..2a0c1511 100644 --- a/scripts/lib/cityReportRender.mjs +++ b/scripts/lib/cityReportRender.mjs @@ -6,44 +6,12 @@ */ import { formatDateBr, formatDateTimeBr, formatInteger } from './cityReportFormat.mjs' - -const htmlEscape = (value) => - String(value ?? '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - -const URL_PATTERN = /(https?:\/\/[^\s<>"')]+)/g - -/** Escapes the text and turns every bare URL into a clickable anchor (PDF links). */ -const htmlWithLinks = (value) => - htmlEscape(value).replace(URL_PATTERN, (url) => `${url}`) - -const INLINE_SOURCE_PATTERN = /\{\{fonte(?::(\d+))?\}\}/g - -/** - * Resolves the `{{fonte}}` / `{{fonte:N}}` tokens the content model leaves in - * the citation text into `(fonte)` anchors — inline, right where the excerpt - * cites the verified fact, so one text can point to more than one source. - */ -const renderInlineSourcesHtml = (value, sources) => - htmlWithLinks(value).replace(INLINE_SOURCE_PATTERN, (_match, index) => { - const position = Number(index ?? 1) - 1 - const url = sources?.[position] - if (!url) return '' - const label = sources.length > 1 ? `(fonte ${position + 1})` : '(fonte)' - return ` ${label}` - }) - -const renderInlineSourcesMd = (value, sources) => - String(value ?? '').replace(INLINE_SOURCE_PATTERN, (_match, index) => { - const position = Number(index ?? 1) - 1 - const url = sources?.[position] - if (!url) return '' - const label = sources.length > 1 ? `fonte ${position + 1}` : 'fonte' - return ` [(${label})](${url})` - }) +import { + htmlEscape, + htmlWithLinks, + renderInlineSourcesHtml, + renderInlineSourcesMd, +} from './reportText.mjs' const sourceKindLabels = { teqo: 'base Teqo', diff --git a/scripts/lib/cityReportResearch.mjs b/scripts/lib/cityReportResearch.mjs index 8e0a0f22..a35a99a4 100644 --- a/scripts/lib/cityReportResearch.mjs +++ b/scripts/lib/cityReportResearch.mjs @@ -33,9 +33,9 @@ const checklistById = new Map(RESEARCH_CHECKLIST.map((item) => [item.id, item])) /** Where an emenda indication points to: the município itself, its region or the regional pole. */ const EMENDA_SPHERES = ['municipio', 'regiao', 'polo'] -const isNonEmptyString = (value) => typeof value === 'string' && value.trim() !== '' +export const isNonEmptyString = (value) => typeof value === 'string' && value.trim() !== '' -const isValidDate = (value) => isNonEmptyString(value) && !Number.isNaN(Date.parse(value)) +export 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 && @@ -47,7 +47,7 @@ const inNewsWindow = (publishedAt, reference) => * url+date; invalid ones are dropped (the primary source already carries the * item, so a broken extra is not worth a visible gap). */ -const collectExtraSources = (entry) => +export const collectExtraSources = (entry) => (Array.isArray(entry?.extraSources) ? entry.extraSources : []) .map((extra) => ({ label: isNonEmptyString(extra?.label) ? extra.label.trim() : null, diff --git a/scripts/lib/dossieBlocks.mjs b/scripts/lib/dossieBlocks.mjs new file mode 100644 index 00000000..10ce7ad8 --- /dev/null +++ b/scripts/lib/dossieBlocks.mjs @@ -0,0 +1,414 @@ +/** + * Content model for the C186 dossiê (owner). Turns the read-only snapshot, the + * per-era research and the official sources (emendas, Câmara, health data) into + * the semantic report the renderer prints — and derives the `bulletinFacts` + * ledger (facts that already carry a source) that the boletim may reuse. + * + * Invariants enforced here: every delivered fact has URL+date; região/polo are + * labelled and never summed into the município; empty sections are omitted. + */ + +import { formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs' +import { + CAREER_NOTES, + CAREER_TIMELINE, + DOSSIER_ERAS, + DOSSIER_SCOPE, + careerTimelineHighlights, +} from './dossieCareer.mjs' +import { stripInlineSources } from './reportText.mjs' + +const MAX_DELIVERIES_PAGE_ONE = 3 +const MAX_HOOKS_PAGE_ONE = 2 +const MAX_PENDING_PAGE_ONE = 4 +const MAX_ERA_NUMBERS = 12 +const MAX_ERA_ACTIONS = 4 +const MAX_REGION_ITEMS = 4 +const MAX_SCOPE_LIST = 5 +const MAX_GAPS = 30 + +const sphereLabels = { municipio: 'município', regiao: 'região', polo: 'polo' } +export const dossierSphereLabel = (sphere) => sphereLabels[sphere] ?? sphere +const phaseLabels = { + autorizado: 'autorizado', + empenhado: 'empenhado', + liquidado: 'liquidado', + pago: 'pago', + restos: 'restos', + nao_informado: 'não informada', +} + +const emendasPhaseKeys = [ + ['empenhado', 'empenhado'], + ['liquidado', 'liquidado'], + ['pago', 'pago'], + ['restos', 'restoPago'], +] + +const isPositive = (value) => Number.isFinite(Number(value)) && Number(value) > 0 + +/** + * Emenda execution rows, one per non-zero phase — never consolidating phases as + * if equivalent (empenho ≠ pagamento). Sourced from the official portal. + */ +const emendaNumberRows = (emendas) => { + const rows = [] + if (!emendas || emendas.status !== 'ok') return rows + for (const row of emendas.rows ?? []) { + for (const [phase, key] of emendasPhaseKeys) { + if (!isPositive(row[key])) continue + rows.push({ + object: row.functionName ?? row.type ?? 'Emenda', + value: Number(row[key]), + year: row.year ? String(row.year) : null, + phase, + sphere: 'municipio', + sourceUrl: emendas.sourceUrl ?? null, + sourceDate: emendas.consultedAt ?? null, + }) + } + } + return rows +} + +const itemNumberRows = (items) => { + const rows = [] + for (const item of items) { + for (const number of item.numbers ?? []) { + rows.push({ + object: number.label, + value: number.value, + year: number.year, + phase: number.phase, + sphere: item.sphere, + sourceUrl: item.sourceUrl, + sourceDate: item.sourceDate, + }) + } + } + return rows +} + +/** + * @template T + * @param {T[]} list + * @param {number} max + * @returns {T[]} + */ +const limit = (list, max) => list.slice(0, max) + +const eraRecovery = { + A: 'Biografia Câmara · DOU/acervo do Ministério da Saúde · pesquisa web datada', + B: 'DOE-BA (DOOL) · notícias SESAB · Transparência Bahia · SIOPS/DATASUS', + C: 'API Câmara · Portal da Transparência · Siga Brasil · acervo interno read-only', +} + +const eraMethod = { + A: 'Itens ligados ao município foram conferidos em fonte oficial e separados por esfera. Cargos técnicos e de gestão têm datas e objetos distintos — sem atribuição local sem documento contemporâneo.', + B: 'A gestão estadual é lida por equipamento, política e obra; cada linha informa esfera e fonte. Região e polo não somam ao município.', + C: 'Mandato, relatorias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase.', +} + +const healthNumberRows = (health) => + (health?.status === 'ok' ? (health.items ?? []) : []).map((item) => ({ + object: item.topic, + value: item.detail, + year: item.year, + phase: 'nao_informado', + sphere: 'municipio', + sourceUrl: item.sourceUrl, + sourceDate: item.sourceDate, + })) + +const camaraItemsAsActions = (camara, era) => + (camara?.status === 'ok' ? (camara.items ?? []) : []).map((item) => ({ + sphere: 'municipio', + year: item.year ? String(item.year) : null, + title: item.title, + detail: item.detail, + sourceUrl: item.sourceUrl, + sourceDate: item.sourceDate, + era, + })) + +/** + * Builds the dossiê report model. `research` is the merged per-era research + * (see `mergeDossierResearch`); `emendas`/`camara`/`health` may be gaps. + * + * @param {{ + * snapshot: any, + * research: any, + * emendas?: any, + * camara?: any, + * health?: any, + * generatedAt?: Date, + * }} params + */ +export const buildDossierReport = ({ + snapshot, + research, + emendas = null, + camara = null, + health = null, + generatedAt = new Date(), +}) => { + const municipality = snapshot.municipality ?? {} + const municipalityName = municipality.name ?? municipality.slug ?? 'Município' + const region = municipality.region ?? null + const poleLine = region + ? `Território de Identidade ${region} · recorte regional não somável ao município` + : 'Recorte regional não somável ao município' + + const items = research.items ?? [] + const municipalItems = items.filter((item) => item.sphere === 'municipio') + const regionalItems = items.filter((item) => item.sphere !== 'municipio') + + const eraRows = emendaNumberRows(emendas) + const healthRows = healthNumberRows(health) + const camaraActions = camaraItemsAsActions(camara, 'C') + + const researchActionsByEra = (era) => + items + .filter((item) => item.era === era) + .map((item) => ({ + sphere: item.sphere, + year: item.numbers?.[0]?.year ?? null, + title: item.answer, + detail: item.details, + sourceUrl: item.sourceUrl, + sourceDate: item.sourceDate, + })) + + const eraSections = DOSSIER_ERAS.map((era) => { + const ownActions = researchActionsByEra(era.id) + const extraActions = era.id === 'C' ? camaraActions : [] + const numbers = + era.id === 'C' + ? limit( + [...emendaNumberRows(emendas), ...itemNumberRows(items.filter((i) => i.era === 'C'))], + MAX_ERA_NUMBERS, + ) + : limit( + [ + ...itemNumberRows(items.filter((i) => i.era === era.id)), + ...(era.id === 'B' ? healthRows : []), + ], + MAX_ERA_NUMBERS, + ) + const honors = items + .filter((item) => item.era === 'C' && item.id === 'era_c_titulos') + .map((item) => ({ text: item.answer, sourceUrl: item.sourceUrl })) + const actions = limit([...ownActions, ...extraActions], MAX_ERA_ACTIONS) + const remainingActions = ownActions.length + extraActions.length - actions.length + if (numbers.length === 0 && actions.length === 0) return null + return { + ...era, + method: eraMethod[era.id], + recovery: eraRecovery[era.id], + numbers, + remainingNumbers: 0, + actions, + remainingActions: Math.max(0, remainingActions), + honors, + } + }).filter(Boolean) + + const deliveries = limit( + [...municipalItems] + .sort((left, right) => { + const leftEra = left.era === 'C' ? 0 : 1 + const rightEra = right.era === 'C' ? 0 : 1 + if (leftEra !== rightEra) return leftEra - rightEra + const leftNumber = left.numbers?.length ? 0 : 1 + const rightNumber = right.numbers?.length ? 0 : 1 + return leftNumber - rightNumber + }) + .map((item) => ({ + sphere: item.sphere, + era: item.era, + year: item.numbers?.[0]?.year ?? null, + title: item.answer, + detail: item.details, + value: item.numbers?.[0]?.value ?? null, + phase: item.numbers?.[0]?.phase ?? null, + sourceUrl: item.sourceUrl, + sourceDate: item.sourceDate, + })), + MAX_DELIVERIES_PAGE_ONE, + ) + + const hooks = limit( + [...municipalItems, ...regionalItems].map((item) => ({ + topic: item.area, + angle: item.answer, + sourceUrl: item.sourceUrl, + })), + MAX_HOOKS_PAGE_ONE, + ) + + const gaps = limit( + (research.gaps ?? []).map((gap) => ({ + label: gap.label ?? gap.id, + reason: gap.reason, + nextStep: 'Apurar em fonte primária.', + })), + MAX_GAPS, + ) + + const pending = limit( + (research.gaps ?? []).map((gap) => gap.label ?? gap.id), + MAX_PENDING_PAGE_ONE, + ) + + const municipalNumbers = itemNumberRows(municipalItems) + const regionalNumbers = itemNumberRows(regionalItems) + const regionItems = limit( + regionalItems.map((item) => ({ + item: item.answer, + sphere: item.sphere, + evidence: item.details ?? item.label, + sourceUrl: item.sourceUrl, + })), + MAX_REGION_ITEMS, + ) + + const bulletinFacts = [ + ...items + .filter((item) => Boolean(item.sourceUrl)) + .map((item) => ({ + id: item.id, + era: item.era, + sphere: item.sphere, + area: item.area, + headline: stripInlineSources(item.answer), + detail: item.details ? stripInlineSources(item.details) : null, + value: item.numbers?.[0]?.value ?? null, + numberLabel: item.numbers?.[0]?.label ?? null, + year: item.numbers?.[0]?.year ?? null, + phase: item.numbers?.[0]?.phase ?? null, + sourceUrl: item.sourceUrl, + sourceDate: item.sourceDate, + })), + ...eraRows + .filter((row) => row.sourceUrl) + .map((row, index) => ({ + id: `emenda-${index}`, + era: 'C', + sphere: row.sphere, + area: 'Emendas', + headline: row.object, + detail: null, + value: formatMoneyCompact(row.value), + numberLabel: row.object, + year: row.year, + phase: row.phase, + sourceUrl: row.sourceUrl, + sourceDate: row.sourceDate, + })), + ] + + const sources = [] + for (const item of items) { + sources.push({ kind: 'web', label: item.label, date: item.sourceDate, url: item.sourceUrl }) + } + for (const row of research.news ?? []) { + sources.push({ + kind: 'web', + label: row.outlet ? `${row.outlet}: ${row.title}` : row.title, + date: row.publishedAt, + url: row.url, + }) + } + if (emendas?.status === 'ok' && emendas.sourceUrl) { + sources.push({ + kind: 'official', + label: 'Portal da Transparência — emendas do autor', + date: emendas.consultedAt, + url: emendas.sourceUrl, + }) + } + + const generatedAtLabel = formatDateTimeBr(generatedAt) + const readAtLabel = snapshot.meta?.readAt ? formatDateTimeBr(snapshot.meta.readAt) : '—' + + return { + meta: { + title: 'Dossiê Solla por cidade', + municipalityName, + region: region ?? '—', + generatedAt, + generatedAtLabel, + readAtLabel, + codeSha: snapshot.meta?.codeSha ?? null, + database: snapshot.meta?.database ?? null, + }, + cover: { + kicker: 'Comunicação · pesquisa documental', + series: 'Série municipal · BA', + internalNote: 'INSUMO INTERNO', + internalNoteSub: 'Não circular · não publicar', + eyebrow: 'Dossiê de atuação pública', + title: municipalityName, + subtitle: 'O que Jorge Solla fez pela cidade e por seu recorte regional ao longo da carreira', + territory: region ?? '—', + pole: poleLine, + howToUse: + 'Escolha um tema, confira a fonte ao lado da afirmação e trate toda lacuna como tarefa de apuração. Este arquivo não é texto final nem peça publicitária.', + scope: DOSSIER_SCOPE, + version: 'Documento de trabalho · versão 01', + }, + page1: { + timeline: careerTimelineHighlights(), + deliveries, + hooks, + pending, + }, + trajectory: CAREER_TIMELINE, + eras: eraSections, + region: { + ruleTitle: 'Região não é cidade. Não some os dois recortes.', + ruleBody: + 'Um equipamento de referência ou uma política regional pode atender moradores do município sem constituir entrega exclusiva para ele. Atribuição só entra com alcance documentado.', + municipal: { + label: 'município', + numbers: municipalNumbers, + items: limit(municipalItems, MAX_SCOPE_LIST), + total: municipalItems.length, + }, + regional: { + label: 'região / polo', + numbers: regionalNumbers, + items: limit(regionalItems, MAX_SCOPE_LIST), + total: regionalItems.length, + }, + items: regionItems, + hook: hooks[0] ?? null, + priorityGap: pending[0] ?? null, + }, + gaps, + news: (research.news ?? []).map((row) => ({ + date: row.publishedAt, + outlet: row.outlet ?? '—', + title: row.title, + url: row.url, + })), + limits: { + coverage: [ + 'O acervo interno de falas cobre 2011+; períodos anteriores dependem de fontes externas.', + 'Resultado de busca não prova ausência histórica.', + 'Emenda de bancada ou relator só recebe autoria quando a fonte a confirma.', + ], + editorial: [ + 'Sem fonte, não publica.', + 'URL e data acompanham cada afirmação não trivial.', + 'Município, região e polo permanecem separados.', + 'Valor sempre informa a fase de execução.', + ], + }, + notes: CAREER_NOTES, + sources, + bulletinFacts, + } +} + +export const dossierPhaseLabel = (phase) => phaseLabels[phase] ?? phaseLabels.nao_informado diff --git a/scripts/lib/dossieBulletin.mjs b/scripts/lib/dossieBulletin.mjs new file mode 100644 index 00000000..bc2bc1e1 --- /dev/null +++ b/scripts/lib/dossieBulletin.mjs @@ -0,0 +1,71 @@ +/** + * Boletim model (C186): one A4 page, voter-facing. It reads ONLY the + * `bulletinFacts` ledger produced by `buildDossierReport` — every fact there + * already carries a source, so the boletim cannot introduce a new fact. No + * sources are printed (they live in the dossiê). + */ + +import { formatDateTimeBr } from './cityReportFormat.mjs' +import { dossierSphereLabel } from './dossieBlocks.mjs' +import { BULLETIN_TIMELINE_STEPS } from './dossieCareer.mjs' + +export const BULLETIN_HIGHLIGHT_LIMIT = 6 +export const BULLETIN_MORE_LIMIT = 14 + +const sphereRank = { municipio: 0, regiao: 1, polo: 2 } +const eraRank = { C: 0, B: 1, A: 2 } + +const rankFacts = (facts) => + [...facts].sort((left, right) => { + const sphere = (sphereRank[left.sphere] ?? 9) - (sphereRank[right.sphere] ?? 9) + if (sphere !== 0) return sphere + const hasValue = (left.value ? 0 : 1) - (right.value ? 0 : 1) + if (hasValue !== 0) return hasValue + const era = (eraRank[left.era] ?? 9) - (eraRank[right.era] ?? 9) + if (era !== 0) return era + return String(left.id).localeCompare(String(right.id)) + }) + +/** + * @param {{ + * facts?: Array<{ id: string, era: string, sphere: string, area: string, headline: string, detail: string|null, value: string|null, year: string|null, phase: string|null, sourceUrl: string, sourceDate: string }>, + * municipality: string, + * region?: string | null, + * generatedAt?: Date, + * }} params + */ +export const buildBulletin = ({ + facts = [], + municipality, + region = null, + generatedAt = new Date(), +}) => { + const ordered = rankFacts(facts) + const highlights = ordered.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => ({ + eyebrow: `${fact.area ?? 'Atuação'} · ${dossierSphereLabel(fact.sphere)}`, + number: fact.value, + title: fact.headline, + note: fact.detail ?? null, + })) + const moreItems = ordered + .slice(BULLETIN_HIGHLIGHT_LIMIT, BULLETIN_HIGHLIGHT_LIMIT + BULLETIN_MORE_LIMIT) + .map((fact) => ({ + label: fact.headline, + detail: fact.detail ?? fact.value ?? fact.area, + })) + + return { + meta: { + title: 'Boletim informativo modelo', + kicker: 'Boletim informativo · atuação pública', + municipality, + region, + generatedAt, + generatedAtLabel: formatDateTimeBr(generatedAt), + modelLabel: 'Modelo — insumo interno', + }, + highlights, + moreItems, + timeline: BULLETIN_TIMELINE_STEPS, + } +} diff --git a/scripts/lib/dossieBulletinRender.mjs b/scripts/lib/dossieBulletinRender.mjs new file mode 100644 index 00000000..d24d93a7 --- /dev/null +++ b/scripts/lib/dossieBulletinRender.mjs @@ -0,0 +1,194 @@ +/** + * Boletim renderer (C186): one A4 `.sheet` in voter-facing language. Ports the + * approved hi-fi classes (`dossie-solla-cidade-boletim-ui-design.html`). No + * source list — the facts come from the dossiê ledger, and the page is labelled + * as an internal model. + */ + +import { htmlEscape } from './reportText.mjs' + +const ASSET = (title, lines) => + `

${htmlEscape(title)}

${lines + .map((line) => htmlEscape(line)) + .join('
')}

` + +const renderHighlight = (highlight) => ` +
+

${htmlEscape(highlight.eyebrow)}

+ ${highlight.number ? `

${htmlEscape(highlight.number)}

` : ''} +

${htmlEscape(highlight.title)}

+ ${highlight.note ? `

${htmlEscape(highlight.note)}

` : ''} +
` + +const renderMoreItem = (item) => + `

${htmlEscape(item.label)}: ${htmlEscape(item.detail)}

` + +const PRINT_CSS = ` + * { box-sizing: border-box; } + html, body { margin: 0; padding: 0; } + body { font-family: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; color: #172033; background: #fff; } + @page { size: A4; margin: 0; } + .sheet { + position: relative; + width: 210mm; + height: 297mm; + min-height: 297mm; + padding: 8mm 10mm 7mm 12mm; + display: flex; + flex-direction: column; + overflow: hidden; + background: #fff; + font-size: 10.25pt; + line-height: 1.26; + } + .sheet::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3.5mm; background: #315c75; } + .sheet p, .sheet h1, .sheet h2, .sheet h3, .sheet ul { margin-top: 0; } + .tabular { font-variant-numeric: tabular-nums; } + .model-label { display: inline-flex; align-items: center; min-height: 6mm; padding: 1mm 2.5mm; border: .35mm solid #8a5a18; border-radius: 99px; color: #68420f; background: #fff7e8; font-size: 7.3pt; line-height: 1; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } + .kicker, .section-label { color: #315c75; font-size: 7.4pt; line-height: 1.1; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } + .section-label { margin-bottom: 1.2mm; } + .asset-box { border: .4mm dashed #748894; color: #40535f; background: #f7f9fa; padding: 3mm; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } + .asset-box p { margin: 0; } + .asset-box .asset-title { font-size: 7.5pt; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; } + .asset-box p + p { margin-top: 1mm; font-size: 7pt; line-height: 1.25; } + .bulletin-head { display: flex; align-items: center; justify-content: space-between; gap: 6mm; border-bottom: .55mm solid #172033; padding-bottom: 2.5mm; } + .bulletin-head .kicker { margin-bottom: 1mm; } + .bulletin-head .place { margin: 0; font-size: 10pt; font-weight: 700; color: #435264; } + .bulletin-head .note { margin: 0; max-width: 44mm; text-align: right; font-size: 6.8pt; font-weight: 600; line-height: 1.2; color: #8a5a18; } + .bulletin-head .right { display: flex; align-items: center; gap: 3mm; } + .opening { margin-top: 3.5mm; display: grid; grid-template-columns: 1fr 46mm; gap: 5mm; } + .opening h1 { margin: 0; font-size: 23pt; font-weight: 600; line-height: 1; letter-spacing: -.038em; } + .opening h1 .accent { color: #315c75; } + .opening .lead { margin: 2.6mm 0 0; max-width: 125mm; font-size: 10.5pt; line-height: 1.33; color: #435264; } + .opening .asset-box { min-height: 36mm; border-radius: .125rem; } + .highlights { margin-top: 3.8mm; } + .highlights-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 4mm; border-bottom: .3mm solid #cbd5dc; padding-bottom: 1.5mm; } + .highlights-head h2 { margin: 0; font-size: 14pt; font-weight: 600; line-height: 1; letter-spacing: -.015em; } + .highlights-head .side { margin: 0; text-align: right; font-size: 7pt; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: #435264; } + .highlight-grid { margin-top: 2.5mm; display: grid; grid-template-columns: repeat(3, 1fr); gap: 2.2mm; } + .highlight-card { min-height: 31mm; padding: 2.8mm 3mm 2.5mm; border: .3mm solid #cbd5dc; border-top: 1.15mm solid #315c75; background: #fff; } + .highlight-eyebrow { margin-bottom: 1.5mm; color: #435264; font-size: 6.7pt; line-height: 1; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } + .highlight-number { margin: 0; color: #315c75; font-size: 18.5pt; line-height: .96; font-weight: 800; letter-spacing: -.035em; } + .highlight-title { margin: 1.5mm 0 0; font-size: 10.8pt; line-height: 1.08; font-weight: 800; letter-spacing: -.01em; } + .highlight-note { margin: 1.3mm 0 0; color: #435264; font-size: 8.15pt; line-height: 1.22; } + .bulletin-timeline { margin-top: 3.5mm; border-radius: .125rem; background: #e8f0f4; padding: 2.8mm 3mm 2.6mm; } + .bulletin-timeline .timeline-head { display: flex; align-items: center; justify-content: space-between; gap: 4mm; } + .bulletin-timeline h2 { margin: 0; font-size: 11pt; font-weight: 600; } + .bulletin-timeline .timeline-side { margin: 0; font-size: 6.7pt; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: #315c75; } + .timeline-grid { margin-top: 3mm; display: grid; grid-template-columns: repeat(4, 1fr); } + .timeline-step { position: relative; padding: 4.5mm 2.5mm 1.5mm; border-top: .4mm solid #97aab6; } + .timeline-step::before { content: ''; position: absolute; top: -1.6mm; left: 2.5mm; width: 3mm; height: 3mm; border: .65mm solid #e8f0f4; border-radius: 99px; background: #315c75; box-shadow: 0 0 0 .3mm #315c75; } + .timeline-period { margin: 0 0 .7mm; font-size: 7.2pt; font-weight: 800; color: #315c75; } + .timeline-text { margin: 0; font-size: 8.4pt; font-weight: 600; line-height: 1.18; } + .more { margin-top: 3.5mm; display: grid; grid-template-columns: 1fr 43mm; gap: 4mm; } + .more-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 4mm; border-bottom: .3mm solid #cbd5dc; padding-bottom: 1.4mm; } + .more-head h2 { margin: 0; font-size: 12.5pt; font-weight: 600; line-height: 1; } + .more-head .side { margin: 0; font-size: 6.7pt; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: #435264; } + .more-grid { margin-top: 2.3mm; display: grid; grid-template-columns: 1fr 1fr; gap: 1.7mm 4mm; } + .more-item { position: relative; margin: 0; min-height: 11mm; padding: 0 0 2mm 5mm; border-bottom: .25mm solid #cbd5dc; font-size: 9pt; line-height: 1.22; } + .more-item::before { content: '✓'; position: absolute; left: 0; top: 0; color: #315c75; font-weight: 900; } + .more-aside { display: grid; grid-template-rows: 1fr 1fr; gap: 2.3mm; } + .more-aside .asset-box { border-radius: .125rem; } + .empty { margin-top: 3mm; color: #435264; font-size: 9.5pt; } + .empty-box { margin-top: 2.3mm; min-height: 40mm; border: .4mm dashed #748894; background: #f7f9fa; color: #40535f; padding: 4mm; display: flex; flex-direction: column; justify-content: center; gap: 1.5mm; } + .empty-box p { margin: 0; font-size: 9.5pt; line-height: 1.3; } + .empty-box .uppercase { text-transform: uppercase; letter-spacing: .06em; } + .bulletin-foot { margin-top: auto; border-top: .35mm solid #cbd5dc; padding-top: 2.2mm; } + .bulletin-foot .row { display: flex; align-items: flex-end; justify-content: space-between; gap: 5mm; } + .bulletin-foot .orig { margin: 0 0 .8mm; font-size: 7.2pt; font-weight: 600; line-height: 1.25; color: #315c75; } + .bulletin-foot .defeso { margin: 0; max-width: 142mm; font-size: 7.4pt; line-height: 1.28; color: #435264; } + .bulletin-foot .defeso strong { color: #8a5a18; } + .bulletin-foot .stamp { margin: 0; text-align: right; font-size: 7.2pt; font-weight: 600; line-height: 1.3; color: #435264; } +` + +export const renderBulletinHtml = (bulletin) => ` + + +${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.municipality)} + +
+
+
+

${htmlEscape(bulletin.meta.kicker)}

+

Município de ${htmlEscape(bulletin.meta.municipality)}${bulletin.meta.region ? ` · ${htmlEscape(bulletin.meta.region)}` : ' · Bahia'}

+
+
+

Síntese do dossiê da cidade — conferir antes de publicar.

+ ${htmlEscape(bulletin.meta.modelLabel)} +
+
+ +
+
+ +

O que Jorge Solla fez por ${htmlEscape(bulletin.meta.municipality)}

+

Da gestão da saúde ao trabalho na Câmara: recursos, serviços, obras e equipamentos que melhoram a vida de quem mora aqui.

+
+ ${ASSET('NEEDS ASSET · foto real', ['Solla na cidade ou no serviço citado', 'origem, autoria e licença'])} +
+ +
+
+
+ +

Entregas que fazem diferença

+
+

cidade ≠ região · cada alcance vem identificado

+
+ ${ + bulletin.highlights.length + ? `
${bulletin.highlights.map(renderHighlight).join('')}
` + : '

Sem fato com fonte suficiente para destacar — ver lacunas no dossiê.

' + } +
+ +
+
+

Desde 1999, uma trajetória na saúde pública

+

experiência que virou trabalho pela Bahia

+
+
+ ${bulletin.timeline + .map( + (step) => `
+

${htmlEscape(step.period)}

+

${htmlEscape(step.text)}

+
`, + ) + .join('')} +
+
+ +
+
+
+
+ +

Outras ações que entram no boletim

+
+

frases curtas · fatos confirmados

+
+ ${ + bulletin.moreItems.length + ? `
${bulletin.moreItems.map(renderMoreItem).join('')}
` + : '

Sem itens adicionais com fonte além dos destaques.

Conferir as lacunas explícitas no dossiê da cidade antes de ampliar o boletim.

' + } +
+ +
+ +
+
+
+

Fatos selecionados do dossiê da cidade.

+

Defeso eleitoral: material informativo em preparação. Não publicar sem revisão editorial e jurídica.

+
+

MODELO · INSUMO INTERNO
A4 · página 1/1
${htmlEscape(bulletin.meta.generatedAtLabel)}

+
+
+
+ +` diff --git a/scripts/lib/dossieCamara.mjs b/scripts/lib/dossieCamara.mjs new file mode 100644 index 00000000..1516afa6 --- /dev/null +++ b/scripts/lib/dossieCamara.mjs @@ -0,0 +1,146 @@ +/** + * Câmara activity for the dossiê's Era C (owner): proposições and discursos of + * the deputy, always with an explicit date window (the API defaults to 7 days) + * and a per-item source URL. Network failures degrade to an explicit gap — the + * dossiê never prints a silent zero. + */ + +import { getJsonWithBackoff, speechesUrl } from './camaraFetch.mjs' +import { SOLLA_DEPUTY_ID } from './dossieCareer.mjs' + +const CAMARA_API = 'https://dadosabertos.camara.leg.br/api/v2' +export const CAMARA_DEFAULT_FROM = '2015-01-01' +const DEFAULT_MAX_PAGES = 2 +const ITEMS_PER_LIST = 20 + +export const propositionsUrl = (deputyId, page) => + `${CAMARA_API}/proposicoes?idDeputadoAutor=${deputyId}&itens=100&ordem=DESC&ordenarPor=id&pagina=${page}` + +const propositionSourceUrl = (id) => + id + ? `https://www.camara.leg.br/proposicoesWeb/fichadetramitacao?idProposicao=${id}` + : `https://www.camara.leg.br/deputados/${SOLLA_DEPUTY_ID}` + +const isNonEmpty = (value) => typeof value === 'string' && value.trim() !== '' + +export const normalizeProposition = (row) => { + const sigla = isNonEmpty(row?.siglaTipo) ? row.siglaTipo.trim() : null + const number = row?.numero ?? null + const year = row?.ano ?? null + const ementa = isNonEmpty(row?.ementa) ? row.ementa.trim() : null + if (!sigla && !ementa) return null + const identifier = [sigla, number && year ? `${number}/${year}` : number ? String(number) : null] + .filter(Boolean) + .join(' ') + return { + title: identifier || 'Proposição de autoria do deputado', + detail: ementa, + year: year ? String(year) : null, + sourceUrl: propositionSourceUrl(row?.id), + } +} + +export const normalizeSpeech = (row, deputyId) => { + const sumario = isNonEmpty(row?.sumario) ? row.sumario.trim() : null + const transcricao = isNonEmpty(row?.transcricao) ? row.transcricao.trim() : null + const detail = (sumario ?? transcricao ?? '').slice(0, 280) || null + if (!detail && !isNonEmpty(row?.dataHoraInicio)) return null + const date = isNonEmpty(row?.dataHoraInicio) ? row.dataHoraInicio.slice(0, 10) : null + const tipo = isNonEmpty(row?.tipoDiscurso) ? row.tipoDiscurso.trim() : 'Pronunciamento' + return { + title: `${tipo} em ${date ?? 'data não informada'}`, + detail, + year: date ? date.slice(0, 4) : null, + sourceUrl: `${CAMARA_API}/deputados/${deputyId}/discursos`, + } +} + +const fetchPaged = async ({ url, page, fetchImpl, label }) => { + const payload = await getJsonWithBackoff(`${url(page)}`, { fetchImpl, label, attempts: 2 }) + return Array.isArray(payload?.dados) ? payload.dados : [] +} + +/** + * The Câmara `/discursos` endpoint rejects a window wider than 4 years + * ("A data de início não pode ser maior que 4 anos em relação à data final"), + * so the mandate is walked in ≤4-year windows. Pure and unit-tested. + */ +export const speechWindows = (from, to, spanYears = 4) => { + const startYear = Number(from.slice(0, 4)) + const endYear = Number(to.slice(0, 4)) + const windows = [] + for (let year = startYear; year <= endYear; year += spanYears) { + const windowStart = `${String(year).padStart(4, '0')}-01-01` + const windowEndYear = Math.min(year + spanYears - 1, endYear) + const lastDay = new Date(Date.UTC(windowEndYear, 12, 0)).toISOString().slice(0, 10) + const windowEnd = windowEndYear === endYear ? to : lastDay + windows.push({ from: windowStart, to: windowEnd }) + } + return windows +} + +/** + * @param {{ + * deputyId?: number, + * from?: string, + * to?: string, + * fetchImpl?: typeof fetch, + * maxPages?: number, + * label?: string, + * }} [options] + * @returns {Promise<{ status: 'ok'|'gap', items: Array<{title: string, detail: string|null, year: string|null, sourceUrl: string}>, reason: string|null, sourceUrl: string }>} + */ +export const fetchCamaraActivity = async ({ + deputyId = SOLLA_DEPUTY_ID, + from = CAMARA_DEFAULT_FROM, + to = new Date().toISOString().slice(0, 10), + fetchImpl = fetch, + maxPages = DEFAULT_MAX_PAGES, + label = 'dossie-camara', +} = {}) => { + const sourceUrl = `${CAMARA_API}/deputados/${deputyId}/discursos` + try { + const propositions = [] + const speeches = [] + for (let page = 1; page <= maxPages; page += 1) { + const rows = await fetchPaged({ + url: (p) => propositionsUrl(deputyId, p), + page, + fetchImpl, + label, + }) + propositions.push(...rows.map(normalizeProposition).filter(Boolean)) + if (rows.length < 100) break + } + for (const window of speechWindows(from, to)) { + if (speeches.length >= ITEMS_PER_LIST) break + const payload = await getJsonWithBackoff( + `${speechesUrl(deputyId, window.from, window.to)}&pagina=1`, + { + fetchImpl, + label, + attempts: 2, + }, + ) + const rows = Array.isArray(payload?.dados) ? payload.dados : [] + speeches.push(...rows.map((row) => normalizeSpeech(row, deputyId)).filter(Boolean)) + } + const items = [...propositions.slice(0, ITEMS_PER_LIST), ...speeches.slice(0, ITEMS_PER_LIST)] + if (items.length === 0) { + return { + status: 'gap', + items: [], + reason: `Sem proposições ou discursos localizados na janela ${from}–${to}.`, + sourceUrl, + } + } + return { status: 'ok', items, reason: null, sourceUrl } + } catch (error) { + return { + status: 'gap', + items: [], + reason: `Falha ao consultar a API da Câmara: ${error instanceof Error ? error.message : error}`, + sourceUrl, + } + } +} diff --git a/scripts/lib/dossieCareer.mjs b/scripts/lib/dossieCareer.mjs new file mode 100644 index 00000000..0abb6851 --- /dev/null +++ b/scripts/lib/dossieCareer.mjs @@ -0,0 +1,151 @@ +/** + * Career literals for the C186 dossiê/boletim (single owner). The timeline is + * loaded verbatim from the intention plan; uncertain periods stay marked, never + * invented. Both the dossiê and the boletim read from here so the two outputs + * cannot drift. + */ + +export const SOLLA_DEPUTY_ID = 178857 +export const SOLLA_BIRTH_DATE = '1961-04-11' +export const SOLLA_BIRTH_PLACE = 'Salvador-BA' +export const SOLLA_OFFICIAL_SITE = 'https://www.camara.leg.br/deputados/178857' + +/** Career eras fixed by the product plan (A/B/C). */ +export const DOSSIER_ERAS = [ + { + id: 'A', + label: 'Era A · Carreira técnica e gestão até 2006', + period: '1985–2006', + subtitle: 'Epidemiologia, gestão municipal e Ministério da Saúde', + }, + { + id: 'B', + label: 'Era B · Secretaria Estadual de Saúde da Bahia', + period: '2007–2014', + subtitle: 'SESAB — governo Jaques Wagner', + }, + { + id: 'C', + label: 'Era C · Deputado Federal', + period: '2015–2027', + subtitle: '55ª, 56ª e 57ª legislaturas', + }, +] + +export const DOSSIER_ERA_IDS = DOSSIER_ERAS.map((era) => era.id) + +/** + * Career timeline, verbatim from the intention plan. `uncertain` carries the + * source disagreement instead of picking a side; `era` binds the row to a + * dossiê section. + */ +export const CAREER_TIMELINE = [ + { + period: '1985–1987', + role: 'Residência em Medicina Social (UFBA/INAMPS)', + recovery: 'Biografia Câmara', + era: 'A', + }, + { + period: '1989/90–1998', + role: 'SESAB — epidemiologista (Itapagipe) e sanitarista (PACS/PSF/DEPAS)', + recovery: 'Pronunciamento 03/01/2007 (saude.ba.gov.br) + biografia Câmara', + era: 'A', + uncertain: 'Período inicial incerto: 1989 vs 1990', + }, + { + period: '1995–1999', + role: 'Consultor do Ministério da Saúde (Brasília)', + recovery: 'Biografia Câmara', + era: 'A', + }, + { + period: '1998', + role: 'Professor (Escola Bahiana de Medicina) e pesquisador ISC/UFBA', + recovery: 'Biografia Câmara / PT-BA', + era: 'A', + }, + { + period: '1999–2002', + role: 'Secretário Municipal de Saúde de Vitória da Conquista', + recovery: 'Biografia Câmara (oficial), PT-BA', + era: 'A', + }, + { + period: '2003–2005', + role: 'Secretário de Atenção à Saúde do Ministério da Saúde', + recovery: 'Biografia Câmara + discurso de 2007', + era: 'A', + uncertain: 'Término incerto: 2005 vs 2006 — fonte oficial Câmara indica 2005', + }, + { + period: '2006–2009', + role: 'Doutorado em Clínica Médica (UFRJ)', + recovery: 'Biografia Câmara', + era: 'A', + }, + { + period: '01/01/2007–18/01/2014', + role: 'Secretário Estadual de Saúde da Bahia (SESAB)', + recovery: 'Wikipedia com refs + notícias SESAB', + era: 'B', + }, + { + period: '2015–2019', + role: 'Deputado Federal, 55ª legislatura (125.159 votos)', + recovery: 'API Câmara /deputados/178857', + era: 'C', + }, + { + period: '2019–2023', + role: 'Deputado Federal, 56ª legislatura (135.657 votos)', + recovery: 'API Câmara /deputados/178857', + era: 'C', + }, + { + period: '2023–2027', + role: 'Deputado Federal, 57ª legislatura (128.968 votos)', + recovery: 'API Câmara /deputados/178857', + era: 'C', + }, +] + +/** Condensed 4-step trajectory for the one-page boletim. */ +export const BULLETIN_TIMELINE_STEPS = [ + { period: '1999–2002', text: 'Saúde municipal em Vitória da Conquista' }, + { period: '2003–2005', text: 'Atenção à saúde no Ministério da Saúde' }, + { period: '2007–2014', text: 'Secretaria da Saúde da Bahia' }, + { period: 'DESDE 2015', text: 'Atuação na Câmara Federal' }, +] + +/** + * The six cells the hi-fi page-1 "trajetória" grid shows; the complete timeline + * (with every period and recovery trail) lives on the dedicated página de + * trajetória — the page-1 grid must stay a one-glance read. + */ +const CAREER_HIGHLIGHT_PERIODS = [ + '1999–2002', + '2003–2005', + '01/01/2007–18/01/2014', + '2015–2019', + '2019–2023', + '2023–2027', +] + +export const careerTimelineHighlights = () => + CAREER_TIMELINE.filter((row) => CAREER_HIGHLIGHT_PERIODS.includes(row.period)) + +/** + * Non-negotiable career facts the renderers may print without a per-item source + * (they are the identity of the document). Anything beyond this needs a source. + */ +export const CAREER_NOTES = [ + 'Nascido em 11/04/1961 (Salvador-BA) — não 1962.', + 'Nunca foi deputado estadual.', + 'Comenda Dois de Julho (ALBA, 2013).', + 'Cidadão Ilheense e Santamarense.', +] + +/** Cover-page scope line (design 01 Capa). */ +export const DOSSIER_SCOPE = + 'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara dos Deputados 2015–2027. Afirmações sem fonte não entram como entrega.' diff --git a/scripts/lib/dossieHealthData.mjs b/scripts/lib/dossieHealthData.mjs new file mode 100644 index 00000000..512c255b --- /dev/null +++ b/scripts/lib/dossieHealthData.mjs @@ -0,0 +1,94 @@ +/** + * IBGE baseline for the C186 dossiê (owner of the programmatic municipal + * context). Fetches the sourced population baseline used as the municipal + * denominator ("leitura relativa" — never an absolute state share). SIOPS/CNES + * health-execution series are not JSON APIs; they stay a per-era research task + * and degrade to an explicit gap when absent. + * + * `fetchImpl` is injectable so the unit tests never touch the network. + */ + +import { formatInteger } from './cityReportFormat.mjs' + +const SIDRA_BASE = 'https://apisidra.ibge.gov.br/values' +const CENSUS_YEAR = 2022 + +export const sidraPopulationUrl = (ibgeCode, year = CENSUS_YEAR) => + `${SIDRA_BASE}/t/9514/n6/${ibgeCode}/v/93/p/${year}` + +/** SIDRA `/values` returns a header row first; the datum is `payload[1].V`. */ +export const parseSidraValue = (payload) => { + if (!Array.isArray(payload) || payload.length < 2) return null + const raw = String(payload[1]?.V ?? '').replace(/[^\d-]/g, '') + if (raw === '') return null + const value = Number(raw) + return Number.isFinite(value) ? value : null +} + +const gap = ({ reason, sourceUrl, consultedAt }) => ({ + status: 'gap', + reason, + items: [], + sourceUrl, + consultedAt, +}) + +/** + * @param {{ + * ibgeCode?: string | null, + * fetchImpl?: typeof fetch, + * now?: Date, + * year?: number, + * }} [options] + * @returns {Promise<{ status: 'ok'|'gap', items: Array<{topic: string, detail: string, value: number, year: string, sourceUrl: string, sourceDate: string}>, reason: string|null, sourceUrl: string|null, consultedAt: string }>} + */ +export const fetchHealthData = async ({ + ibgeCode = null, + fetchImpl = fetch, + now = new Date(), + year = CENSUS_YEAR, +} = {}) => { + const consultedAt = now.toISOString() + if (!ibgeCode) { + return gap({ + reason: 'Município sem código IBGE no snapshot — contexto não consultado.', + sourceUrl: null, + consultedAt, + }) + } + const sourceUrl = sidraPopulationUrl(ibgeCode, year) + try { + const response = await fetchImpl(sourceUrl) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const population = parseSidraValue(await response.json()) + if (population === null) { + return gap({ + reason: 'SIDRA não devolveu valor de população para o município.', + sourceUrl, + consultedAt, + }) + } + return { + status: 'ok', + reason: null, + sourceUrl, + consultedAt, + items: [ + { + topic: `População residente (Censo ${year})`, + detail: `${formatInteger(population)} habitantes`, + value: population, + year: String(year), + sourceUrl, + sourceDate: consultedAt.slice(0, 10), + }, + ], + } + } catch (error) { + return gap({ + reason: `Falha ao consultar o IBGE/SIDRA: ${error instanceof Error ? error.message : error}`, + sourceUrl, + consultedAt, + }) + } +} diff --git a/scripts/lib/dossieRender.mjs b/scripts/lib/dossieRender.mjs new file mode 100644 index 00000000..1a93b2b2 --- /dev/null +++ b/scripts/lib/dossieRender.mjs @@ -0,0 +1,708 @@ +/** + * Dossiê renderer (C186): the block model goes to print HTML (Chromium + * `page.pdf`, A4, one `.sheet` per page) and to the companion `.md`. Ports the + * approved hi-fi classes (`dossie-solla-cidade-ui-design.html`) class-by-class; + * shared escaping/source-token contract lives in `reportText.mjs`. + */ + +import { formatDateBr, formatDateTimeBr } from './cityReportFormat.mjs' +import { dossierPhaseLabel, dossierSphereLabel } from './dossieBlocks.mjs' +import { htmlEscape, stripInlineSources } from './reportText.mjs' + +const SOURCE_ICON = + '' + +const WARNING_ICON = + '' + +const sourceLink = (url, label = '(fonte)') => + url + ? `${SOURCE_ICON}${htmlEscape(label)}` + : '' + +const scopeBadge = (sphere, label = null) => + `${htmlEscape(label ?? dossierSphereLabel(sphere))}` + +/** `dd/mm/aa` — the news table column is 9% wide (no room for a 4-digit year). */ +const formatShortDateBr = (value) => { + const full = formatDateBr(value) + const parts = full.split('/') + return parts.length === 3 ? `${parts[0]}/${parts[1]}/${parts[2].slice(-2)}` : full +} + +/** Research text without the inline `{{fonte}}` markers — the row's own link carries the source. */ +const cleanText = (value) => htmlEscape(stripInlineSources(value)) + +const phaseBadge = (phase) => `${htmlEscape(dossierPhaseLabel(phase))}` + +const placeholder = (label) => + `` + +const valueCell = (value, widthLabel = 'Valor desconhecido') => + value === null || value === undefined || value === '' + ? placeholder(widthLabel) + : `${htmlEscape(value)}` + +const assetBox = (title, lines) => + `

${htmlEscape(title)}

${lines + .map((line) => htmlEscape(line)) + .join('
')}

` + +const pageCount = (report) => report.meta.pageTotal ?? 5 + report.eras.length + +/** Gaps are paginated, never truncated: 12 rows per A4 sheet. */ +const GAPS_PER_PAGE = 12 + +const chunkGaps = (gaps) => { + const list = Array.isArray(gaps) ? gaps : [] + if (list.length === 0) return [[]] + const chunks = [] + for (let index = 0; index < list.length; index += GAPS_PER_PAGE) { + chunks.push(list.slice(index, index + GAPS_PER_PAGE)) + } + return chunks +} + +const timelineGrid = (rows) => ` +
+ ${rows + .map( + (row) => `
+

${htmlEscape(row.period)}

+

${htmlEscape(row.role)}

+

${htmlEscape(row.recovery)}${row.uncertain ? ` · ${htmlEscape(row.uncertain)}` : ''}

+
`, + ) + .join('')} +
` + +const sheetHeader = (report, title, subtitle, pageNo) => ` +
+
+

Dossiê de atuação pública · ${htmlEscape(report.meta.municipalityName)}

+

${htmlEscape(title)}

+ ${subtitle ? `

${htmlEscape(subtitle)}

` : ''} +
+
+

Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}

+

INSUMO INTERNO

+

pág. ${pageNo}/${pageCount(report)}

+
+
` + +const sheetFooter = (report, left, pageNo) => ` +
+ Dossiê Solla por cidade · ${htmlEscape(report.meta.municipalityName)} · ${htmlEscape(left)} + Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))} · pág. ${pageNo}/${pageCount(report)} +
` + +const renderCover = (report) => ` +
+
+
+
+

${htmlEscape(report.cover.kicker)}

+

${htmlEscape(report.cover.series)}

+
+
${htmlEscape(report.cover.internalNote)}
${htmlEscape(report.cover.internalNoteSub)}
+
+ +
+

${htmlEscape(report.cover.eyebrow)}

+

${htmlEscape(report.cover.title)}

+

${htmlEscape(report.cover.subtitle)}

+
+ +
+
Território de Identidade
${htmlEscape(report.cover.territory)}
+
Região / polo adotado
${htmlEscape(report.cover.pole)}
+
Data de geração
${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}
+
+ +
+

Como usar

${htmlEscape(report.cover.howToUse)}

+ ${assetBox('NEEDS ASSET', ['selo institucional autorizado', 'opcional — nunca marca de campanha'])} +
+ +
+
+
+

${htmlEscape(report.cover.scope)}

+

${htmlEscape(report.cover.version)}

+
+
+
` + +const renderTimeline = (report) => ` +
+
+

01 · trajetória

Linha do tempo da carreira

+

Período + papel + trilha de recuperação

+
+ ${timelineGrid(report.page1.timeline)} +
` + +const renderTrajectory = (report, pageNo) => ` +
+ ${sheetHeader(report, 'Trajetória completa', 'Todos os períodos da carreira e como cada um foi recuperado', pageNo)} + ${timelineGrid(report.trajectory)} +

Períodos incertos ficam marcados na própria linha; o acervo interno de falas cobre 2011+ e o que falta vira lacuna explícita.

+ ${sheetFooter(report, 'Trajetória', pageNo)} +
` + +const renderDeliveries = (report) => { + if (report.page1.deliveries.length === 0) return '' + return ` +
+
+

02 · destaques com lastro

Principais entregas localizadas

+
+
+ ${report.page1.deliveries + .map( + (row) => `
+
${scopeBadge(row.sphere)}

Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}

+
+

${cleanText(row.title)}

+ ${row.detail ? `

${cleanText(row.detail)}

` : ''} +
+
+

${valueCell(row.value, 'Valor não informado')}

+ ${row.phase ? phaseBadge(row.phase) : ''} + ${sourceLink(row.sourceUrl)} +
+
`, + ) + .join('')} +
+

Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor.

+
` +} + +const renderHooksAndPending = (report) => { + const hooks = report.page1.hooks + const pending = report.page1.pending + if (hooks.length === 0 && pending.length === 0) return '' + return ` +
+ ${ + hooks.length + ? `
+

03 · ganchos para o boletim

+
    ${hooks + .map( + (hook) => + `
  • Tema: ${htmlEscape(hook.topic)}. Ângulo: ${cleanText(hook.angle)} ${sourceLink(hook.sourceUrl)}
  • `, + ) + .join('')}
+
` + : '
' + } + ${ + pending.length + ? `
+

04 · o que falta

+
    ${pending.map((item) => `
  • ${htmlEscape(item)}
  • `).join('')}
+

Ausência é resultado: não completar por inferência.

+
` + : '
' + } +
` +} + +const renderSummary = (report, pageNo) => ` +
+ ${sheetHeader(report, 'Resumo de uma olhada', `${report.meta.region} · informação datada e pronta para conferência`, pageNo)} + ${renderTimeline(report)} + ${renderDeliveries(report)} + ${renderHooksAndPending(report)} + ${sheetFooter(report, 'Resumo', pageNo)} +
` + +const renderEra = (report, era, pageNo) => ` +
+ ${sheetHeader(report, era.label, `${era.period} · ${era.subtitle}`, pageNo)} + +
+
+

Recorte e método

+

${htmlEscape(era.method)}

+
+ +
+ + ${ + era.numbers.length + ? `
+
+

Números com fase

Recursos e entregas quantificáveis

+
+ + + + + ${era.numbers + .map( + (row) => ` + + + + + + + `, + ) + .join('')} +
Valores por ano, fase, esfera e fonte
ObjetoValorAnoFaseEsferaFonte
${htmlEscape(row.object)}${valueCell(row.value, 'Valor a recuperar')}${htmlEscape(row.year ?? '—')}${phaseBadge(row.phase)}${scopeBadge(row.sphere)}${sourceLink(row.sourceUrl)}${row.sphere !== 'municipio' ? '
não somar à cidade' : ''}
+

Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.

+
` + : '' + } + + ${ + era.actions.length + ? `
+

Atuação registrada

+

O que fez — item, alcance e lastro

+
+ ${era.actions + .map( + (action) => `
+
${scopeBadge(action.sphere)}${htmlEscape(action.year ?? '—')}
+

${cleanText(action.title)}

+ ${action.detail ? `

${cleanText(action.detail)}

` : ''} + ${sourceLink(action.sourceUrl)} +
`, + ) + .join('')} +
+
` + : '' + } + + ${ + era.honors.length + ? `
+
+

Títulos, honrarias e vínculos locais

+
    ${era.honors + .map((honor) => `
  • ${cleanText(honor.text)} ${sourceLink(honor.sourceUrl)}
  • `) + .join('')}
+
+ ${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])} +
` + : '' + } + + ${sheetFooter(report, era.label, pageNo)} +
` + +const renderScopeLists = (report) => ` +
+

Leitura lado a lado

+

Duas listas, dois totais, nenhuma soma

+
+
+
${scopeBadge('municipio')}${report.region.municipal.total} ${report.region.municipal.total === 1 ? 'item' : 'itens'} com fonte
+
    ${report.region.municipal.items + .map((item) => `
  • ${cleanText(item.answer)} ${sourceLink(item.sourceUrl)}
  • `) + .join( + '', + )}${report.region.municipal.items.length === 0 ? '
  • Nenhum item municipal com fonte.
  • ' : ''}
+
+ +
+
${scopeBadge('regiao', 'região / polo')}SEM TOTAL COMBINADO · ${report.region.regional.total} ${report.region.regional.total === 1 ? 'item' : 'itens'}
+
    ${report.region.regional.items + .map((item) => `
  • ${cleanText(item.answer)} ${sourceLink(item.sourceUrl)}
  • `) + .join( + '', + )}${report.region.regional.items.length === 0 ? '
  • Nenhum item regional com fonte.
  • ' : ''}
+
+
+
` + +const renderRegion = (report, pageNo) => ` +
+ ${sheetHeader(report, 'Região / polo', 'Impacto compartilhado, apresentado fora da conta municipal', pageNo)} + +
+ ${WARNING_ICON} +
+

${htmlEscape(report.region.ruleTitle)}

+

${htmlEscape(report.region.ruleBody)}

+
+
+ + ${renderScopeLists(report)} + + ${ + report.region.items.length + ? `
+

Itens regionais recuperados

Evidência de alcance, sem apropriação local

${scopeBadge('regiao')}
+ + + + + ${report.region.items + .map( + (item) => ` + + + + + `, + ) + .join('')} +
Itens do recorte regional com evidência e fonte
ItemEsferaEvidência de alcanceFonte
${cleanText(item.item)}${scopeBadge(item.sphere)}${cleanText(item.evidence)}${sourceLink(item.sourceUrl)}
+
` + : '' + } + + ${ + report.region.hook || report.region.priorityGap + ? `
+ ${ + report.region.hook + ? `

Gancho possível

Tema: ${htmlEscape(report.region.hook.topic)}. Ângulo: ${cleanText(report.region.hook.angle)} ${sourceLink(report.region.hook.sourceUrl)}

` + : '
' + } + ${ + report.region.priorityGap + ? `

Lacuna prioritária

${htmlEscape(report.region.priorityGap)}

` + : '
' + } +
` + : '' + } + + ${sheetFooter(report, 'Região/polo · recortes não somáveis', pageNo)} +
` + +const renderGapsPage = (report, chunk, index, pageNo) => { + const rows = chunk.length + ? chunk + .map( + (gap) => ` + ${htmlEscape(gap.label)} + ${htmlEscape(gap.reason)} + Não completar por inferência. + ${htmlEscape(gap.nextStep)} + `, + ) + .join('') + : 'Nenhuma lacuna declarada — confira a cobertura das eras.' + return ` +
+ ${sheetHeader(report, 'Lacunas explícitas', `O que não foi encontrado${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)} +
+

O que não foi encontrado

Lacunas explícitas

Não completar por memória ou inferência

+ + + + + ${rows} +
Tabela de lacunas explícitas e próximos passos de apuração
LacunaSituação da buscaLimite editorialPróximo passo
+
+ ${sheetFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)} +
` +} + +const renderSourcesPage = (report, pageNo) => ` +
+ ${sheetHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)} + + ${ + report.news.length + ? `
+

Lista clicável

+

Notícias e documentos consultados

+ + + + + ${report.news + .map( + (row) => ` + + + + + `, + ) + .join('')} +
Fontes consultadas com largura fixa: data 12%, veículo 13%, título 47% e link 28%
DataVeículoTítuloLink
${htmlEscape(formatShortDateBr(row.date))}${htmlEscape(row.outlet)}${htmlEscape(row.title)}${sourceLink(row.url, '(fonte)')}
+
` + : '' + } + +
+

Limites de cobertura

    ${report.limits.coverage + .map((item) => `
  • ${htmlEscape(item)}
  • `) + .join('')}
+

Regras para uso editorial

    ${report.limits.editorial + .map((item) => `
  • ${htmlEscape(item)}
  • `) + .join('')}
+
+ + + + ${sheetFooter(report, 'Fontes e limites · INSUMO INTERNO', pageNo)} +
` + +const PRINT_CSS = ` + * { box-sizing: border-box; } + html, body { margin: 0; padding: 0; } + body { font-family: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; color: #172033; font-size: 10.5pt; line-height: 1.32; background: #fff; } + @page { size: A4; margin: 0; } + .sheet { + position: relative; + width: 210mm; + min-height: 297mm; + height: 297mm; + padding: 12mm 14mm 10mm; + background: #fff; + display: flex; + flex-direction: column; + overflow: hidden; + break-after: page; + } + .sheet:last-child { break-after: auto; } + .sheet p, .sheet ul, .sheet ol, .sheet dl { margin-top: 0; } + .sheet a { color: #245b78; text-decoration: underline; text-underline-offset: 1.5px; } + .accent-bar { position: absolute; left: 0; top: 0; width: 4mm; height: 297mm; background: #315c75; } + .report-header { display: flex; justify-content: space-between; gap: 8mm; align-items: flex-start; padding-bottom: 3mm; border-bottom: .45mm solid #172033; } + .report-kicker { margin: 0; color: #315c75; font-size: 8pt; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; } + .sheet-title { margin: 1.2mm 0 0; font-size: 18pt; font-weight: 700; line-height: 1.1; letter-spacing: -.02em; } + .sheet-sub { margin: .8mm 0 0; font-size: 10.5pt; color: #435264; } + .report-meta { min-width: 43mm; margin: 0; color: #435264; font-size: 8pt; line-height: 1.45; text-align: right; font-variant-numeric: tabular-nums; } + .report-meta p { margin: 0; } + .report-footer { margin-top: auto; padding-top: 2.5mm; border-top: .25mm solid #cbd5dc; display: flex; justify-content: space-between; gap: 5mm; color: #435264; font-size: 8pt; line-height: 1.3; } + .section-title { margin: 0; font-size: 13.5pt; line-height: 1.18; letter-spacing: -.01em; } + .eyebrow { margin: 0 0 1.4mm; color: #315c75; font-size: 8pt; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } + .eyebrow.warning-text, .warning-text { color: #8a5a18; } + .muted { color: #435264; } + .small { font-size: 10.5pt; line-height: 1.28; } + .meta { font-size: 7.7pt; line-height: 1.2; color: #435264; } + .strong { font-weight: 600; } + .tabular { font-variant-numeric: tabular-nums; } + .nowrap { white-space: nowrap; } + .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } + .block { margin-top: 3.4mm; break-inside: avoid; } + .block-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 4mm; } + .source-link { display: inline-flex; align-items: baseline; gap: 1mm; white-space: nowrap; font-size: 9pt; font-weight: 600; } + .source-link svg { width: 3mm; height: 3mm; transform: translateY(.35mm); } + .scope { display: inline-flex; align-items: center; min-height: 5mm; padding: .4mm 1.6mm; border: .25mm solid #9aacb7; border-radius: 99px; color: #304758; background: #f7fafb; font-size: 7.5pt; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; } + .scope-region { border-color: #aa8752; color: #6b4918; background: #fff9ee; } + .phase { display: inline-block; padding: .5mm 1.4mm; border-radius: 1mm; background: #e9eef1; color: #334553; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: .035em; } + .document-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 10.5pt; line-height: 1.22; margin-top: 2.5mm; } + .document-table th { padding: 1.7mm 1.6mm; border-top: .25mm solid #cbd5dc; border-bottom: .35mm solid #9caab3; background: #f3f6f8; color: #435264; font-size: 7.5pt; font-weight: 700; letter-spacing: .045em; text-align: left; text-transform: uppercase; } + .document-table td { padding: 2mm 1.6mm; border-bottom: .25mm solid #dce3e7; vertical-align: top; overflow-wrap: anywhere; } + .document-table tbody tr:nth-child(even) { background: #fafcfd; } + .document-table .numeric { font-variant-numeric: tabular-nums; white-space: nowrap; } + .placeholder-bar { display: inline-block; width: 22mm; height: 2.6mm; border-radius: 1mm; background: #d8e0e5; vertical-align: middle; } + .asset-box { border: .35mm dashed #80919d; color: #4c5c68; background: #f7f9fa; padding: 3mm; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; min-height: 30mm; border-radius: .25rem; font-size: 8pt; font-weight: 600; } + .asset-box p { margin: 0; } + .asset-box .asset-title { text-transform: uppercase; letter-spacing: .08em; } + .timeline-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.8mm; margin-top: 2.5mm; } + .timeline-cell { border-top: 1.6px solid #315c75; background: #f3f6f8; padding: 2mm; } + .timeline-period { margin: 0; font-size: 7.6pt; font-weight: 700; color: #315c75; } + .timeline-role { margin: .8mm 0 0; font-size: 10.5pt; font-weight: 600; line-height: 1.16; } + .timeline-recovery { margin: .7mm 0 0; font-size: 7.5pt; line-height: 1.16; color: #435264; } + .deliveries { border-top: .25mm solid #cbd5dc; border-bottom: .25mm solid #cbd5dc; margin-top: 1.8mm; } + .delivery-row { display: grid; grid-template-columns: 26mm 1fr 42mm; gap: 3mm; padding: 2mm 0; border-bottom: .25mm solid #cbd5dc; } + .delivery-row:last-child { border-bottom: none; } + .delivery-title { margin: 0; font-weight: 600; } + .delivery-value { text-align: right; } + .value-big { margin: 0; font-size: 14pt; font-weight: 600; } + .page1-grid { display: grid; grid-template-columns: 1.15fr .85fr; gap: 3mm; margin-top: 3.2mm; break-inside: avoid; } + .panel { border: .25mm solid #cbd5dc; border-radius: .25rem; padding: 3mm; break-inside: avoid; } + .panel-warning { border: none; border-left: 3mm solid #8a5a18; background: #fff7e8; } + .panel-region { border: .35mm solid #c9a66d; background: #fffbf3; } + .tight-list { margin: 0; padding-left: 4mm; } + .tight-list li { margin-bottom: 1mm; } + .aside-accent { border-radius: .25rem; background: #e8f0f4; padding: 3mm; font-size: 10.5pt; line-height: 1.35; } + .era-method { display: grid; grid-template-columns: 1fr 55mm; gap: 4mm; margin-top: 4mm; } + .action-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin-top: 3mm; } + .action-head { display: flex; align-items: center; justify-content: space-between; gap: 3mm; } + .action-title { margin: 2mm 0 0; font-size: 10.5pt; font-weight: 600; } + .honors { display: grid; grid-template-columns: 1fr 58mm; gap: 4mm; } + .rule-warning { display: flex; align-items: flex-start; gap: 3mm; border: .45mm solid #8a5a18; background: #fff7e8; border-radius: .25rem; padding: 4mm; margin-top: 4mm; } + .rule-warning .warn-icon { width: 5mm; height: 5mm; color: #8a5a18; flex-shrink: 0; margin-top: .5mm; } + .rule-title { margin: 0; font-size: 12pt; font-weight: 700; color: #8a5a18; } + .rule-warning .muted { margin-bottom: 0; } + .scope-compare { display: grid; grid-template-columns: 1fr 12mm 1fr; align-items: stretch; gap: 3mm; margin-top: 3mm; } + .compare-mid { display: flex; flex-direction: column; align-items: center; justify-content: center; color: #8a5a18; } + .compare-mid::before, .compare-mid::after { content: ''; width: 1px; flex: 1; background: #d8c09a; } + .compare-mid span { font-size: 18pt; font-weight: 700; margin: 2mm 0; } + .cover-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8mm; border-bottom: .25mm solid #cbd5dc; padding-bottom: 4mm; } + .cover-series { margin: 2mm 0 0; font-size: 10.5pt; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: #435264; } + .internal-note { border: .25mm solid #cbd5dc; background: #f3f6f8; padding: 2mm 3mm; text-align: right; font-size: 8pt; line-height: 1.5; color: #435264; border-radius: .25rem; } + .cover-block { margin-top: 28mm; max-width: 150mm; } + .cover-title { margin: 0; font-size: 34pt; font-weight: 700; line-height: 1.03; letter-spacing: -.035em; } + .cover-sub { margin: 5mm 0 0; max-width: 125mm; font-size: 15pt; line-height: 1.4; color: #435264; } + .cover-dl { display: grid; grid-template-columns: 42mm 1fr; border-top: .25mm solid #cbd5dc; border-bottom: .25mm solid #cbd5dc; margin: 22mm 0 0; font-size: 11pt; } + .cover-dl dt { border-bottom: .25mm solid #cbd5dc; padding: 3mm 0; font-weight: 600; color: #435264; } + .cover-dl dd { margin: 0; border-bottom: .25mm solid #cbd5dc; padding: 3mm 0; font-weight: 600; } + .cover-dl dt:last-of-type, .cover-dl dd:last-of-type { border-bottom: none; } + .cover-grid { display: grid; grid-template-columns: 1fr 56mm; gap: 5mm; margin-top: 6mm; } + .how-to { border-radius: .25rem; background: #e8f0f4; padding: 4mm; } + .how-to .how-to-title { margin: 0; font-size: 10.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: #315c75; } + .how-to p { margin: 2mm 0 0; font-size: 10.5pt; line-height: 1.35; color: #435264; } + .cover-foot { margin-top: auto; } + .cover-foot .ink-rule { height: .5mm; background: #172033; margin-bottom: 5mm; } + .cover-foot-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 8mm; } + .cover-scope { margin: 0; max-width: 118mm; font-size: 10.5pt; line-height: 1.4; color: #435264; } + .cover-version { margin: 0; text-align: right; font-size: 8pt; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: #315c75; } + .defeso { margin-top: auto; } +` + +export const renderDossierHtml = (report) => { + const gapChunks = chunkGaps(report.gaps) + report.meta.pageTotal = 5 + report.eras.length + gapChunks.length + let page = 0 + const parts = [] + parts.push(renderCover(report)) + page += 1 + parts.push(renderTrajectory(report, ++page)) + parts.push(renderSummary(report, ++page)) + for (const era of report.eras) parts.push(renderEra(report, era, ++page)) + parts.push(renderRegion(report, ++page)) + gapChunks.forEach((chunk, index) => parts.push(renderGapsPage(report, chunk, index, ++page))) + parts.push(renderSourcesPage(report, ++page)) + + return ` + +${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.municipalityName)} + +${parts.join('\n')} + +` +} + +const mdEra = (era) => { + const lines = [ + `## ${era.label} (${era.period})`, + '', + `_${era.method}_`, + '', + `Trilha: ${era.recovery}`, + ] + if (era.numbers.length) { + lines.push( + '', + '| Objeto | Valor | Ano | Fase | Esfera | Fonte |', + '| --- | --- | --- | --- | --- | --- |', + ) + for (const row of era.numbers) { + lines.push( + `| ${row.object} | ${row.value ?? '—'} | ${row.year ?? '—'} | ${dossierPhaseLabel(row.phase)} | ${row.sphere} | ${row.sourceUrl ?? '—'} |`, + ) + } + } + for (const action of era.actions) { + lines.push( + '', + `- **${stripInlineSources(action.title)}** (${action.sphere}${action.year ? ` · ${action.year}` : ''})${action.detail ? ` — ${stripInlineSources(action.detail)}` : ''} [fonte](${action.sourceUrl})`, + ) + } + for (const honor of era.honors) + lines.push('', `- Honraria: ${stripInlineSources(honor.text)} [fonte](${honor.sourceUrl})`) + return lines.join('\n') +} + +export const renderDossierMd = (report) => { + const lines = [ + `# ${report.meta.title} — ${report.meta.municipalityName}`, + '', + `**Território de identidade:** ${report.meta.region}`, + `**Gerado em:** ${formatDateTimeBr(report.meta.generatedAt)} · **Base Teqo (read-only) lida em:** ${report.meta.readAtLabel}`, + '', + '> INSUMO INTERNO — não circular, não publicar. Documento de trabalho.', + '', + '## Linha do tempo da carreira', + '', + ] + for (const row of report.trajectory) { + lines.push( + `- **${row.period}** — ${row.role}${row.uncertain ? ` _(${row.uncertain})_` : ''} · ${row.recovery}`, + ) + } + + if (report.page1.deliveries.length) { + lines.push('', '## Principais entregas localizadas', '') + for (const row of report.page1.deliveries) { + lines.push( + `- **${stripInlineSources(row.title)}** · _${row.sphere}_${row.value ? ` · ${row.value}` : ''}${row.phase ? ` (${dossierPhaseLabel(row.phase)})` : ''}${row.detail ? ` — ${stripInlineSources(row.detail)}` : ''} [fonte](${row.sourceUrl})`, + ) + } + lines.push('', '_Autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor._') + } + + for (const era of report.eras) { + lines.push('', mdEra(era)) + } + + lines.push( + '', + '## Região / polo', + '', + `> ${report.region.ruleTitle}`, + `> ${report.region.ruleBody}`, + '', + ) + lines.push('### Município', '') + for (const item of report.region.municipal.items) { + lines.push(`- ${stripInlineSources(item.answer)} — [fonte](${item.sourceUrl})`) + } + lines.push('', '### Região / polo (não somável)', '') + for (const item of report.region.regional.items) { + lines.push(`- ${stripInlineSources(item.answer)} — [fonte](${item.sourceUrl})`) + } + if (report.region.items.length) { + lines.push('', '| Item | Esfera | Evidência | Fonte |', '| --- | --- | --- | --- |') + for (const item of report.region.items) { + lines.push( + `| ${stripInlineSources(item.item)} | ${item.sphere} | ${stripInlineSources(item.evidence)} | ${item.sourceUrl} |`, + ) + } + } + + lines.push('', '## Lacunas explícitas', '') + if (report.gaps.length === 0) lines.push('_Nenhuma lacuna declarada._') + for (const gap of report.gaps) { + lines.push(`- **${gap.label}:** ${gap.reason} → ${gap.nextStep}`) + } + + if (report.news.length) { + lines.push('', '## Notícias e documentos consultados', '') + for (const row of report.news) { + lines.push(`- ${formatDateBr(row.date)} · ${row.outlet} — ${row.title} — ${row.url}`) + } + } + + lines.push( + '', + '## Limites de cobertura', + '', + ...report.limits.coverage.map((item) => `- ${item}`), + '', + '## Regras para uso editorial', + '', + ...report.limits.editorial.map((item) => `- ${item}`), + '', + '## Nota de defeso eleitoral 2026', + '', + 'Insumo interno não é autorização para publicar. Publicidade institucional e propaganda eleitoral obedecem a regimes diferentes. Antes de adaptar qualquer trecho para peça externa, submeta texto, imagem, autoria, período e canal à revisão responsável.', + '', + `_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/dossieResearch.mjs b/scripts/lib/dossieResearch.mjs new file mode 100644 index 00000000..f8f41360 --- /dev/null +++ b/scripts/lib/dossieResearch.mjs @@ -0,0 +1,324 @@ +/** + * Research contract for the C186 dossiê (owner). The dossiê is researched one + * file per era (`..research.json`); each claim either carries a date + * + URL or becomes an explicit gap. Reuses the C163 validation primitives + * (exported additively from `cityReportResearch.mjs`) instead of twinning them. + */ + +import { collectExtraSources, isNonEmptyString, isValidDate } from './cityReportResearch.mjs' +import { DOSSIER_ERA_IDS } from './dossieCareer.mjs' + +/** Explicit spheres: a regional/pole item is never summed into the município. */ +const DOSSIER_SPHERES = ['municipio', 'regiao', 'polo'] + +/** Execution phases (empenho ≠ pagamento). Unknown/absent degrades to "não informada". */ +const DOSSIER_PHASES = ['autorizado', 'empenhado', 'liquidado', 'pago', 'restos', 'nao_informado'] + +/** Checklist per era — the dossiê's research checklist. `area` labels the boletim card. */ +const DOSSIER_RESEARCH_CHECKLIST = [ + { + id: 'era_a_formacao', + era: 'A', + area: 'Formação', + label: 'Formação e residência (UFBA/INAMPS)', + }, + { + id: 'era_a_sesab', + era: 'A', + area: 'Saúde pública', + label: 'SESAB — epidemiologia e sanitarismo (até 1998)', + }, + { + id: 'era_a_consultor_ms', + era: 'A', + area: 'Ministério da Saúde', + label: 'Consultoria no Ministério da Saúde (1995–1999)', + }, + { + id: 'era_a_conquista', + era: 'A', + area: 'Saúde municipal', + label: 'Secretaria Municipal de Saúde de Vitória da Conquista (1999–2002)', + }, + { + id: 'era_a_sas_ms', + era: 'A', + area: 'Ministério da Saúde', + label: 'Secretaria de Atenção à Saúde do Ministério da Saúde (2003–2005)', + }, + { id: 'era_b_sesab', era: 'B', area: 'Saúde estadual', label: 'Gestão da SESAB (2007–2014)' }, + { + id: 'era_b_equipamentos', + era: 'B', + area: 'Equipamentos', + label: 'Hospitais, UPAs e equipamentos na região', + }, + { + id: 'era_b_programas', + era: 'B', + area: 'Programas', + label: 'Programas e políticas estaduais de saúde', + }, + { + id: 'era_b_obras', + era: 'B', + area: 'Obras', + label: 'Obras e investimentos em saúde no município/região', + }, + { + id: 'era_c_discursos', + era: 'C', + area: 'Mandato', + label: 'Pronunciamentos e falas com menção ao município', + }, + { + id: 'era_c_proposicoes', + era: 'C', + area: 'Mandato', + label: 'Proposições, relatorias e ações legislativas', + }, + { + id: 'era_c_emendas', + era: 'C', + area: 'Emendas', + label: 'Emendas e recursos para o município/região', + }, + { + id: 'era_c_titulos', + era: 'C', + area: 'Reconhecimento', + label: 'Títulos, honrarias e vínculos locais', + }, + { + id: 'era_c_atuacao', + era: 'C', + area: 'Atuação regional', + label: 'Atuação e articulação regional', + }, +] + +export const DOSSIER_RESEARCH_CHECKLIST_IDS = DOSSIER_RESEARCH_CHECKLIST.map((item) => item.id) + +const checklistById = new Map(DOSSIER_RESEARCH_CHECKLIST.map((item) => [item.id, item])) + +export const dossierChecklistForEra = (era) => + DOSSIER_RESEARCH_CHECKLIST.filter((item) => item.era === era) + +const normalizeSphere = (value) => { + if (!isNonEmptyString(value)) return 'municipio' + const sphere = value.trim() + return DOSSIER_SPHERES.includes(sphere) ? sphere : null +} + +const normalizePhase = (value) => { + if (!isNonEmptyString(value)) return 'nao_informado' + const phase = value.trim() + return DOSSIER_PHASES.includes(phase) ? phase : 'nao_informado' +} + +const normalizeNumbers = (entry) => { + const numbers = [] + for (const number of Array.isArray(entry?.numbers) ? entry.numbers : []) { + const label = isNonEmptyString(number?.label) ? number.label.trim() : null + const value = isNonEmptyString(number?.value) ? number.value.trim() : null + if (!label || !value) continue + numbers.push({ + label, + value, + year: isNonEmptyString(number?.year) ? number.year.trim() : null, + phase: normalizePhase(number?.phase), + }) + } + return numbers +} + +/** + * Validates/normalizes one era's agent-written research JSON. + * + * Structural failures (no slug, no research date, no/unknown era) throw — the + * operator fixes the file. Content failures (missing source, unknown item, item + * from another era) degrade to explicit gaps. + */ +export const normalizeDossierResearchInput = (raw, { now = new Date() } = {}) => { + if (!raw || typeof raw !== 'object') { + throw new Error('Pesquisa do dossiê inválida: o arquivo precisa ser um objeto JSON.') + } + if (!isNonEmptyString(raw.municipalitySlug)) { + throw new Error('Pesquisa do dossiê inválida: falta "municipalitySlug".') + } + if (!isValidDate(raw.researchedAt)) { + throw new Error( + 'Pesquisa do dossiê inválida: falta "researchedAt" (ISO) — a pesquisa precisa ser datada.', + ) + } + const era = isNonEmptyString(raw.era) ? raw.era.trim().toUpperCase() : null + if (!era || !DOSSIER_ERA_IDS.includes(era)) { + throw new Error('Pesquisa do dossiê inválida: falta "era" (A, B ou C).') + } + + 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 + const checklistItem = id ? checklistById.get(id) : null + if (!checklistItem) { + gaps.push({ + id: id ?? 'desconhecido', + label: null, + reason: 'Item fora do checklist de pesquisa do dossiê.', + }) + continue + } + if (checklistItem.era !== era) { + gaps.push({ + id, + label: checklistItem.label, + reason: `Item da Era ${checklistItem.era} enviado no arquivo da Era ${era}.`, + }) + 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: checklistItem.label, reason: 'Não pesquisado.' }) + continue + } + if (!sourceUrl || !sourceDate) { + gaps.push({ + id, + label: checklistItem.label, + reason: 'Sem fonte: cada item exige URL e data.', + }) + continue + } + const sphere = normalizeSphere(entry.sphere) + if (!sphere) { + gaps.push({ + id, + label: checklistItem.label, + reason: `Esfera inválida (esperado ${DOSSIER_SPHERES.join('/')}).`, + }) + continue + } + items.push({ + id, + era, + label: checklistItem.label, + area: checklistItem.area, + answer, + details: isNonEmptyString(entry.details) ? entry.details.trim() : null, + sphere, + numbers: normalizeNumbers(entry), + sourceUrl, + sourceDate, + extraSources: collectExtraSources(entry), + consultedAt: isValidDate(entry.consultedAt) ? entry.consultedAt : null, + }) + } + + for (const entry of Array.isArray(raw.gaps) ? raw.gaps : []) { + if (isNonEmptyString(entry?.reason)) { + const id = isNonEmptyString(entry.id) ? entry.id : 'lacuna' + if (checklistById.has(id)) seen.add(id) + gaps.push({ + id, + label: isNonEmptyString(entry.label) ? entry.label : null, + reason: entry.reason.trim(), + }) + } + } + + for (const checklistItem of dossierChecklistForEra(era)) { + 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 + } + news.push({ + era, + title, + outlet: isNonEmptyString(entry.outlet) ? entry.outlet.trim() : null, + publishedAt, + url, + summary: isNonEmptyString(entry.summary) ? entry.summary.trim() : null, + }) + } + + return { + municipalitySlug: raw.municipalitySlug.trim(), + era, + researchedAt: researchedAt.toISOString(), + reference, + items, + news, + gaps, + } +} + +/** Short receipt the researcher subagent returns (never the body). */ +export const dossierResearchReceipt = (research) => ({ + slug: research.municipalitySlug, + era: research.era, + status: 'ok', + researchPath: `data/dossie-solla-cidade/${research.municipalitySlug}.${research.era.toLowerCase()}.research.json`, + researchedAt: research.researchedAt, + itemCount: research.items.length, + gapCount: research.gaps.length, + newsCount: research.news.length, + gaps: research.gaps.map((gap) => gap.id), +}) + +/** + * Merges the per-era research files into one view. Fails closed when the slugs + * disagree (a mixed bundle would print another city's facts). + */ +export const mergeDossierResearch = (researches) => { + const list = Array.isArray(researches) ? researches.filter(Boolean) : [] + if (list.length === 0) throw new Error('Dossiê sem pesquisa: nenhum arquivo por era foi lido.') + const slug = list[0].municipalitySlug + for (const research of list) { + if (research.municipalitySlug !== slug) { + throw new Error( + `Pesquisas de municípios diferentes ("${slug}" e "${research.municipalitySlug}") — pare e regenere.`, + ) + } + } + const eraOrder = new Map(DOSSIER_ERA_IDS.map((id, index) => [id, index])) + const items = list + .flatMap((research) => research.items) + .sort((left, right) => (eraOrder.get(left.era) ?? 0) - (eraOrder.get(right.era) ?? 0)) + const news = list + .flatMap((research) => research.news) + .sort((left, right) => Date.parse(right.publishedAt) - Date.parse(left.publishedAt)) + const gaps = list.flatMap((research) => research.gaps) + const researchedAt = list + .map((research) => research.researchedAt) + .sort() + .at(-1) + return { + municipalitySlug: slug, + eras: list.map((research) => research.era), + items, + news, + gaps, + researchedAt, + } +} diff --git a/scripts/lib/reportText.mjs b/scripts/lib/reportText.mjs new file mode 100644 index 00000000..3c2060f0 --- /dev/null +++ b/scripts/lib/reportText.mjs @@ -0,0 +1,53 @@ +/** + * Shared print-text contract for the Report builders (C163 city report + C186 + * dossiê/boletim): escaping, bare-URL anchors and the inline-source tokens + * (`{{fonte}}` / `{{fonte:N}}`). Extracted from `cityReportRender.mjs` so the + * dossiê renderer does not twin the subtle token resolution — one owner, two + * consumers. + */ + +export const htmlEscape = (value) => + String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + +const URL_PATTERN = /(https?:\/\/[^\s<>"')]+)/g + +/** Escapes the text and turns every bare URL into a clickable anchor (PDF links). */ +export const htmlWithLinks = (value) => + htmlEscape(value).replace(URL_PATTERN, (url) => `${url}`) + +const INLINE_SOURCE_PATTERN = /\{\{fonte(?::(\d+))?\}\}/g + +/** Drops the inline-source tokens (no source surface, e.g. the boletim). */ +export const stripInlineSources = (value) => + String(value ?? '') + .replace(INLINE_SOURCE_PATTERN, '') + .replace(/\s{2,}/g, ' ') + .replace(/\s+([.,;:!?])/g, '$1') + .trim() + +/** + * Resolves the `{{fonte}}` / `{{fonte:N}}` tokens the content model leaves in + * the citation text into `(fonte)` anchors — inline, right where the excerpt + * cites the verified fact, so one text can point to more than one source. + */ +export const renderInlineSourcesHtml = (value, sources) => + htmlWithLinks(value).replace(INLINE_SOURCE_PATTERN, (_match, index) => { + const position = Number(index ?? 1) - 1 + const url = sources?.[position] + if (!url) return '' + const label = sources.length > 1 ? `(fonte ${position + 1})` : '(fonte)' + return ` ${label}` + }) + +export const renderInlineSourcesMd = (value, sources) => + String(value ?? '').replace(INLINE_SOURCE_PATTERN, (_match, index) => { + const position = Number(index ?? 1) - 1 + const url = sources?.[position] + if (!url) return '' + const label = sources.length > 1 ? `fonte ${position + 1}` : 'fonte' + return ` [(${label})](${url})` + }) diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 1ed57a6a..e41e2dad 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -41,6 +41,7 @@ export const SCRIPTS_SPEC_PINNED = [ 'scripts/lib/agent-session.mjs', 'scripts/lib/ansi.mjs', 'scripts/lib/auto-unblock.mjs', + 'scripts/lib/camaraFetch.mjs', 'scripts/lib/camaraSpeeches.mjs', 'scripts/lib/changelog.mjs', 'scripts/lib/cityReportBlocks.mjs', @@ -53,6 +54,14 @@ export const SCRIPTS_SPEC_PINNED = [ 'scripts/lib/conflictMarkers.mjs', 'scripts/lib/db-start.mjs', 'scripts/lib/deploy-trigger.mjs', + 'scripts/lib/dossieBlocks.mjs', + 'scripts/lib/dossieBulletin.mjs', + 'scripts/lib/dossieBulletinRender.mjs', + 'scripts/lib/dossieCamara.mjs', + 'scripts/lib/dossieCareer.mjs', + 'scripts/lib/dossieHealthData.mjs', + 'scripts/lib/dossieRender.mjs', + 'scripts/lib/dossieResearch.mjs', 'scripts/lib/e2e-affected-manifest.mjs', 'scripts/lib/github-api.mjs', 'scripts/lib/github-branch-protection.mjs', @@ -64,6 +73,7 @@ export const SCRIPTS_SPEC_PINNED = [ 'scripts/lib/plansOnlyClosesGuard.mjs', 'scripts/lib/playwright-e2e-args.mjs', 'scripts/lib/portalTransparenciaEmendas.mjs', + 'scripts/lib/reportText.mjs', 'scripts/lib/seed-minimal-manifest.mjs', 'scripts/lib/sollaCeuciSalvadorMetrics.mjs', 'scripts/lib/staging-test-account.mjs', diff --git a/tests/unit/dossieBatchSkill.unit.spec.ts b/tests/unit/dossieBatchSkill.unit.spec.ts new file mode 100644 index 00000000..d2c676b9 --- /dev/null +++ b/tests/unit/dossieBatchSkill.unit.spec.ts @@ -0,0 +1,91 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// C186: pins the batch contract of the dossie-solla-cidade skill, the per-era +// fan-out and the researcher-only role of its subagent. The batch entry and the +// era briefing are prompt text, so the contract lives in the SKILL.md prose — +// this guard fails the build if the batch/resolver/receipt/serialization/era +// wording is dropped, or if the subagent regains the ssh/build pipeline. + +const repoRoot = process.cwd() + +const read = (path: string): string => readFileSync(resolve(repoRoot, path), 'utf8') + +const skill = read('.agents/skills/dossie-solla-cidade/SKILL.md') +const researcher = read('.opencode/agent/dossie-solla-cidade.md') + +describe('skill /dossie-solla-cidade documents the batch contract', () => { + it('keeps the comma-separated batch entry and the single-city case', () => { + expect(skill).toContain('## Lote (várias cidades)') + expect(skill, 'batch example must be literal').toContain( + '/dossie-solla-cidade Ilheus, Itacare, Una', + ) + expect(skill, 'single-city must remain valid').toContain('/dossie-solla-cidade Ilheus') + expect(skill, 'comma is the separator').toContain('vírgula') + expect(skill, 'N=1 is the preserved path').toContain('N=1') + }) + + it('pins the canonical slug resolution and the ambiguity policy', () => { + expect(skill).toContain('isMunicipalitySlug') + expect(skill).toContain('resolveMunicipalityName') + expect(skill, 'Salvador ambiguity must be explicit').toContain('salvador-ze-N') + expect(skill, 'ambiguous token is an isolated failure').toContain('ambíguo') + }) + + it('documents the staged pipeline with a serialized extraction', () => { + expect(skill).toContain('## Pipeline (etapas)') + expect(skill).toContain('serializada') + expect(skill, 'isolated failure must be visible as partial success').toContain( + 'sucesso parcial', + ) + expect(skill, 'summary lists status per entry').toContain('(ok|failed)') + }) + + it('pins the per-era research fan-out and the concurrency guard', () => { + expect(skill).toContain('## Briefing por era (A/B/C)') + expect(skill, 'the three eras are the recorte').toContain('Era A') + expect(skill, 'the three eras are the recorte').toContain('Era B') + expect(skill, 'the three eras are the recorte').toContain('Era C') + expect(skill, 'the fan-out must be bounded').toContain('MAX_RESEARCHERS_IN_FLIGHT') + }) + + it('documents the dossiê and the boletim outputs', () => { + expect(skill).toContain('-dossie.pdf') + expect(skill).toContain('-boletim.pdf') + expect(skill, 'the boletim has no source list').toContain('Sem declaração de fontes') + }) +}) + +describe('finite researcher receipt (C186)', () => { + it('declares the short receipt and its fields', () => { + expect(skill).toContain('## Recibo do researcher') + for (const field of ['itemCount', 'gapCount', 'newsCount', 'failureReason']) { + expect(skill, `receipt must declare ${field}`).toContain(field) + } + expect(skill, 'receipt must never carry the body').toMatch(/corpo\s+do\s+`research\.json`/) + }) + + it('declares the per-era JSON contract', () => { + expect(skill).toContain('## Contrato dos JSONs') + expect(skill, 'era is required in the file').toContain('"era": "C"') + for (const id of ['era_a_formacao', 'era_b_sesab', 'era_c_emendas']) { + expect(skill, `checklist must list ${id}`).toContain(id) + } + expect(skill, 'region/polo is never summed').toMatch(/nunca\*?\*?\s*é\s+somado ao município/) + }) +}) + +describe('subagent .opencode/agent/dossie-solla-cidade.md is researcher-only', () => { + it('stays a subagent that writes the era research.json and returns the receipt', () => { + expect(researcher).toMatch(/^---\n[\s\S]*mode: subagent[\s\S]*\n---/) + expect(researcher).toContain('researcher') + expect(researcher).toContain('.c.research.json') + expect(researcher).toContain('Recibo do researcher') + }) + + it('does not run the deterministic extract/build stages', () => { + expect(researcher, 'researcher must not run the extractor CLI').not.toContain('--municipality=') + expect(researcher, 'researcher must not run the builder CLI').not.toContain('--snapshot=') + }) +}) diff --git a/tests/unit/dossieBlocks.unit.spec.ts b/tests/unit/dossieBlocks.unit.spec.ts new file mode 100644 index 00000000..c0d3f793 --- /dev/null +++ b/tests/unit/dossieBlocks.unit.spec.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' + +import { buildDossierReport } from '../../scripts/lib/dossieBlocks.mjs' +import { + dossierChecklistForEra, + mergeDossierResearch, + normalizeDossierResearchInput, +} from '../../scripts/lib/dossieResearch.mjs' + +const generatedAt = new Date('2026-09-17T12:00:00.000Z') + +const snapshot = { + meta: { readAt: '2026-09-16T10:00:00.000Z', codeSha: 'abc1234', database: 'teqo_test' }, + municipality: { slug: 'ilheus', name: 'Ilhéus', region: 'Litoral Sul', ibgeCode: '2913606' }, +} + +const item = (id: string, overrides: Record = {}) => ({ + id, + answer: `Resposta de ${id}`, + sourceUrl: `https://exemplo.test/${id}`, + sourceDate: '2026-09-10', + ...overrides, +}) + +const eraFile = (era: 'A' | 'B' | 'C', items: unknown[]) => ({ + municipalitySlug: 'ilheus', + era, + researchedAt: '2026-09-16T10:00:00.000Z', + items, + news: [], + gaps: [], +}) + +const research = mergeDossierResearch([ + normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista', { number: undefined })]), { + now: generatedAt, + }), + normalizeDossierResearchInput( + eraFile('B', [ + item('era_b_sesab', { + numbers: [{ label: 'Recurso estadual', value: 'R$ 2 mi', year: '2012', phase: 'pago' }], + }), + item('era_b_obras', { sphere: 'regiao', answer: 'Hospital de referência regional' }), + ]), + { now: generatedAt }, + ), + normalizeDossierResearchInput( + eraFile('C', [ + item('era_c_emendas', { + numbers: [{ label: 'Recurso', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }], + }), + item('era_c_titulos', { answer: 'Cidadão Ilheense' }), + ]), + { now: generatedAt }, + ), +]) + +const emendas = { + status: 'ok', + sourceUrl: 'https://portaldatransparencia.test/emendas', + consultedAt: '2026-09-17T11:00:00.000Z', + rows: [ + { + functionName: 'Saúde', + year: 2024, + empenhado: 1_200_000, + liquidado: 0, + pago: 0, + restoPago: 0, + }, + ], +} + +const camara = { + status: 'ok', + items: [ + { + title: 'PL 1/2023', + detail: 'Ementa de exemplo', + year: '2023', + sourceUrl: 'https://camara.test/proposicao/1', + }, + ], +} + +const health = { + status: 'ok', + items: [ + { + topic: 'População residente (Censo 2022)', + detail: '100.000 habitantes', + value: 100_000, + year: '2022', + sourceUrl: 'https://sidra.test/9514', + sourceDate: '2026-09-16', + }, + ], +} + +const build = (overrides: Record = {}) => + buildDossierReport({ snapshot, research, emendas, camara, health, generatedAt, ...overrides }) + +describe('buildDossierReport', () => { + it('builds one section per era with sourced actions', () => { + const report = build() + expect(report.eras.map((era) => era?.id)).toEqual(['A', 'B', 'C']) + expect( + report.eras.every((era) => (era?.numbers.length ?? 0) + (era?.actions.length ?? 0) > 0), + ).toBe(true) + }) + + it('turns official emenda execution into phase-labelled rows (empenho ≠ pagamento)', () => { + const eraC = build().eras.find((era) => era?.id === 'C') + expect(eraC?.numbers).toContainEqual( + expect.objectContaining({ object: 'Saúde', phase: 'empenhado', sphere: 'municipio' }), + ) + }) + + it('includes the IBGE baseline as an Era B number', () => { + const eraB = build().eras.find((era) => era?.id === 'B') + expect(eraB?.numbers).toContainEqual( + expect.objectContaining({ + object: 'População residente (Censo 2022)', + phase: 'nao_informado', + }), + ) + }) + + it('never sums região/polo into the município list', () => { + const report = build() + expect(report.region.municipal.items.every((row) => row.sphere === 'municipio')).toBe(true) + expect(report.region.regional.items.some((row) => row.sphere === 'regiao')).toBe(true) + expect(report.region.municipal.items.some((row) => row.id === 'era_b_obras')).toBe(false) + }) + + it('derives bulletinFacts only from sourced items and official rows', () => { + const report = build() + expect(report.bulletinFacts.length).toBeGreaterThan(0) + expect(report.bulletinFacts.every((fact) => Boolean(fact.sourceUrl))).toBe(true) + expect(report.bulletinFacts.some((fact) => fact.area === 'Emendas')).toBe(true) + }) + + it('omits an era that has no number and no action', () => { + const empty = mergeDossierResearch([ + normalizeDossierResearchInput(eraFile('A', []), { now: generatedAt }), + normalizeDossierResearchInput(eraFile('B', []), { now: generatedAt }), + normalizeDossierResearchInput(eraFile('C', []), { now: generatedAt }), + ]) + const report = buildDossierReport({ + snapshot, + research: empty, + emendas: { status: 'gap', rows: [], reason: 'sem chave' }, + camara: { status: 'gap', items: [] }, + health: { status: 'gap', items: [] }, + generatedAt, + }) + expect(report.eras).toHaveLength(0) + }) + + it('caps the page-1 deliveries and keeps the full timeline on its own page', () => { + const report = build() + expect(report.page1.deliveries.length).toBeLessThanOrEqual(3) + expect(report.page1.timeline.length).toBe(6) + expect(report.trajectory.length).toBeGreaterThan(report.page1.timeline.length) + }) + + it('carries the defeso limits and the editorial rules', () => { + const report = build() + expect(report.limits.editorial).toContain('Sem fonte, não publica.') + expect(report.limits.coverage.join(' ')).toContain('2011') + }) +}) + +describe('dossier checklist coverage', () => { + it('every checklist item is bound to an era', () => { + for (const era of ['A', 'B', 'C'] as const) { + expect(dossierChecklistForEra(era).every((row) => row.era === era)).toBe(true) + } + }) +}) diff --git a/tests/unit/dossieBulletin.unit.spec.ts b/tests/unit/dossieBulletin.unit.spec.ts new file mode 100644 index 00000000..37ee7c2c --- /dev/null +++ b/tests/unit/dossieBulletin.unit.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' + +import { + BULLETIN_HIGHLIGHT_LIMIT, + BULLETIN_MORE_LIMIT, + buildBulletin, +} from '../../scripts/lib/dossieBulletin.mjs' + +const fact = (index: number, overrides: Record = {}) => ({ + id: `fato-${index}`, + era: 'C', + sphere: 'municipio', + area: 'Saúde', + headline: `Entrega ${index}`, + detail: `Detalhe ${index}`, + value: `R$ ${index} mil`, + year: '2024', + phase: 'empenhado', + sourceUrl: `https://exemplo.test/${index}`, + sourceDate: '2026-09-10', + ...overrides, +}) + +const generatedAt = new Date('2026-09-17T12:00:00.000Z') + +describe('buildBulletin', () => { + it('caps highlights and "e mais" items', () => { + const facts = Array.from({ length: 30 }, (_value, index) => fact(index)) + const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt }) + expect(bulletin.highlights).toHaveLength(BULLETIN_HIGHLIGHT_LIMIT) + expect(bulletin.moreItems).toHaveLength(BULLETIN_MORE_LIMIT) + }) + + it('orders município before região and a sourced number before none', () => { + const facts = [ + fact(1, { sphere: 'regiao' }), + fact(2, { value: null }), + fact(3, { sphere: 'municipio', value: '10 UPAs' }), + ] + const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt }) + expect(bulletin.highlights[0].title).toBe('Entrega 3') + }) + + it('composes the card eyebrow with area and sphere', () => { + const bulletin = buildBulletin({ + facts: [fact(1, { sphere: 'regiao' })], + municipality: 'Ilhéus', + generatedAt, + }) + expect(bulletin.highlights[0].eyebrow).toBe('Saúde · região') + }) + + it('renders an empty state without inventing facts', () => { + const bulletin = buildBulletin({ facts: [], municipality: 'Ilhéus', generatedAt }) + expect(bulletin.highlights).toEqual([]) + expect(bulletin.moreItems).toEqual([]) + expect(bulletin.timeline.length).toBe(4) + }) + + it('never drops the fact source from the ledger (boletim can only inherit sourced facts)', () => { + const facts = Array.from({ length: 25 }, (_value, index) => fact(index)) + const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt }) + const chosen = bulletin.highlights.length + bulletin.moreItems.length + expect(chosen).toBe(BULLETIN_HIGHLIGHT_LIMIT + BULLETIN_MORE_LIMIT) + expect(facts.every((row) => Boolean(row.sourceUrl))).toBe(true) + }) +}) diff --git a/tests/unit/dossieCamara.unit.spec.ts b/tests/unit/dossieCamara.unit.spec.ts new file mode 100644 index 00000000..cdddf755 --- /dev/null +++ b/tests/unit/dossieCamara.unit.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' + +import { + fetchCamaraActivity, + normalizeProposition, + normalizeSpeech, + propositionsUrl, + speechWindows, +} from '../../scripts/lib/dossieCamara.mjs' + +const jsonResponse = (body: unknown, ok = true, status = 200) => ({ + ok, + status, + json: async () => body, + text: async () => JSON.stringify(body), +}) + +describe('normalizeProposition / normalizeSpeech', () => { + it('builds a proposition title from sigla/numero/ano and links the tramitação', () => { + const row = normalizeProposition({ + id: 123, + siglaTipo: 'PL', + numero: 456, + ano: 2023, + ementa: 'Dispõe sobre saúde.', + }) + expect(row?.title).toBe('PL 456/2023') + expect(row?.detail).toBe('Dispõe sobre saúde.') + expect(row?.sourceUrl).toContain('idProposicao=123') + }) + + it('ignores an empty proposition row', () => { + expect(normalizeProposition({})).toBeNull() + }) + + it('derives the speech year and keeps the API URL as source', () => { + const row = normalizeSpeech( + { + dataHoraInicio: '2023-05-10T14:00:00', + tipoDiscurso: 'DISCURSO', + sumario: 'Fala sobre a cidade.', + }, + 178857, + ) + expect(row?.year).toBe('2023') + expect(row?.sourceUrl).toContain('/deputados/178857/discursos') + }) +}) + +describe('fetchCamaraActivity', () => { + it('queries proposições and discursos with an explicit window and returns sourced items', async () => { + const urls: string[] = [] + const fetchImpl = async (url: string) => { + urls.push(url) + if (url.includes('/proposicoes')) { + return jsonResponse({ + dados: [{ id: 1, siglaTipo: 'PL', numero: 1, ano: 2024, ementa: 'Saúde' }], + }) + } + return jsonResponse({ dados: [{ dataHoraInicio: '2024-03-01T10:00:00', sumario: 'Fala' }] }) + } + + const result = await fetchCamaraActivity({ + from: '2024-01-01', + to: '2024-12-31', + fetchImpl: fetchImpl as unknown as typeof fetch, + }) + + expect(result.status).toBe('ok') + expect(result.items.length).toBeGreaterThan(0) + expect(result.items.every((item) => Boolean(item.sourceUrl))).toBe(true) + const speechUrl = urls.find((url) => url.includes('/discursos')) + expect(speechUrl).toContain('dataInicio=2024-01-01') + expect(speechUrl).toContain('dataFim=2024-12-31') + }) + + it('degrades to an explicit gap on an HTTP failure', async () => { + const fetchImpl = async () => jsonResponse({}, false, 500) + const result = await fetchCamaraActivity({ fetchImpl: fetchImpl as unknown as typeof fetch }) + expect(result.status).toBe('gap') + expect(result.items).toEqual([]) + expect(result.reason).toMatch(/Câmara/) + }) + + it('builds the propositions URL with the deputy id', () => { + expect(propositionsUrl(178857, 2)).toContain('idDeputadoAutor=178857') + expect(propositionsUrl(178857, 2)).toContain('pagina=2') + }) +}) + +describe('speechWindows', () => { + it('chunks the mandate into ≤4-year windows (the API rejects wider ranges)', () => { + const windows = speechWindows('2015-01-01', '2026-09-17') + expect(windows.length).toBeGreaterThan(1) + for (const window of windows) { + const span = Number(window.to.slice(0, 4)) - Number(window.from.slice(0, 4)) + expect(span).toBeLessThanOrEqual(3) + } + expect(windows[0]).toEqual({ from: '2015-01-01', to: '2018-12-31' }) + expect(windows.at(-1)?.to).toBe('2026-09-17') + }) + + it('keeps a single short window when the range is under four years', () => { + expect(speechWindows('2024-01-01', '2024-12-31')).toEqual([ + { from: '2024-01-01', to: '2024-12-31' }, + ]) + }) +}) diff --git a/tests/unit/dossieCareer.unit.spec.ts b/tests/unit/dossieCareer.unit.spec.ts new file mode 100644 index 00000000..e84a8e0b --- /dev/null +++ b/tests/unit/dossieCareer.unit.spec.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { + BULLETIN_TIMELINE_STEPS, + CAREER_NOTES, + CAREER_TIMELINE, + DOSSIER_ERAS, + SOLLA_BIRTH_DATE, + SOLLA_BIRTH_PLACE, + SOLLA_DEPUTY_ID, + SOLLA_OFFICIAL_SITE, +} from '../../scripts/lib/dossieCareer.mjs' + +describe('career literals (verbatim from the intention)', () => { + it('pins the identity facts', () => { + expect(SOLLA_DEPUTY_ID).toBe(178857) + expect(SOLLA_BIRTH_DATE).toBe('1961-04-11') + expect(SOLLA_BIRTH_PLACE).toBe('Salvador-BA') + expect(SOLLA_OFFICIAL_SITE).toContain('178857') + expect(CAREER_NOTES).toContain('Nunca foi deputado estadual.') + }) + + it('covers the three eras with the strategic posts', () => { + expect(DOSSIER_ERAS.map((era) => era.id)).toEqual(['A', 'B', 'C']) + const roles = CAREER_TIMELINE.map((row) => row.role).join(' | ') + expect(roles).toContain('Vitória da Conquista') + expect(roles).toContain('Atenção à Saúde do Ministério da Saúde') + expect(roles).toContain('Secretário Estadual de Saúde da Bahia') + expect(roles).toContain('Deputado Federal, 57ª legislatura') + }) + + it('keeps the uncertain periods marked instead of inventing a date', () => { + const uncertain = CAREER_TIMELINE.filter((row) => row.uncertain).map((row) => row.uncertain) + expect(uncertain.join(' ')).toMatch(/1989 vs 1990/) + expect(uncertain.join(' ')).toMatch(/2005 vs 2006/) + }) + + it('has the four-step boletim trajectory', () => { + expect(BULLETIN_TIMELINE_STEPS).toHaveLength(4) + }) + + it('does not drift from the skill prose', () => { + const skill = readFileSync( + resolve(process.cwd(), '.agents/skills/dossie-solla-cidade/SKILL.md'), + 'utf8', + ) + expect(skill).toContain('178857') + expect(skill).toContain('2007–2014') + expect(skill).toContain('2015–2027') + expect(skill).toContain('pré-2011') + }) +}) diff --git a/tests/unit/dossieHealthData.unit.spec.ts b/tests/unit/dossieHealthData.unit.spec.ts new file mode 100644 index 00000000..174c60fa --- /dev/null +++ b/tests/unit/dossieHealthData.unit.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { + fetchHealthData, + parseSidraValue, + sidraPopulationUrl, +} from '../../scripts/lib/dossieHealthData.mjs' + +const jsonResponse = (body: unknown, ok = true, status = 200) => ({ + ok, + status, + json: async () => body, +}) + +describe('sidraPopulationUrl / parseSidraValue', () => { + it('builds the SIDRA population URL for the municipality', () => { + const url = sidraPopulationUrl('2913606') + expect(url).toContain('/t/9514/n6/2913606/v/93/p/2022') + }) + + it('parses the SIDRA value row and tolerates a missing datum', () => { + expect(parseSidraValue([{ V: 'Valor' }, { V: '159.923' }])).toBe(159923) + expect(parseSidraValue([{ V: 'Valor' }])).toBeNull() + expect(parseSidraValue(null)).toBeNull() + }) +}) + +describe('fetchHealthData', () => { + it('returns the sourced population baseline', async () => { + const fetchImpl = async () => jsonResponse([{ V: 'Valor' }, { V: '159.923' }]) + const result = await fetchHealthData({ + ibgeCode: '2913606', + fetchImpl: fetchImpl as unknown as typeof fetch, + now: new Date('2026-09-17T12:00:00.000Z'), + }) + expect(result.status).toBe('ok') + expect(result.items[0].detail).toContain('159.923') + expect(result.items[0].sourceUrl).toContain('sidra') + }) + + it('degrades to a gap when the municipality has no IBGE code', async () => { + const result = await fetchHealthData({ ibgeCode: null }) + expect(result.status).toBe('gap') + expect(result.items).toEqual([]) + }) + + it('degrades to a gap on a failed request', async () => { + const fetchImpl = async () => jsonResponse({}, false, 503) + const result = await fetchHealthData({ + ibgeCode: '2913606', + fetchImpl: fetchImpl as unknown as typeof fetch, + }) + expect(result.status).toBe('gap') + expect(result.reason).toMatch(/IBGE/) + }) +}) diff --git a/tests/unit/dossieRender.unit.spec.ts b/tests/unit/dossieRender.unit.spec.ts new file mode 100644 index 00000000..0b46d00c --- /dev/null +++ b/tests/unit/dossieRender.unit.spec.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' + +import { buildDossierReport } from '../../scripts/lib/dossieBlocks.mjs' +import { buildBulletin } from '../../scripts/lib/dossieBulletin.mjs' +import { renderBulletinHtml } from '../../scripts/lib/dossieBulletinRender.mjs' +import { renderDossierHtml, renderDossierMd } from '../../scripts/lib/dossieRender.mjs' +import { + mergeDossierResearch, + normalizeDossierResearchInput, +} from '../../scripts/lib/dossieResearch.mjs' + +const generatedAt = new Date('2026-09-17T12:00:00.000Z') +const snapshot = { + meta: { readAt: '2026-09-16T10:00:00.000Z', codeSha: 'abc1234', database: 'teqo_test' }, + municipality: { slug: 'ilheus', name: 'Ilhéus', region: 'Litoral Sul', ibgeCode: '2913606' }, +} + +const research = mergeDossierResearch([ + normalizeDossierResearchInput( + { + municipalitySlug: 'ilheus', + era: 'A', + researchedAt: '2026-09-16T10:00:00.000Z', + items: [ + { + id: 'era_a_conquista', + answer: 'Gestão da saúde em Vitória da Conquista', + details: 'Detalhe com {{fonte}}.', + sourceUrl: 'https://exemplo.test/conquista', + sourceDate: '2026-09-10', + }, + ], + news: [], + gaps: [], + }, + { now: generatedAt }, + ), + normalizeDossierResearchInput( + { + municipalitySlug: 'ilheus', + era: 'B', + researchedAt: '2026-09-16T10:00:00.000Z', + items: [ + { + id: 'era_b_equipamentos', + answer: '1 hospital regional', + sphere: 'polo', + sourceUrl: 'https://saude.test/hospital', + sourceDate: '2026-09-10', + }, + ], + news: [], + gaps: [], + }, + { now: generatedAt }, + ), + normalizeDossierResearchInput( + { + municipalitySlug: 'ilheus', + era: 'C', + researchedAt: '2026-09-16T10:00:00.000Z', + items: [ + { + id: 'era_c_emendas', + answer: 'R$ 1,2 milhão para equipamentos', + numbers: [{ label: 'Saúde', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }], + sourceUrl: 'https://portaldatransparencia.test/emendas', + sourceDate: '2026-09-15', + }, + ], + news: [ + { + title: 'Matéria local', + url: 'https://jornal.test/materia', + publishedAt: '2024-06-01', + outlet: 'Jornal Local', + }, + ], + gaps: [], + }, + { now: generatedAt }, + ), +]) + +const report = buildDossierReport({ + snapshot, + research, + emendas: { status: 'gap', rows: [], reason: 'sem chave' }, + camara: { status: 'gap', items: [] }, + health: { status: 'gap', items: [] }, + generatedAt, +}) + +describe('renderDossierHtml', () => { + const html = renderDossierHtml(report) + + it('anchors every page for the overflow guard and navigability', () => { + for (const anchor of [ + 'capa', + 'trajetoria', + 'resumo', + 'era-a', + 'era-b', + 'era-c', + 'regiao', + 'lacunas', + 'fontes', + ]) { + expect(html).toContain(`data-page="${anchor}"`) + } + }) + + it('escapes untrusted research text', () => { + expect(html).not.toContain('') + expect(html).toContain('<script>') + }) + + it('strips the inline {{fonte}} token — the row link carries the source', () => { + expect(html).not.toContain('{{fonte}}') + expect(html).not.toContain('class="inline-source"') + expect(html).toContain('https://exemplo.test/conquista') + }) + + it('keeps the region rule and the region/polo badge', () => { + expect(html).toContain('Região não é cidade') + expect(html).toContain('scope-region') + }) + + it('prints the defeso note', () => { + expect(html).toContain('Nota de defeso eleitoral 2026') + }) +}) + +describe('renderDossierMd', () => { + const md = renderDossierMd(report) + + it('mirrors the sections and keeps the sources clickable', () => { + expect(md).toContain('# Dossiê Solla por cidade — Ilhéus') + expect(md).toContain('## Linha do tempo da carreira') + expect(md).toContain('## Região / polo') + expect(md).toContain('## Lacunas explícitas') + expect(md).toContain('[fonte](https://exemplo.test/conquista)') + }) + + it('carries the defeso note', () => { + expect(md).toContain('defeso eleitoral') + }) +}) + +describe('renderBulletinHtml', () => { + const bulletin = buildBulletin({ + facts: report.bulletinFacts, + municipality: snapshot.municipality.name, + region: snapshot.municipality.region, + generatedAt, + }) + const html = renderBulletinHtml(bulletin) + + it('is a single A4 page with the model label', () => { + expect(html).toContain('data-page="boletim"') + expect(html).toContain('Modelo — insumo interno') + expect((html.match(/data-page="boletim"/g) ?? []).length).toBe(1) + }) + + it('declares no sources (they live in the dossiê)', () => { + expect(html).not.toContain('source-link') + expect(html).not.toContain('Fonte:') + expect(html).not.toContain('portaldatransparencia.test') + }) + + it('prints the trajectory and the defeso note', () => { + expect(html).toContain('Desde 1999') + expect(html).toContain('Defeso eleitoral') + }) +}) diff --git a/tests/unit/dossieResearch.unit.spec.ts b/tests/unit/dossieResearch.unit.spec.ts new file mode 100644 index 00000000..c451c973 --- /dev/null +++ b/tests/unit/dossieResearch.unit.spec.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' + +import { + DOSSIER_RESEARCH_CHECKLIST_IDS, + dossierChecklistForEra, + dossierResearchReceipt, + mergeDossierResearch, + normalizeDossierResearchInput, +} from '../../scripts/lib/dossieResearch.mjs' + +const now = new Date('2026-09-17T12: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 eraResearch = (era: 'A' | 'B' | 'C', overrides: Record = {}) => ({ + municipalitySlug: 'ilheus', + era, + researchedAt: '2026-09-16T10:00:00.000Z', + items: dossierChecklistForEra(era).map((item) => validItem(item.id)), + news: [], + gaps: [], + ...overrides, +}) + +describe('normalizeDossierResearchInput', () => { + it('exposes a per-era checklist covering three eras', () => { + expect(dossierChecklistForEra('A').length).toBeGreaterThan(0) + expect(dossierChecklistForEra('B').length).toBeGreaterThan(0) + expect(dossierChecklistForEra('C').length).toBeGreaterThan(0) + expect(new Set(DOSSIER_RESEARCH_CHECKLIST_IDS).size).toBe(DOSSIER_RESEARCH_CHECKLIST_IDS.length) + }) + + it('keeps fully sourced items and defaults the sphere to município', () => { + const research = normalizeDossierResearchInput(eraResearch('C'), { now }) + expect(research.items).toHaveLength(dossierChecklistForEra('C').length) + expect(research.gaps).toEqual([]) + expect(research.items.every((item) => item.sphere === 'municipio')).toBe(true) + }) + + it('throws when the slug, the date or the era is missing', () => { + expect(() => + normalizeDossierResearchInput(eraResearch('A', { municipalitySlug: '' }), { now }), + ).toThrow(/municipalitySlug/) + expect(() => + normalizeDossierResearchInput(eraResearch('A', { researchedAt: null }), { now }), + ).toThrow(/researchedAt/) + expect(() => normalizeDossierResearchInput(eraResearch('A', { era: 'Z' }), { now })).toThrow( + /era/, + ) + }) + + it('degrades an unsourced item to an explicit gap', () => { + const items = dossierChecklistForEra('A').map((item) => validItem(item.id)) + items[0] = validItem(items[0].id, { sourceUrl: null }) + const research = normalizeDossierResearchInput(eraResearch('A', { items }), { now }) + expect(research.gaps).toContainEqual( + expect.objectContaining({ id: items[0].id, reason: expect.stringMatching(/Sem fonte/) }), + ) + expect(research.items).toHaveLength(dossierChecklistForEra('A').length - 1) + }) + + it('rejects an item that belongs to another era', () => { + const research = normalizeDossierResearchInput( + eraResearch('A', { items: [...eraResearch('A').items, validItem('era_c_emendas')] }), + { now }, + ) + expect(research.gaps).toContainEqual( + expect.objectContaining({ id: 'era_c_emendas', reason: expect.stringMatching(/Era C/) }), + ) + }) + + it('marks unreached checklist items as gaps', () => { + const research = normalizeDossierResearchInput(eraResearch('B', { items: [] }), { now }) + expect(research.gaps.filter((gap) => gap.reason === 'Não pesquisado.')).toHaveLength( + dossierChecklistForEra('B').length, + ) + }) + + it('preserves numbers, defaulting an unknown phase to não informada', () => { + const items = dossierChecklistForEra('C').map((item) => validItem(item.id)) + items[0] = validItem(items[0].id, { + numbers: [ + { label: 'Recurso', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }, + { label: 'Sem fase', value: 'R$ 10 mil' }, + ], + }) + const research = normalizeDossierResearchInput(eraResearch('C', { items }), { now }) + const item = research.items.find((row) => row.id === items[0].id) + expect(item?.numbers).toHaveLength(2) + expect(item?.numbers[0]).toMatchObject({ label: 'Recurso', phase: 'empenhado' }) + expect(item?.numbers[1].phase).toBe('nao_informado') + }) + + it('flags an invalid sphere as a gap instead of silently summing it', () => { + const items = dossierChecklistForEra('A').map((item) => validItem(item.id)) + items[0] = validItem(items[0].id, { sphere: 'estado' }) + const research = normalizeDossierResearchInput(eraResearch('A', { items }), { now }) + expect(research.gaps).toContainEqual( + expect.objectContaining({ + id: items[0].id, + reason: expect.stringMatching(/Esfera inválida/), + }), + ) + }) + + it('emits the short receipt with the era file path', () => { + const receipt = dossierResearchReceipt(normalizeDossierResearchInput(eraResearch('B'), { now })) + expect(receipt).toMatchObject({ slug: 'ilheus', era: 'B', status: 'ok' }) + expect(receipt.researchPath).toContain('ilheus.b.research.json') + }) +}) + +describe('mergeDossierResearch', () => { + it('merges the three eras ordered A→B→C', () => { + const merged = mergeDossierResearch( + ['A', 'B', 'C'].map((era) => + normalizeDossierResearchInput(eraResearch(era as 'A' | 'B' | 'C'), { now }), + ), + ) + expect(merged.eras).toEqual(['A', 'B', 'C']) + expect(merged.items[0].era).toBe('A') + expect(merged.items.at(-1).era).toBe('C') + }) + + it('fails closed when the slugs disagree', () => { + const a = normalizeDossierResearchInput(eraResearch('A'), { now }) + const b = normalizeDossierResearchInput(eraResearch('B', { municipalitySlug: 'una' }), { now }) + expect(() => mergeDossierResearch([a, b])).toThrow(/municípios diferentes/) + }) + + it('rejects an empty bundle', () => { + expect(() => mergeDossierResearch([])).toThrow(/sem pesquisa/) + }) +}) diff --git a/tests/unit/opencodeCommands.unit.spec.ts b/tests/unit/opencodeCommands.unit.spec.ts index 6dbd1075..b32454f1 100644 --- a/tests/unit/opencodeCommands.unit.spec.ts +++ b/tests/unit/opencodeCommands.unit.spec.ts @@ -16,6 +16,7 @@ const commands = [ 'testing-audit', 'bug-fix', 'relatorio-cidade', + 'dossie-solla-cidade', ] as const describe('opencode commands couple to their skill by exact name', () => {