mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
feat(C186): dossiê Solla por cidade + boletim modelo — skill + builder (Closes #1132)
This commit is contained in:
parent
b9de38212c
commit
c1b22d1b29
29 changed files with 3818 additions and 47 deletions
258
.agents/skills/dossie-solla-cidade/SKILL.md
Normal file
258
.agents/skills/dossie-solla-cidade/SKILL.md
Normal file
|
|
@ -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 <cidade>" e/ou o "boletim modelo de <cidade>"
|
||||
— 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/<slug>.<era>.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/<slug>.snapshot.json \
|
||||
--research-dir=data/dossie-solla-cidade \
|
||||
--out-dir=docs/research/dossie-solla-cidade
|
||||
```
|
||||
Saídas: `docs/research/dossie-solla-cidade/<slug>-<YYYY-MM-DD>-dossie.pdf` +
|
||||
`-dossie.md` + `-boletim.pdf`. Intermediários gitignored em
|
||||
`data/dossie-solla-cidade/` (HTML, JSONs, emendas, logs). Flags úteis:
|
||||
`--emendas=<json>` (replay sem rede), `--author=<nome>` (default
|
||||
`JORGE SOLLA`), `--generated-at=<ISO>`, `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" <cidade>` + `<tema>`; 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
|
||||
|
||||
`<slug>.<era>.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 <cidade>"; **≤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.
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
41
.opencode/agent/dossie-solla-cidade.md
Normal file
41
.opencode/agent/dossie-solla-cidade.md
Normal file
|
|
@ -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/<slug>.<era>.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".
|
||||
9
.opencode/commands/dossie-solla-cidade.md
Normal file
9
.opencode/commands/dossie-solla-cidade.md
Normal file
|
|
@ -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 <Cidade> [<Cidade>, ...]` — ex.: `/dossie-solla-cidade Ilhéus, Itacaré, Una`. Uma cidade = dossiê (`<slug>-<YYYY-MM-DD>-dossie.pdf`/`.md`) + boletim (`-boletim.pdf`).
|
||||
|
||||
$ARGUMENTS
|
||||
1
docs/changelog/2026-09-17-c186.md
Normal file
1
docs/changelog/2026-09-17-c186.md
Normal file
|
|
@ -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.
|
||||
183
docs/plans/dossie-solla-cidade-impl.md
Normal file
183
docs/plans/dossie-solla-cidade-impl.md
Normal file
|
|
@ -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 <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, `<dl>` 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 <cidade>" + 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 <cidade,...>"] --> B["orquestrador: resolve slug<br/>isMunicipalitySlug / resolveMunicipalityName"]
|
||||
B --> C["N researchers por era<br/>(A|B|C × cidade, paralelo, teto 6)"]
|
||||
C --> R["<slug>.<era>.research.json<br/>+ recibo curto (só status/paths/gaps)"]
|
||||
B --> E["extração read-only, 1 cidade por vez<br/>extract-city-report-snapshot.mjs<br/>composeCityReportSnapshot"]
|
||||
E --> S["<slug>.snapshot.json"]
|
||||
R --> G["build-dossie-solla-cidade.mjs"]
|
||||
S --> G
|
||||
H["APIs públicas em tempo de build<br/>Câmara · emendas 2015–2026 · IBGE/DATASUS/SIOPS"] --> G
|
||||
G --> I["dossieBlocks.mjs: capa+resumo+eras+região+fontes<br/>→ 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/<slug>-<YYYY-MM-DD>.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/<slug>.<era>.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ê `<slug>.{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.**
|
||||
266
scripts/build-dossie-solla-cidade.mjs
Normal file
266
scripts/build-dossie-solla-cidade.mjs
Normal file
|
|
@ -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=<json>`). 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/<slug>.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=<json> [--research-dir=<dir>] [--emendas=<json>] [--out-dir=<dir>] [--author=<nome>]',
|
||||
)
|
||||
}
|
||||
if (Number.isNaN(generatedAt.getTime())) die(`--generated-at inválida: ${flags['generated-at']}`)
|
||||
|
||||
const readJson = async (relativePath, label) => {
|
||||
try {
|
||||
return JSON.parse(await readFile(resolve(ROOT, relativePath), 'utf8'))
|
||||
} catch (error) {
|
||||
die(
|
||||
`Falha ao ler ${label} (${relativePath}): ${error instanceof Error ? error.message : error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = await readJson(snapshotPath, 'snapshot')
|
||||
const 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)
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) => `<a href="${url}">${url}</a>`)
|
||||
|
||||
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 ` <a class="inline-source" href="${htmlEscape(url)}">${label}</a>`
|
||||
})
|
||||
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
414
scripts/lib/dossieBlocks.mjs
Normal file
414
scripts/lib/dossieBlocks.mjs
Normal file
|
|
@ -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
|
||||
71
scripts/lib/dossieBulletin.mjs
Normal file
71
scripts/lib/dossieBulletin.mjs
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
194
scripts/lib/dossieBulletinRender.mjs
Normal file
194
scripts/lib/dossieBulletinRender.mjs
Normal file
|
|
@ -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) =>
|
||||
`<div class="asset-box"><p class="asset-title">${htmlEscape(title)}</p><p>${lines
|
||||
.map((line) => htmlEscape(line))
|
||||
.join('<br />')}</p></div>`
|
||||
|
||||
const renderHighlight = (highlight) => `
|
||||
<article class="highlight-card">
|
||||
<p class="highlight-eyebrow">${htmlEscape(highlight.eyebrow)}</p>
|
||||
${highlight.number ? `<p class="highlight-number tabular">${htmlEscape(highlight.number)}</p>` : ''}
|
||||
<h3 class="highlight-title">${htmlEscape(highlight.title)}</h3>
|
||||
${highlight.note ? `<p class="highlight-note">${htmlEscape(highlight.note)}</p>` : ''}
|
||||
</article>`
|
||||
|
||||
const renderMoreItem = (item) =>
|
||||
`<p class="more-item"><strong>${htmlEscape(item.label)}:</strong> ${htmlEscape(item.detail)}</p>`
|
||||
|
||||
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) => `
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.municipality)}</title><style>${PRINT_CSS}</style></head>
|
||||
<body>
|
||||
<article class="sheet" data-page="boletim" aria-label="Modelo de boletim informativo de uma página">
|
||||
<header class="bulletin-head">
|
||||
<div>
|
||||
<p class="kicker">${htmlEscape(bulletin.meta.kicker)}</p>
|
||||
<p class="place">Município de ${htmlEscape(bulletin.meta.municipality)}${bulletin.meta.region ? ` · ${htmlEscape(bulletin.meta.region)}` : ' · Bahia'}</p>
|
||||
</div>
|
||||
<div class="right">
|
||||
<p class="note">Síntese do dossiê da cidade — conferir antes de publicar.</p>
|
||||
<span class="model-label">${htmlEscape(bulletin.meta.modelLabel)}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="opening" aria-label="Abertura do boletim">
|
||||
<div>
|
||||
<p class="section-label">Trabalho que chega à cidade e à região</p>
|
||||
<h1>O que Jorge Solla fez por <span class="accent">${htmlEscape(bulletin.meta.municipality)}</span></h1>
|
||||
<p class="lead">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.</p>
|
||||
</div>
|
||||
${ASSET('NEEDS ASSET · foto real', ['Solla na cidade ou no serviço citado', 'origem, autoria e licença'])}
|
||||
</section>
|
||||
|
||||
<section class="highlights" aria-label="Destaques">
|
||||
<div class="highlights-head">
|
||||
<div>
|
||||
<p class="section-label">Destaques</p>
|
||||
<h2>Entregas que fazem diferença</h2>
|
||||
</div>
|
||||
<p class="side">cidade ≠ região · cada alcance vem identificado</p>
|
||||
</div>
|
||||
${
|
||||
bulletin.highlights.length
|
||||
? `<div class="highlight-grid">${bulletin.highlights.map(renderHighlight).join('')}</div>`
|
||||
: '<p class="empty">Sem fato com fonte suficiente para destacar — ver lacunas no dossiê.</p>'
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="bulletin-timeline" aria-label="Trajetória">
|
||||
<div class="timeline-head">
|
||||
<h2>Desde 1999, uma trajetória na saúde pública</h2>
|
||||
<p class="timeline-side">experiência que virou trabalho pela Bahia</p>
|
||||
</div>
|
||||
<div class="timeline-grid">
|
||||
${bulletin.timeline
|
||||
.map(
|
||||
(step) => `<div class="timeline-step">
|
||||
<p class="timeline-period">${htmlEscape(step.period)}</p>
|
||||
<p class="timeline-text">${htmlEscape(step.text)}</p>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="more" aria-label="Outras ações">
|
||||
<div>
|
||||
<div class="more-head">
|
||||
<div>
|
||||
<p class="section-label">E mais</p>
|
||||
<h2>Outras ações que entram no boletim</h2>
|
||||
</div>
|
||||
<p class="side">frases curtas · fatos confirmados</p>
|
||||
</div>
|
||||
${
|
||||
bulletin.moreItems.length
|
||||
? `<div class="more-grid">${bulletin.moreItems.map(renderMoreItem).join('')}</div>`
|
||||
: '<div class="empty-box"><p><strong class="uppercase">Sem itens adicionais com fonte além dos destaques.</strong></p><p>Conferir as lacunas explícitas no dossiê da cidade antes de ampliar o boletim.</p></div>'
|
||||
}
|
||||
</div>
|
||||
<aside class="more-aside" aria-label="Ativos visuais pendentes">
|
||||
${ASSET('NEEDS ASSET · selo', ['marca institucional aprovada', 'arquivo vetorial'])}
|
||||
${ASSET('NEEDS ASSET · recorte', ['imagem real de cobertura', 'origem e licença'])}
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<footer class="bulletin-foot">
|
||||
<div class="row">
|
||||
<div>
|
||||
<p class="orig">Fatos selecionados do dossiê da cidade.</p>
|
||||
<p class="defeso"><strong>Defeso eleitoral:</strong> material informativo em preparação. Não publicar sem revisão editorial e jurídica.</p>
|
||||
</div>
|
||||
<p class="stamp">MODELO · INSUMO INTERNO<br />A4 · página 1/1<br />${htmlEscape(bulletin.meta.generatedAtLabel)}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</body>
|
||||
</html>`
|
||||
146
scripts/lib/dossieCamara.mjs
Normal file
146
scripts/lib/dossieCamara.mjs
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
151
scripts/lib/dossieCareer.mjs
Normal file
151
scripts/lib/dossieCareer.mjs
Normal file
|
|
@ -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.'
|
||||
94
scripts/lib/dossieHealthData.mjs
Normal file
94
scripts/lib/dossieHealthData.mjs
Normal file
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
708
scripts/lib/dossieRender.mjs
Normal file
708
scripts/lib/dossieRender.mjs
Normal file
|
|
@ -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 =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'
|
||||
|
||||
const WARNING_ICON =
|
||||
'<svg class="warn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10.3 2.9 1.8 17a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 2.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/></svg>'
|
||||
|
||||
const sourceLink = (url, label = '(fonte)') =>
|
||||
url
|
||||
? `<a class="source-link" href="${htmlEscape(url)}" aria-label="Abrir fonte">${SOURCE_ICON}${htmlEscape(label)}</a>`
|
||||
: ''
|
||||
|
||||
const scopeBadge = (sphere, label = null) =>
|
||||
`<span class="scope${sphere === 'municipio' ? '' : ' scope-region'}">${htmlEscape(label ?? dossierSphereLabel(sphere))}</span>`
|
||||
|
||||
/** `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) => `<span class="phase">${htmlEscape(dossierPhaseLabel(phase))}</span>`
|
||||
|
||||
const placeholder = (label) =>
|
||||
`<span class="placeholder-bar" aria-label="${htmlEscape(label)}"></span>`
|
||||
|
||||
const valueCell = (value, widthLabel = 'Valor desconhecido') =>
|
||||
value === null || value === undefined || value === ''
|
||||
? placeholder(widthLabel)
|
||||
: `<strong>${htmlEscape(value)}</strong>`
|
||||
|
||||
const assetBox = (title, lines) =>
|
||||
`<div class="asset-box"><p class="asset-title">${htmlEscape(title)}</p><p>${lines
|
||||
.map((line) => htmlEscape(line))
|
||||
.join('<br />')}</p></div>`
|
||||
|
||||
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) => `
|
||||
<div class="timeline-grid">
|
||||
${rows
|
||||
.map(
|
||||
(row) => `<div class="timeline-cell">
|
||||
<p class="timeline-period">${htmlEscape(row.period)}</p>
|
||||
<p class="timeline-role">${htmlEscape(row.role)}</p>
|
||||
<p class="timeline-recovery">${htmlEscape(row.recovery)}${row.uncertain ? ` · ${htmlEscape(row.uncertain)}` : ''}</p>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>`
|
||||
|
||||
const sheetHeader = (report, title, subtitle, pageNo) => `
|
||||
<header class="report-header">
|
||||
<div>
|
||||
<p class="report-kicker">Dossiê de atuação pública · ${htmlEscape(report.meta.municipalityName)}</p>
|
||||
<h2 class="sheet-title">${htmlEscape(title)}</h2>
|
||||
${subtitle ? `<p class="sheet-sub">${htmlEscape(subtitle)}</p>` : ''}
|
||||
</div>
|
||||
<div class="report-meta">
|
||||
<p><strong>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</strong></p>
|
||||
<p>INSUMO INTERNO</p>
|
||||
<p>pág. ${pageNo}/${pageCount(report)}</p>
|
||||
</div>
|
||||
</header>`
|
||||
|
||||
const sheetFooter = (report, left, pageNo) => `
|
||||
<footer class="report-footer">
|
||||
<span>Dossiê Solla por cidade · ${htmlEscape(report.meta.municipalityName)} · ${htmlEscape(left)}</span>
|
||||
<span>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))} · pág. ${pageNo}/${pageCount(report)}</span>
|
||||
</footer>`
|
||||
|
||||
const renderCover = (report) => `
|
||||
<article class="sheet sheet-cover" data-page="capa" aria-label="Capa do dossiê">
|
||||
<div class="accent-bar"></div>
|
||||
<header class="cover-head">
|
||||
<div>
|
||||
<p class="report-kicker">${htmlEscape(report.cover.kicker)}</p>
|
||||
<p class="cover-series">${htmlEscape(report.cover.series)}</p>
|
||||
</div>
|
||||
<div class="internal-note"><strong>${htmlEscape(report.cover.internalNote)}</strong><br />${htmlEscape(report.cover.internalNoteSub)}</div>
|
||||
</header>
|
||||
|
||||
<div class="cover-block">
|
||||
<p class="eyebrow">${htmlEscape(report.cover.eyebrow)}</p>
|
||||
<h1 class="cover-title">${htmlEscape(report.cover.title)}</h1>
|
||||
<p class="cover-sub">${htmlEscape(report.cover.subtitle)}</p>
|
||||
</div>
|
||||
|
||||
<dl class="cover-dl">
|
||||
<dt>Território de Identidade</dt><dd>${htmlEscape(report.cover.territory)}</dd>
|
||||
<dt>Região / polo adotado</dt><dd>${htmlEscape(report.cover.pole)}</dd>
|
||||
<dt>Data de geração</dt><dd class="tabular">${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</dd>
|
||||
</dl>
|
||||
|
||||
<div class="cover-grid">
|
||||
<div class="how-to"><p class="how-to-title">Como usar</p><p>${htmlEscape(report.cover.howToUse)}</p></div>
|
||||
${assetBox('NEEDS ASSET', ['selo institucional autorizado', 'opcional — nunca marca de campanha'])}
|
||||
</div>
|
||||
|
||||
<div class="cover-foot">
|
||||
<div class="ink-rule"></div>
|
||||
<div class="cover-foot-row">
|
||||
<p class="cover-scope">${htmlEscape(report.cover.scope)}</p>
|
||||
<p class="cover-version">${htmlEscape(report.cover.version)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>`
|
||||
|
||||
const renderTimeline = (report) => `
|
||||
<section class="block">
|
||||
<div class="block-head">
|
||||
<div><p class="eyebrow">01 · trajetória</p><h3 class="section-title">Linha do tempo da carreira</h3></div>
|
||||
<p class="meta">Período + papel + trilha de recuperação</p>
|
||||
</div>
|
||||
${timelineGrid(report.page1.timeline)}
|
||||
</section>`
|
||||
|
||||
const renderTrajectory = (report, pageNo) => `
|
||||
<article class="sheet" data-page="trajetoria" aria-label="Trajetória completa">
|
||||
${sheetHeader(report, 'Trajetória completa', 'Todos os períodos da carreira e como cada um foi recuperado', pageNo)}
|
||||
${timelineGrid(report.trajectory)}
|
||||
<p class="meta">Períodos incertos ficam marcados na própria linha; o acervo interno de falas cobre 2011+ e o que falta vira lacuna explícita.</p>
|
||||
${sheetFooter(report, 'Trajetória', pageNo)}
|
||||
</article>`
|
||||
|
||||
const renderDeliveries = (report) => {
|
||||
if (report.page1.deliveries.length === 0) return ''
|
||||
return `
|
||||
<section class="block">
|
||||
<div class="block-head">
|
||||
<div><p class="eyebrow">02 · destaques com lastro</p><h3 class="section-title">Principais entregas localizadas</h3></div>
|
||||
</div>
|
||||
<div class="deliveries">
|
||||
${report.page1.deliveries
|
||||
.map(
|
||||
(row) => `<div class="delivery-row">
|
||||
<div class="delivery-scope">${scopeBadge(row.sphere)}<p class="meta">Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}</p></div>
|
||||
<div>
|
||||
<p class="delivery-title">${cleanText(row.title)}</p>
|
||||
${row.detail ? `<p class="muted small">${cleanText(row.detail)}</p>` : ''}
|
||||
</div>
|
||||
<div class="delivery-value">
|
||||
<p class="value-big tabular">${valueCell(row.value, 'Valor não informado')}</p>
|
||||
${row.phase ? phaseBadge(row.phase) : ''}
|
||||
${sourceLink(row.sourceUrl)}
|
||||
</div>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
<p class="muted small strong">Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor.</p>
|
||||
</section>`
|
||||
}
|
||||
|
||||
const renderHooksAndPending = (report) => {
|
||||
const hooks = report.page1.hooks
|
||||
const pending = report.page1.pending
|
||||
if (hooks.length === 0 && pending.length === 0) return ''
|
||||
return `
|
||||
<section class="page1-grid">
|
||||
${
|
||||
hooks.length
|
||||
? `<div class="panel">
|
||||
<p class="eyebrow">03 · ganchos para o boletim</p>
|
||||
<ul class="tight-list">${hooks
|
||||
.map(
|
||||
(hook) =>
|
||||
`<li><strong>Tema:</strong> ${htmlEscape(hook.topic)}. <strong>Ângulo:</strong> ${cleanText(hook.angle)} ${sourceLink(hook.sourceUrl)}</li>`,
|
||||
)
|
||||
.join('')}</ul>
|
||||
</div>`
|
||||
: '<div></div>'
|
||||
}
|
||||
${
|
||||
pending.length
|
||||
? `<div class="panel panel-warning">
|
||||
<p class="eyebrow warning-text">04 · o que falta</p>
|
||||
<ul class="tight-list">${pending.map((item) => `<li>${htmlEscape(item)}</li>`).join('')}</ul>
|
||||
<p class="warning-text small strong">Ausência é resultado: não completar por inferência.</p>
|
||||
</div>`
|
||||
: '<div></div>'
|
||||
}
|
||||
</section>`
|
||||
}
|
||||
|
||||
const renderSummary = (report, pageNo) => `
|
||||
<article class="sheet" data-page="resumo" aria-label="Resumo de uma olhada">
|
||||
${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)}
|
||||
</article>`
|
||||
|
||||
const renderEra = (report, era, pageNo) => `
|
||||
<article class="sheet" data-page="era-${era.id.toLowerCase()}" aria-label="${htmlEscape(era.label)}">
|
||||
${sheetHeader(report, era.label, `${era.period} · ${era.subtitle}`, pageNo)}
|
||||
|
||||
<div class="era-method">
|
||||
<div>
|
||||
<p class="eyebrow">Recorte e método</p>
|
||||
<p class="muted">${htmlEscape(era.method)}</p>
|
||||
</div>
|
||||
<aside class="aside-accent"><strong>Trilha de recuperação</strong><br />${htmlEscape(era.recovery)}</aside>
|
||||
</div>
|
||||
|
||||
${
|
||||
era.numbers.length
|
||||
? `<section class="block">
|
||||
<div class="block-head">
|
||||
<div><p class="eyebrow">Números com fase</p><h3 class="section-title">Recursos e entregas quantificáveis</h3></div>
|
||||
</div>
|
||||
<table class="document-table">
|
||||
<caption class="sr-only">Valores por ano, fase, esfera e fonte</caption>
|
||||
<colgroup><col style="width:26%" /><col style="width:15%" /><col style="width:10%" /><col style="width:17%" /><col style="width:12%" /><col style="width:20%" /></colgroup>
|
||||
<thead><tr><th>Objeto</th><th>Valor</th><th>Ano</th><th>Fase</th><th>Esfera</th><th>Fonte</th></tr></thead>
|
||||
<tbody>${era.numbers
|
||||
.map(
|
||||
(row) => `<tr>
|
||||
<td>${htmlEscape(row.object)}</td>
|
||||
<td class="numeric">${valueCell(row.value, 'Valor a recuperar')}</td>
|
||||
<td class="numeric">${htmlEscape(row.year ?? '—')}</td>
|
||||
<td>${phaseBadge(row.phase)}</td>
|
||||
<td>${scopeBadge(row.sphere)}</td>
|
||||
<td>${sourceLink(row.sourceUrl)}${row.sphere !== 'municipio' ? '<br /><span class="meta">não somar à cidade</span>' : ''}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}</tbody>
|
||||
</table>
|
||||
<p class="warning-text small strong">Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.</p>
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
era.actions.length
|
||||
? `<section class="block">
|
||||
<p class="eyebrow">Atuação registrada</p>
|
||||
<h3 class="section-title">O que fez — item, alcance e lastro</h3>
|
||||
<div class="action-grid">
|
||||
${era.actions
|
||||
.map(
|
||||
(action) => `<article class="panel">
|
||||
<div class="action-head">${scopeBadge(action.sphere)}<span class="meta">${htmlEscape(action.year ?? '—')}</span></div>
|
||||
<h4 class="action-title">${cleanText(action.title)}</h4>
|
||||
${action.detail ? `<p class="muted small">${cleanText(action.detail)}</p>` : ''}
|
||||
${sourceLink(action.sourceUrl)}
|
||||
</article>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
era.honors.length
|
||||
? `<section class="block honors">
|
||||
<div class="panel">
|
||||
<p class="eyebrow">Títulos, honrarias e vínculos locais</p>
|
||||
<ul class="tight-list">${era.honors
|
||||
.map((honor) => `<li>${cleanText(honor.text)} ${sourceLink(honor.sourceUrl)}</li>`)
|
||||
.join('')}</ul>
|
||||
</div>
|
||||
${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])}
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${sheetFooter(report, era.label, pageNo)}
|
||||
</article>`
|
||||
|
||||
const renderScopeLists = (report) => `
|
||||
<section class="block">
|
||||
<p class="eyebrow">Leitura lado a lado</p>
|
||||
<h3 class="section-title">Duas listas, dois totais, nenhuma soma</h3>
|
||||
<div class="scope-compare">
|
||||
<div class="panel">
|
||||
<div class="action-head">${scopeBadge('municipio')}<span class="meta strong">${report.region.municipal.total} ${report.region.municipal.total === 1 ? 'item' : 'itens'} com fonte</span></div>
|
||||
<ul class="tight-list">${report.region.municipal.items
|
||||
.map((item) => `<li>${cleanText(item.answer)} ${sourceLink(item.sourceUrl)}</li>`)
|
||||
.join(
|
||||
'',
|
||||
)}${report.region.municipal.items.length === 0 ? '<li class="muted">Nenhum item municipal com fonte.</li>' : ''}</ul>
|
||||
</div>
|
||||
<div class="compare-mid" aria-hidden="true"><span>≠</span></div>
|
||||
<div class="panel panel-region">
|
||||
<div class="action-head">${scopeBadge('regiao', 'região / polo')}<span class="warning-text meta strong">SEM TOTAL COMBINADO · ${report.region.regional.total} ${report.region.regional.total === 1 ? 'item' : 'itens'}</span></div>
|
||||
<ul class="tight-list">${report.region.regional.items
|
||||
.map((item) => `<li>${cleanText(item.answer)} ${sourceLink(item.sourceUrl)}</li>`)
|
||||
.join(
|
||||
'',
|
||||
)}${report.region.regional.items.length === 0 ? '<li class="muted">Nenhum item regional com fonte.</li>' : ''}</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>`
|
||||
|
||||
const renderRegion = (report, pageNo) => `
|
||||
<article class="sheet" data-page="regiao" aria-label="Recorte regional e polo">
|
||||
${sheetHeader(report, 'Região / polo', 'Impacto compartilhado, apresentado fora da conta municipal', pageNo)}
|
||||
|
||||
<section class="rule-warning">
|
||||
${WARNING_ICON}
|
||||
<div>
|
||||
<p class="rule-title">${htmlEscape(report.region.ruleTitle)}</p>
|
||||
<p class="muted">${htmlEscape(report.region.ruleBody)}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${renderScopeLists(report)}
|
||||
|
||||
${
|
||||
report.region.items.length
|
||||
? `<section class="block">
|
||||
<div class="block-head"><div><p class="eyebrow">Itens regionais recuperados</p><h3 class="section-title">Evidência de alcance, sem apropriação local</h3></div>${scopeBadge('regiao')}</div>
|
||||
<table class="document-table">
|
||||
<caption class="sr-only">Itens do recorte regional com evidência e fonte</caption>
|
||||
<colgroup><col style="width:30%" /><col style="width:15%" /><col style="width:34%" /><col style="width:21%" /></colgroup>
|
||||
<thead><tr><th>Item</th><th>Esfera</th><th>Evidência de alcance</th><th>Fonte</th></tr></thead>
|
||||
<tbody>${report.region.items
|
||||
.map(
|
||||
(item) => `<tr>
|
||||
<td>${cleanText(item.item)}</td>
|
||||
<td>${scopeBadge(item.sphere)}</td>
|
||||
<td>${cleanText(item.evidence)}</td>
|
||||
<td>${sourceLink(item.sourceUrl)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}</tbody>
|
||||
</table>
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
report.region.hook || report.region.priorityGap
|
||||
? `<section class="page1-grid">
|
||||
${
|
||||
report.region.hook
|
||||
? `<div class="aside-accent"><p class="eyebrow">Gancho possível</p><p><strong>Tema:</strong> ${htmlEscape(report.region.hook.topic)}. <strong>Ângulo:</strong> ${cleanText(report.region.hook.angle)} ${sourceLink(report.region.hook.sourceUrl)}</p></div>`
|
||||
: '<div></div>'
|
||||
}
|
||||
${
|
||||
report.region.priorityGap
|
||||
? `<div class="panel panel-warning"><p class="eyebrow warning-text">Lacuna prioritária</p><p>${htmlEscape(report.region.priorityGap)}</p></div>`
|
||||
: '<div></div>'
|
||||
}
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${sheetFooter(report, 'Região/polo · recortes não somáveis', pageNo)}
|
||||
</article>`
|
||||
|
||||
const renderGapsPage = (report, chunk, index, pageNo) => {
|
||||
const rows = chunk.length
|
||||
? chunk
|
||||
.map(
|
||||
(gap) => `<tr>
|
||||
<td><strong>${htmlEscape(gap.label)}</strong></td>
|
||||
<td>${htmlEscape(gap.reason)}</td>
|
||||
<td>Não completar por inferência.</td>
|
||||
<td>${htmlEscape(gap.nextStep)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')
|
||||
: '<tr><td colspan="4" class="muted">Nenhuma lacuna declarada — confira a cobertura das eras.</td></tr>'
|
||||
return `
|
||||
<article class="sheet" data-page="lacunas${index > 0 ? `-${index + 1}` : ''}" aria-label="Lacunas explícitas">
|
||||
${sheetHeader(report, 'Lacunas explícitas', `O que não foi encontrado${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)}
|
||||
<section class="block">
|
||||
<div class="block-head"><div><p class="eyebrow">O que não foi encontrado</p><h3 class="section-title">Lacunas explícitas</h3></div><p class="warning-text meta strong">Não completar por memória ou inferência</p></div>
|
||||
<table class="document-table">
|
||||
<caption class="sr-only">Tabela de lacunas explícitas e próximos passos de apuração</caption>
|
||||
<colgroup><col style="width:26%" /><col style="width:30%" /><col style="width:24%" /><col style="width:20%" /></colgroup>
|
||||
<thead><tr><th>Lacuna</th><th>Situação da busca</th><th>Limite editorial</th><th>Próximo passo</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
${sheetFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const renderSourcesPage = (report, pageNo) => `
|
||||
<article class="sheet" data-page="fontes" aria-label="Fontes e limites do dossiê">
|
||||
${sheetHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)}
|
||||
|
||||
${
|
||||
report.news.length
|
||||
? `<section class="block">
|
||||
<p class="eyebrow">Lista clicável</p>
|
||||
<h3 class="section-title">Notícias e documentos consultados</h3>
|
||||
<table class="document-table">
|
||||
<caption class="sr-only">Fontes consultadas com largura fixa: data 12%, veículo 13%, título 47% e link 28%</caption>
|
||||
<colgroup><col style="width:12%" /><col style="width:13%" /><col style="width:47%" /><col style="width:28%" /></colgroup>
|
||||
<thead><tr><th>Data</th><th>Veículo</th><th>Título</th><th>Link</th></tr></thead>
|
||||
<tbody>${report.news
|
||||
.map(
|
||||
(row) => `<tr>
|
||||
<td class="numeric nowrap">${htmlEscape(formatShortDateBr(row.date))}</td>
|
||||
<td>${htmlEscape(row.outlet)}</td>
|
||||
<td>${htmlEscape(row.title)}</td>
|
||||
<td>${sourceLink(row.url, '(fonte)')}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}</tbody>
|
||||
</table>
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
<section class="page1-grid">
|
||||
<div class="panel"><p class="eyebrow">Limites de cobertura</p><ul class="tight-list">${report.limits.coverage
|
||||
.map((item) => `<li>${htmlEscape(item)}</li>`)
|
||||
.join('')}</ul></div>
|
||||
<div class="panel"><p class="eyebrow">Regras para uso editorial</p><ul class="tight-list">${report.limits.editorial
|
||||
.map((item) => `<li>${htmlEscape(item)}</li>`)
|
||||
.join('')}</ul></div>
|
||||
</section>
|
||||
|
||||
<aside class="rule-warning defeso">
|
||||
${WARNING_ICON}
|
||||
<div>
|
||||
<p class="eyebrow warning-text">Nota de defeso eleitoral 2026</p>
|
||||
<h3 class="rule-title">Insumo interno não é autorização para publicar.</h3>
|
||||
<p class="muted">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. Este dossiê organiza evidências; não substitui análise jurídica nem editorial.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
${sheetFooter(report, 'Fontes e limites · INSUMO INTERNO', pageNo)}
|
||||
</article>`
|
||||
|
||||
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 `<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head><meta charset="utf-8"><title>${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.municipalityName)}</title><style>${PRINT_CSS}</style></head>
|
||||
<body>
|
||||
${parts.join('\n')}
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
324
scripts/lib/dossieResearch.mjs
Normal file
324
scripts/lib/dossieResearch.mjs
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
/**
|
||||
* Research contract for the C186 dossiê (owner). The dossiê is researched one
|
||||
* file per era (`<slug>.<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,
|
||||
}
|
||||
}
|
||||
53
scripts/lib/reportText.mjs
Normal file
53
scripts/lib/reportText.mjs
Normal file
|
|
@ -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) => `<a href="${url}">${url}</a>`)
|
||||
|
||||
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 ` <a class="inline-source" href="${htmlEscape(url)}">${label}</a>`
|
||||
})
|
||||
|
||||
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})`
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
91
tests/unit/dossieBatchSkill.unit.spec.ts
Normal file
91
tests/unit/dossieBatchSkill.unit.spec.ts
Normal file
|
|
@ -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=')
|
||||
})
|
||||
})
|
||||
180
tests/unit/dossieBlocks.unit.spec.ts
Normal file
180
tests/unit/dossieBlocks.unit.spec.ts
Normal file
|
|
@ -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<string, unknown> = {}) => ({
|
||||
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<string, unknown> = {}) =>
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
67
tests/unit/dossieBulletin.unit.spec.ts
Normal file
67
tests/unit/dossieBulletin.unit.spec.ts
Normal file
|
|
@ -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<string, unknown> = {}) => ({
|
||||
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)
|
||||
})
|
||||
})
|
||||
108
tests/unit/dossieCamara.unit.spec.ts
Normal file
108
tests/unit/dossieCamara.unit.spec.ts
Normal file
|
|
@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
54
tests/unit/dossieCareer.unit.spec.ts
Normal file
54
tests/unit/dossieCareer.unit.spec.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
56
tests/unit/dossieHealthData.unit.spec.ts
Normal file
56
tests/unit/dossieHealthData.unit.spec.ts
Normal file
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
175
tests/unit/dossieRender.unit.spec.ts
Normal file
175
tests/unit/dossieRender.unit.spec.ts
Normal file
|
|
@ -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 <script>alert(1)</script> 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('<script>alert(1)</script>')
|
||||
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')
|
||||
})
|
||||
})
|
||||
140
tests/unit/dossieResearch.unit.spec.ts
Normal file
140
tests/unit/dossieResearch.unit.spec.ts
Normal file
|
|
@ -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<string, unknown> = {}) => ({
|
||||
id,
|
||||
answer: `Resposta de ${id}`,
|
||||
sourceUrl: `https://exemplo.test/${id}`,
|
||||
sourceDate: '2026-09-10',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const eraResearch = (era: 'A' | 'B' | 'C', overrides: Record<string, unknown> = {}) => ({
|
||||
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/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue