feat(C186): dossiê Solla por cidade + boletim modelo — skill + builder (Closes #1132)

This commit is contained in:
Francisco Solla 2026-09-17 16:09:31 -03:00
parent b9de38212c
commit c1b22d1b29
29 changed files with 3818 additions and 47 deletions

View 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
View file

@ -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

View 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".

View 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

View 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.

View 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.**

View 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)

View file

@ -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) {

View file

@ -6,44 +6,12 @@
*/
import { formatDateBr, formatDateTimeBr, formatInteger } from './cityReportFormat.mjs'
const htmlEscape = (value) =>
String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
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',

View file

@ -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,

View 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

View 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,
}
}

View 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>`

View 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,
}
}
}

View 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.'

View 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,
})
}
}

View 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`
}

View 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,
}
}

View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
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})`
})

View file

@ -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',

View 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=')
})
})

View 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)
}
})
})

View 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)
})
})

View 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' },
])
})
})

View 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')
})
})

View 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/)
})
})

View 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('&lt;script&gt;')
})
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')
})
})

View 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/)
})
})

View file

@ -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', () => {