feat(C190): dossiê Solla por tema/área + boletim modelo (skill + builder)

Terceira variante da família de dossiês: recorte pela área canônica do
acervo (SPEECH_TOPICS), com o pipeline C186/C187 generalizado para a forma
subject via THEME_UNIT (generalização aditiva; C186/C187 byte-idênticos).

- resolveSpeechTopic no dono speechFacets (fail-closed, sem slug inventado)
- themeSnapshot + extract-theme-snapshot (THEME_REPORT_CONFIRM=1, read-only)
- build-dossie-solla-tema (pack medido, PDF A4 + .md + boletim 1 página)
- skill/comando/2 subagentes (researcher por era + redator) + specs
- cenas theme-only no descritor (acervo universo/amostra, abrangência,
  lacunas, hierarquia de fontes, pills do boletim) certificadas pelo designer

Closes #1143
This commit is contained in:
Francisco Solla 2026-09-18 16:42:56 -03:00
parent 6ec14823e5
commit e5e1ae7ee8
24 changed files with 2997 additions and 272 deletions

View file

@ -0,0 +1,369 @@
---
name: dossie-solla-tema
description: 'Gera o dossiê Solla por tema/área (PDF A4 + .md, tudo o que Solla fez pela área por era) e o Boletim Informativo modelo de 1 página, a partir da base Teqo read-only + pesquisa web datada + fontes oficiais; aceita uma área da taxonomia do acervo ou um lote separado por vírgula.'
---
# Dossiê Solla por tema/área + boletim modelo (C190)
Entrega, por **área de política** (as 18 áreas canônicas do acervo — Saúde,
Educação, Cultura, Esporte, …), um **par PDF A4 datado + companion `.md`** com
tudo o que **Jorge Solla fez pela área ao longo da carreira** — 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). Skill irmã da `dossie-solla-cidade` (C186) e da
`dossie-solla-instituicao` (C187): mesmo pipeline, recorte por área. O layout
vem dos artefatos hi-fi aprovados pelo `designer`.
O recorte da área é **nativo do acervo**: cada fala já é classificada por tema
(`Speech.topics`, taxonomia `SPEECH_TOPICS`, C153/C154), então aqui o acervo é
evidência de primeira classe — não uma ponte nominal. A taxonomia é **fechada**:
token fora dos 18 canônicos **falha fechado**, nunca vira slug inventado.
O dossiê temático **não usa caps**: cada seção (eras, abrangência, lacunas,
notícias, acervo) flui por quantas páginas precisar, com quebra só entre linhas
— o orquestrador não decide o corte, o builder mede e pagina. Sobre essa base
vêm a **redação de abertura** (carta), a **síntese** dos números e os **gráficos
consolidados**, e cada era abre com um **parágrafo de consolidação** do que ela
entrega.
## Quando usar
- A comunicação pede "o dossiê da <área>" e/ou o "boletim modelo da <área>" — ou
várias numa invocação (`/dossie-solla-tema Educação, Saúde`).
- Quem executa são o **orquestrador** (agente principal) + um sub-agente
**researcher por era, por área**, em paralelo + um sub-agente **redator** +
os scripts (`extract` no homeserver, `build` local). O detalhe está em
"Pipeline (etapas)".
## Lote (várias áreas)
A invocação aceita **uma** área ou **várias**, separadas por **vírgula**:
```text
/dossie-solla-tema Educação # N=1
/dossie-solla-tema Educação, Saúde # lote
```
Parsing (orquestrador, **antes** de qualquer pesquisa):
- separa por `,`; aplica `trim`; descarta vazio;
- cada token é resolvido pela taxonomia canônica do acervo
(`resolveSpeechTopic` sobre `SPEECH_TOPICS`): aceita o **valor** (`educacao`)
ou o **label pt-BR** (`Educação`), fold acento/caixa-insensível;
- **0 entradas** = token desconhecido; o token **falha fechado** listando as 18
áreas canônicas e **nunca** inventa slug (a taxonomia é fechada — não há
escape one-off);
- **dedupe após a resolução**, preservando a ordem;
- token inválido vira **falha isolada** com o motivo no summary final —
**nunca** aborta o lote.
Uma área = um dossiê (PDF+MD) + **um** boletim PDF; **sem** índice/PDF agregado.
Falha de uma área/era **não** cancela as demais (**sucesso parcial** explícito).
## Pipeline (etapas)
1. **Orquestrador (agente principal).** Parseia a lista (seção "Lote"), resolve
os tokens na taxonomia (`resolveSpeechTopic` de `src/lib/speechFacets.ts`) e
dispara **um researcher por era (A/B/C), por área** (Task), em paralelo, com
guarda de concorrência (`MAX_RESEARCHERS_IN_FLIGHT = 6`). Não invente slug:
token fora dos 18 falha fechado. Nunca retém pesquisa web nem o corpo de um
`research.json` — só os recibos.
2. **Researcher (sub-agente, ×3 por área, em paralelo).**
`.opencode/agent/dossie-solla-tema.md` — o **único** passo pesado de
contexto. Faz a pesquisa web datada da sua era e escreve
`data/dossie-solla-tema/<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. **Redação (sub-agente, 1 por área).**
`.opencode/agent/dossie-solla-tema-redacao.md` — depois das eras
pesquisadas, escreve `data/dossie-solla-tema/<slug>.narrative.json` (redação
de abertura + 1 parágrafo por era) **somente** a partir dos itens com fonte;
devolve só o recibo (seção "Recibo do redator"). O orquestrador **audita
citações** (nome, data, valor, órgão) contra os `research.json` e corrige ou
descarta o trecho sem lastro — nunca aceita fato novo. Sem o arquivo, o
builder cai na consolidação determinística dos números.
4. **Extração read-only no homeserver — serializada, 1 área por vez.**
`scripts/extract-theme-snapshot.mjs` (`THEME_REPORT_CONFIRM=1`, sessão
read-only) lê o acervo interno de falas **pelo tema direto**
(`Speech.topics = valor canônico`; o acervo cobre 2011+) e grava
`data/dossie-solla-tema/<slug>.theme.snapshot.json`.
5. **Fontes oficiais em tempo de build.** O Portal da Transparência e a Câmara
**não filtram por área**; emendas/proposições/relatorias por área entram como
**itens de pesquisa** (com fase e fonte). Sem atribuição → lacuna, **nunca
zero silencioso** nem inferência por palavra-chave na ementa. O builder
**não** busca emendas/Câmara/IBGE nem dados de saúde.
6. **Render local (por área):**
```bash
NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-tema.mjs \
--snapshot=data/dossie-solla-tema/<slug>.theme.snapshot.json \
--research-dir=data/dossie-solla-tema \
--out-dir=docs/research/dossie-solla-tema
```
O builder pagina por medição (`scripts/lib/dossiePack.mjs`): render de prova
→ pack → **grow/shrink medido** por folha até estabilizar (nada de página
pela metade, nada cortado), e só então emite o PDF com a guarda de A4.
Saídas: `docs/research/dossie-solla-tema/<slug>-<YYYY-MM-DD>-dossie.pdf`
+ `-dossie.md` + `-boletim.pdf`. Intermediários gitignored em
`data/dossie-solla-tema/` (HTML, JSONs, logs). Flags úteis:
`--generated-at=<ISO>`, `DOSSIER_STRICT=1` (falha o run se houver lacuna —
conferência, não entrega).
7. **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"). As eras são as mesmas da C186 (não criar eras novas):
- **Era A (até 2006)** — formação/residência/pesquisa na área; SESAB;
consultoria no MS; gestão municipal; Secretaria de Atenção à Saúde do MS;
atuação documentada na área (pesquisa, conselho, docência). O acervo interno
de falas cobre 2011+: **pré-2011 é lacuna explícita**, não invenção.
- **Era B (2007–2014)** — gestão da SESAB com a área; políticas/programas
estaduais; investimentos/equipamentos; convênios/termos; articulação regional
e setorial. Fontes: DOE-BA (DOOL), SESAB, Transparência Bahia, SIOPS/DATASUS.
- **Era C (2015–2027)** — pronunciamentos; proposições; relatorias; emendas;
programas federais; audiências; títulos/honrarias. 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" <área>` + `<tema>`; recorte por data
`after:YYYY-MM-DD before:YYYY-MM-DD`; `site:` por fonte oficial/setorial
(camara.leg.br, saude.ba.gov.br, educacao.ba.gov.br, inep.gov.br, ptbahia.org.br,
jorgesolla.com.br); excluir ruído de campanha 2026 (`-eleição -candidato -voto
-coligação -pesquisa`) e preferir verbos de entrega (emenda, recurso, convênio,
programa, projeto, relatoria, audiência, homenagem, repassado).
## Recibo do researcher
O sub-agente devolve **só** este recibo curto (≤ ~15 linhas) — **nunca** o corpo
do `research.json` (`items[].answer/details`):
```jsonc
{
"slug": "educacao",
"era": "C",
"status": "ok", // ou "failed"
"researchPath": "data/dossie-solla-tema/educacao.c.research.json",
"researchedAt": "2026-09-18T10:00:00.000Z",
"itemCount": 5, // itens da era com fonte
"gapCount": 2, // == gaps.length
"newsCount": 3,
"gaps": ["era_c_relatorias", "…"],
"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.
## Recibo do redator
O sub-agente da redação devolve **só** este recibo curto — **nunca** os
parágrafos:
```jsonc
{
"slug": "educacao",
"status": "ok", // ou "failed"
"narrativePath": "data/dossie-solla-tema/educacao.narrative.json",
"paragraphs": 3, // parágrafos de abertura
"eraParagraphs": 3, // eras com parágrafo (A/B/C)
"wordCounts": { "opening": [127, 146, 140], "A": 119, "B": 119, "C": 118 },
"failureReason": "…" // opcional (só quando status = failed)
}
```
O orquestrador **audita** cada citação factual (nome, data, valor, órgão) contra
os `research.json` antes de aceitar o arquivo; trecho sem lastro é corrigido ou
removido. `status: "failed"` não escreve arquivo parcial — o builder segue com a
consolidação determinística.
## Contrato dos JSONs
`<slug>.<era>.research.json` (um arquivo por era; `era` ∈ `A|B|C`):
```jsonc
{
"themeSlug": "educacao", // obrigatório
"era": "C", // obrigatório: A | B | C
"researchedAt": "2026-09-18T10:00:00.000Z", // obrigatório
"items": [
{
"id": "era_c_emendas", // id do checklist da era
"answer": "R$ 5,0 milhões para a rede de ensino",
"details": "opcional; aceita {{fonte}} / {{fonte:N}}",
"sphere": "area", // area | segmento | rede (default area)
"numbers": [ // opcional
{ "label": "Emenda", "value": "R$ 5,0 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-18T09: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`, `era_a_vinculo`.
- **B:** `era_b_sesab`, `era_b_politicas`, `era_b_investimentos`,
`era_b_convenios`, `era_b_articulacao`.
- **C:** `era_c_discursos`, `era_c_proposicoes`, `era_c_relatorias`,
`era_c_emendas`, `era_c_programas`, `era_c_audiencias`, `era_c_titulos`.
A **abrangência** (`sphere`) é `area` | `segmento` | `rede`; `segmento` e `rede`
não são a área e **nunca são somados** a ela (cada linha informa sua
abrangência). Item sem `sourceUrl`/`sourceDate`, item ausente ou item de outra
era vira **lacuna** (`Não pesquisado.` / `Sem fonte: …`); abrangência inválida
vira lacuna. `phase` ∈ `autorizado|empenhado|liquidado|pago|restos` (default
`nao_informado`; empenho **não** é pagamento). O `bulletinFacts` (ledger do
boletim) só é populado por item **com fonte** — o boletim não introduz fato novo.
`<slug>.narrative.json` (opcional; escrito pelo redator, auditado pelo
orquestrador):
```jsonc
{
"themeSlug": "educacao", // obrigatório
"generatedAt": "2026-09-18T00:26:19.000Z", // obrigatório
"title": "O que Jorge Solla fez pela Educação",
"opening": ["parágrafo", "parágrafo", "parágrafo"],
"eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" }
}
```
Sem o arquivo (ou com `themeSlug` diferente do snapshot), o builder **não
falha**: a abertura repete a leitura dos números e cada era usa a consolidação
determinística. `opening` e `eras` só podem conter fatos dos itens com fonte —
nenhum número, data, nome ou órgão novo.
## Conteúdo do dossiê
- **Capa** — série temática, "INSUMO INTERNO — defeso 2026", identificação da
área (label `Educação` + valor canônico `educacao` + nota da taxonomia), data,
"como usar", escopo/versão.
- **A contribuição (carta)** — redação de abertura sobre o que Solla fez pela
área ao longo das eras, em prosa, com a nota "Como ler" (cada afirmação tem
lastro em item datado; a redação não preenche por inferência).
- **Resumo de uma olhada** — identificação (área/token/origem/filtro) + linha do
tempo documentada do vínculo; principais entregas localizadas (badge de
abrangência + valor + badge de fase + fonte); gancho para a agenda; o que
falta. Guarda: **autorizado ≠ empenhado ≠ liquidado ≠ pago**. É a única página
com listas capadas — e o "e mais N" aponta para a seção onde a lista completa
está.
- **Síntese** — leitura dos números com lastro (pontos por era e abrangência,
concentração relativa, itens com valor por fase, recursos por fase, temas,
lacunas) + guardas de leitura; nada que não venha dos itens.
- **Gráficos consolidados** — recursos **com execução por ano** (empilhado por
fase), **propostas/articulações sem fase informada**, abrangência, trajetória
por ano, áreas e lacunas por era, painel do acervo. Valores sempre em R$ com a
fase; recortes nunca somados.
- **Seção por era (A/B/C)** — parágrafo de consolidação ("O que esta era
entrega") + recorte e método + trilha de recuperação; tabela
**Objeto/Valor/Ano/Fase/Abrangência/Fonte**; cards "Papéis e iniciativas com
evidência visível". **Sem cap**: a era flui por quantas folhas precisar
(continuações com cabeçalho "continuação N"). Uma era **sem evidência** vira
**página de lacuna explícita** (nunca seção em branco nem "zero").
- **Abrangência: área × segmento × rede** — painel "Segmento/rede não é a área.
Não some os recortes." com contagem por recorte (**nenhum total combinado**) e
prévia; em seguida **as três listas completas** em tabela
(item/abrangência/evidência/fonte), cada uma fluindo por quantas folhas
precisar; gancho e lacuna prioritária.
- **Lacunas explícitas**, **Notícias e documentos consultados** e **Acervo
interno por tema** — tabelas/listas completas, também correntes (sem cap). O
acervo é **amostra declarada** (as falas mais recentes com link, de N do
recorte `Speech.topics = <valor>`) com painel do total e do universo.
- **Fontes e limites** — limites de cobertura; regras para uso editorial; nota de
defeso eleitoral 2026.
- **Nada some e nada é cortado:** o dossiê temático **não usa caps** — o builder
mede a altura real de cada linha e pagina (grow/shrink) até a página ficar
cheia sem estourar; listas continuam em folhas de continuação com o texto
inteiro. O único "e mais N" é o do resumo e o da amostra do acervo, ambos
apontando para onde o resto está.
## Conteúdo do Boletim modelo (1 página A4)
- Cabeçalho com o nome da área + identity pills + rótulo **"Modelo — insumo
interno"**; faixa de **defeso** ("sem CTA · sem propaganda"); abertura "O que
Jorge Solla fez na área de <área>"; **≤6 destaques** (eyebrow "Área ·
abrangência", 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 controle
editorial e defeso. Com **poucos fatos**, usa a variação de lacuna (não cria
cards vazios para chegar a seis).
- **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.
- **Redação e parágrafos com lastro**: a carta e o parágrafo de cada era só usam
fatos dos itens com fonte; o orquestrador audita citação por citação (nome,
data, valor, órgão) e remove o que não tiver lastro.
- **Empenho ≠ pagamento**: cada valor acompanha sua fase; nunca consolidar.
- **Abrangência explícita** (`área`/`segmento`/`rede`): segmento e rede
**nunca** são somados como se fossem a área.
- **Atribuição temática só com fonte**: emenda/proposição/relatoria por área não
é filtrada pelo Portal/Câmara; sem fonte que a ligue à área → lacuna (nunca
zero silencioso nem match por palavra-chave).
- **Taxonomia fechada**: token fora dos 18 `SPEECH_TOPICS` falha fechado —
nunca slug inventado.
- **Dossiê sem caps**: o conteúdo flui por quantas páginas precisar; a página é
preenchida por medição (grow/shrink), nunca por corte de texto.
- **Leitura relativa**: nunca % estadual absoluto.
- **Lacuna explícita** em vez de inferência; o acervo interno cobre 2011+.
- **PII mínima**: telefone/e-mail nunca entram.
- **Falha isolada** por era/área 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. **Sem schema/migration/DB write.**
## Troubleshooting
- **Folha do dossiê estourou**: o builder mede a altura real de cada linha e
repagina (grow/shrink) até estabilizar; se uma folha continuar estourando, é
porque **uma linha sozinha** não cabe — aperte a copy/caps daquele item, nunca
o layout. Página **não-packed** (carta, resumo, síntese, gráficos, fontes)
estourando = corte copy ou reposicione o card.
- **Página pela metade**: o pack estabilizou? Confira o log (`pack estável`); se
uma seção ficou rala, o problema é o custo medido (bloco novo) — não force
cap, ajuste a copy do item que abre a seção.
- **Era sem pesquisa**: o builder sintetiza o arquivo da era com lacunas
explícitas — e a era sai como página de lacuna, não inventada.
- **Sem redação (`narrative.json`)**: a abertura repete a leitura dos números e
as eras usam a consolidação determinística; não é falha, é degradação.
`themeSlug` diferente do snapshot = erro (regenere).
- **Token desconhecido no lote**: vira linha `failed` no summary com a lista das
18 áreas canônicas; não há escape — a taxonomia é fechada.
- **Slug de pesquisa ≠ snapshot**: o builder recusa o par — regenere, nunca
edite o JSON à mão para casar.
- **Chromium**: vem do `@playwright/test`; se faltar binário,
`pnpm exec playwright install chromium`.
## Referências
- Intenção: `docs/plans/dossie-solla-tema.md`; impl:
`docs/plans/dossie-solla-tema-impl.md`.
- Designs hi-fi (fonte de verdade do port):
`docs/plans/dossie-solla-tema-ui-design.html` e
`docs/plans/dossie-solla-tema-boletim-ui-design.html` — variantes da família
C187; um passe do `designer` os mantém sincronizados com a revisão corrente.
- Skills irmãs: `.agents/skills/dossie-solla-cidade/SKILL.md` (dona do pipeline)
e `.agents/skills/dossie-solla-instituicao/SKILL.md` (segundo recorte).
- Scripts: `scripts/build-dossie-solla-tema.mjs`,
`scripts/extract-theme-snapshot.mjs`, `scripts/themeSnapshot.mjs`,
`scripts/lib/dossiePack.mjs` (packing grow/shrink),
`scripts/lib/buildPdf.mjs` (probe/medição/emit),
`scripts/lib/dossie*.mjs`, `scripts/lib/readOnlyExtract.mjs`.
- Taxonomia/recorte: `src/lib/speechFacets.ts` (`SPEECH_TOPICS`,
`resolveSpeechTopic`), `src/utilities/speech/speechListFilters.ts` (`topics`).
- Runbook de extração read-only: `docs/ops/teqo-1313-deploy.md` §C163.

4
.gitignore vendored
View file

@ -92,6 +92,10 @@ next-env.d.ts
/data/dossie-solla-instituicao/
/docs/research/dossie-solla-instituicao/
# C190 — dossiê Solla por tema/área + boletim modelo: same rule (C186).
/data/dossie-solla-tema/
/docs/research/dossie-solla-tema/
# Local-only sensitive material (agents may read; never commit content)
/private/**
!/private/README.md

View file

@ -0,0 +1,35 @@
---
description: Redator do dossiê Solla por tema/área — escreve a redação de abertura e o parágrafo de consolidação por era (A/B/C) somente a partir dos itens com fonte; devolve só o recibo curto (etapa do lote /dossie-solla-tema)
mode: subagent
---
# Dossiê Solla por tema/área — redator (abertura + parágrafos por era)
Você é a etapa **redação** da skill `.agents/skills/dossie-solla-tema/SKILL.md`
(fonte canônica: contrato do `narrative.json`, regras editoriais e recibo).
Leia-a antes de escrever. Você é invocado **uma vez por área**, depois de as
três eras terem sido pesquisadas, pelo agente principal.
## Papel
1. Ler os três `data/dossie-solla-tema/<slug>.<era>.research.json`
(`items[]` com `answer`/`details`/`brief`/`numbers`/`sphere`/`sourceUrl`/
`sourceDate`; `gaps[]` são lacunas explícitas).
2. Escrever `data/dossie-solla-tema/<slug>.narrative.json` no contrato da skill:
`themeSlug`, `generatedAt`, `title`, `opening` (3 parágrafos, 110–150
palavras) e `eras` (A/B/C, 80–120 palavras cada) — prosa corrida, contando a
contribuição de Solla para a área por era.
3. Devolver **apenas o recibo curto** (seção "Recibo do redator" da skill).
## Limites
- **Só fatos dos itens** (com data e fonte): nenhum número, data, nome, órgão ou
valor novo; nada de memória, inferência ou conhecimento externo. O
orquestrador audita citação por citação e remove o que não tiver lastro.
- `segmento` e `rede` **nunca** são somados à área — diga isso quando citar.
- Valor sempre com a fase informada; **empenho ≠ pagamento**; "emenda de bancada
proposta" não é execução.
- Sem bullets, sem listas, sem reticências, sem CTA de campanha, sem PII; não
use as palavras "dossiê", "insumo" ou "pesquisa" dentro dos parágrafos.
- **Não** rode ssh, extração ou build; **não** edite os `research.json`; **não**
commite nada (artefato gitignored, repo público).

View file

@ -0,0 +1,46 @@
---
description: Researcher por era — pesquisa web datada e escreve o research.json de uma era (A/B/C) do dossiê Solla por tema/área; devolve só o recibo curto (etapa do lote /dossie-solla-tema)
mode: subagent
---
# Dossiê Solla por tema/área — researcher (por era)
Você é a etapa **researcher** da skill `.agents/skills/dossie-solla-tema/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 da área na taxonomia e coordena as demais eras/áreas em paralelo.
## Papel
1. Confirmar o slug canônico da área (taxonomia `SPEECH_TOPICS` de
`src/lib/speechFacets.ts`; ex.: Educação → `educacao`) e a era atribuída.
2. Fazer a **pesquisa web datada** da era e escrever
`data/dossie-solla-tema/<slug>.<era>.research.json` no contrato da skill
(ex.: `educacao.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` (`area`/`segmento`/`rede`); segmento e rede **nunca** são somados à
área.
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-theme-snapshot.mjs` no homeserver,
`scripts/build-dossie-solla-tema.mjs` local). Não faça `ssh`, `scp` nem
render.
- A atribuição de emenda/proposição/relatoria à área **só entra com fonte** que
a ligue ao tema; sem fonte, vira lacuna — nunca zero silencioso.
- A redação de abertura e o parágrafo de consolidação por era são de **outra
etapa** (o redator) — não os escreva; seu produto é só o `research.json` da
sua era.
- **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 tema/área (PDF A4 + .md) e o Boletim Informativo modelo de 1 página para uma área ou um lote separado por vírgula
---
Carregue a skill `dossie-solla-tema` (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 tokens na taxonomia canônica do acervo (`SPEECH_TOPICS`/`resolveSpeechTopic`), dispara um researcher por era (A/B/C) por área, dispara o redator (redação de abertura + parágrafos por era, auditados contra os itens), roda a extração read-only do acervo (por `Speech.topics`) e o build local (dossiê + boletim, folhas correntes sem caps) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-tema/SKILL.md` — não a transcreva nem recrie o fluxo.
Uso: `/dossie-solla-tema <Área> [<Área>, ...]` — ex.: `/dossie-solla-tema Educação, Saúde`. Uma área = dossiê (`<slug>-<YYYY-MM-DD>-dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). Token fora da taxonomia dos 18 temas falha fechado, listando as áreas canônicas; nunca invente slug.
$ARGUMENTS

View file

@ -0,0 +1 @@
**C190 — Dossiê Solla por tema/área + Boletim modelo (2026-09-18):** terceira variante da família de dossiês — o recorte passa a ser a **área de política** do acervo (os 18 `SPEECH_TOPICS`), ao lado de município (C186) e instituição (C187), sem geminar o pipeline. (1) **Seam generalizado**: `scripts/lib/dossieUnit.mjs` ganha `THEME_UNIT` (`area|segmento|rede`, default `area`) e a forma `subject` compartilhada com a instituição; `dossieBlocks`/`dossieRender`/`dossieBulletinRender` passam a ler identidade, esferas, listas de abrangência, método por era, limites e copy do descritor — município e instituição seguem byte-idênticos (specs C186/C187 verdes; `isSubjectUnit` substitui `isInstitutionUnit`). (2) **Resolução canônica no dono**: `resolveSpeechTopic` exportado de `src/lib/speechFacets.ts` resolve valor/label (acento/caixa-insensível) e **falha fechado** — token fora dos 18 nunca vira slug inventado. (3) **Recorte nativo do acervo**: `scripts/themeSnapshot.mjs` + `scripts/extract-theme-snapshot.mjs` (`THEME_REPORT_CONFIRM=1`, sessão read-only) filtram `Speech.topics` direto, sem ponte nominal nem `mentionExcerpt`; `scripts/build-dossie-solla-tema.mjs` pagina por medição (`dossiePack`) e emite PDF A4 + `.md` + boletim de 1 página. (4) **Skill/comando/2 subagentes** (`dossie-solla-tema` researcher por era + redator), checklist próprio por era, lote por vírgula com dedup e falha isolada (`sucesso parcial`), sem escape one-off. (5) **Guardrails**: abrangência nunca somada (`área × segmento × rede`), empenho ≠ pagamento, atribuição de emenda/proposição só com fonte, acervo 2011+, PII mínima, artefatos gitignored, sem schema/migration/DB write. Specs novas: `dossieTheme`, `dossieThemeResearch`, `dossieThemeBatchSkill` (unit) + `themeSnapshot` (int) e `resolveSpeechTopic` no spec do dono; `SCRIPTS_SPEC_PINNED` e `opencodeCommands` atualizados. (6) **Design certificado** pela crítica final do `designer` (`openai/gpt-5.6-sol`) após adaptação theme-only via descritor: pills/identidade canônica no boletim, cards sem métrica com ano ou variante textual, cena do acervo com universo/amostra + alerta de pertinência, tabelas temáticas de abrangência/lacunas/hierarquia de fontes, cobertura das eras pelo ledger, fases separadas e eixo anual completo; smoke com continuidade real (lacunas 15+16+10, notícias 16+16+9, 20 páginas).

View file

@ -0,0 +1,171 @@
# Impl: Dossiê Solla por tema/área + Boletim modelo (skill → dossiê + boletim)
Status: aprovado
Atualizado em: 2026-09-18
Issue: #1143
Intenção: docs/plans/dossie-solla-tema.md
Appetite restante: ~2–3 dias eng (herdado)
> Aprovado no modo `--auto` (`/work-issue --issue 1143 --auto`): a pausa do GATE virou apresentação-no-chat. Literais e cortes do plano de intenção foram assumidos; **sem schema/migration/DB write**, sem Consent, sem URL pública e sem alterar `/dossie-solla-cidade` nem `/dossie-solla-instituicao` (generalização aditiva do dono, com as specs C186/C187 verdes).
## Leitura da intenção
- **Outcome:** `/dossie-solla-tema Educação` entrega, numa invocação, um par **PDF A4 datado + companion `.md`** (identificação da área — label `Educação` + valor canônico `educacao` + nota da taxonomia do acervo; seções por era A/B/C; tabelas **Objeto/Valor/Ano/Fase/Abrangência/Fonte**; painel de abrangência `área × segmento × rede`; **evidência direta do acervo por `Speech.topics`**; lacunas; fontes; defeso) **e** o **Boletim modelo** (1 página A4, linguagem de eleitor, ≤6 destaques + ≤14 "E mais", sem fontes, sem CTA, rótulo "Modelo — insumo interno"). Lote de 1+N; falha isolada não cancela o lote.
- **O que NÃO negociar:** token **na taxonomia fechada** dos 18 `SPEECH_TOPICS` (`src/lib/speechFacets.ts:14`), **fail-closed** — nunca slug inventado; eras A/B/C **reusadas** do C186 (sem eras novas); abrangência `area | segmento | rede` **nunca somada**; fases `autorizado|empenhado|liquidado|pago|restos` (default `nao_informado`, empenho ≠ pago); "sem fonte, não publica" + lacuna explícita (emenda/proposição por área **só com fonte**); acervo cobre **2011+** (pré-2011 = lacuna); PII mínima; leitura relativa (nunca % estadual absoluto); artefato **gitignored**; **sem schema/migration/DB write**; **sem truncamento mecânico/descarte silencioso** (regra C188 — lista capada declara "e mais N"); boletim herda **apenas** `bulletinFacts` (fato com fonte).
- **O que reavaliar (hipóteses da "Direção no codebase"):**
- "só o render é binário" — verdade a confirmar: o **dispatch** é binário em 4 módulos (`dossieBlocks.mjs:222`, `dossieRender.mjs:813-827/1329-1330/2119-2127`, `dossieBulletinRender.mjs:372-376`); `isInstitutionUnit` tem 11 usos e o boletim (`dossieBulletin.mjs`) já é unit-aware.
- "o ramo instituição é quase todo reutilizável" — verdade: a variante tema é **estruturalmente a mesma família** (abrangência 3-listas, eras correntes sem caps, carta/síntese/gráficos, acervo), então o certo é **generalizar a forma `subject`** em vez de geminar um terceiro branch (o gatilho do 3º recorte já estava registrado no simplify do C187).
- "catálogo novo" — falso: o "catálogo" do tema já existe (`SPEECH_TOPICS`); falta **exportar** um resolver fail-closed do dono (`topicByKey` é privado, `speechFacets.ts:64`).
- "guard de extração igual ao institucional" — a **receita** (`readOnlyExtract.mjs`) reusa, mas o **alvo/guard** é próprio (`THEME_REPORT_CONFIRM=1`); o compositor institucional é _institution-shaped_ (`matchNames`/`mentionExcerpt`, `institutionSnapshot.mjs:84-113`).
- **Design (fonte de verdade do port — portar classe a classe):** dossiê `docs/plans/dossie-solla-tema-ui-design.html`, **14 cenas** (01 Capa e identificação da área; 02 A contribuição; 03 Resumo; 04 Síntese; 05 Gráficos; 06/07/08 Eras A/B/C; 09 Folha corrente/continuação; 10 Abrangência — lista completa; 11 Lacunas; 12 Notícias; 13 Acervo interno por tema; 14 Fontes/limites/defeso). Boletim `docs/plans/dossie-solla-tema-boletim-ui-design.html`, **2 cenas** (completo + variação de poucos fatos). O implementer **porta**, não redesenha.
## Abordagem recomendada
```mermaid
flowchart LR
A["/dossie-solla-tema Educação, Saúde"] --> B["orquestrador: resolve token<br/>resolveSpeechTopic (18 canônicos)<br/>fail-closed + dedup"]
B --> C["N researchers por era (A|B|C × tema)<br/>paralelo · MAX_RESEARCHERS_IN_FLIGHT=6"]
C --> R["<slug>.<era>.research.json<br/>+ recibo curto (status/paths/gaps)"]
B --> E["extração read-only, 1 tema por vez<br/>extract-theme-snapshot.mjs · THEME_REPORT_CONFIRM=1<br/>composeThemeSnapshot (Speech.topics direto)"]
E --> S["<slug>.theme.snapshot.json"]
R --> G["build-dossie-solla-tema.mjs"]
S --> G
G --> I["buildDossierReport(unit=THEME):<br/>capa+resumo+eras+abrangência+acervo+fontes<br/>→ bulletinFacts ledger (só fato com fonte)"]
I --> J["renderDossierHtml/Md → PDF A4 multi-folha + .md<br/>(buildPdf.mjs + dossiePack.mjs, sem caps)"]
I --> K["buildBulletin(unit=THEME) → renderBulletinHtml"]
K --> L["PDF 1×A4 boletim modelo"]
J --> M["docs/research/dossie-solla-tema/<slug>-<YYYY-MM-DD>-*.pdf/.md"]
L --> M
```
**Opções consideradas:** A) **descritor de unidade data-driven** + generalizar a forma `subject` (instituição e tema no mesmo dono) + registro por `unit.id`; B) terceiro branch `isThemeUnit` ao lado de `isInstitutionUnit` (relatório/render tema irmãos); C) o descritor carregar `buildReport`/`render` (função no dado).
**Recomendação:** **A** — a variante tema é a mesma forma da instituição (abrangência 3-listas, eras correntes, carta/síntese/gráficos/acervo); um **registro por id** substitui a cadeia de ternários e o descritor só ganha o vocabulário/copy que muda. Mantém **byte-idêntico** o caminho C186/C187 (mesmas funções, agora resolvidas por tabela) e escala para N recortes sem terceiro fork.
**Rejeitadas:** **B** — gemina o maior branch do pipeline (a drift que o repo proíbe; C187 já registrou a duplicação da mecânica com gatilho "3º unit/recorte"); **C** — função no descritor cria import circular (descritor em `dossieUnit.mjs`, builders em `dossieBlocks.mjs`) e transforma o dado em service locator.
### Componentes / mudanças
**Seam + resolução canônica (donos editados)**
- **`scripts/lib/dossieUnit.mjs`** — `THEME_UNIT` (`id:'theme'`, `slugField:'themeSlug'`, `shape:'subject'`, `spheres:['area','segmento','rede']`, `defaultSphere:'area'`, `sphereLabels`, `sphereBadgeClass` (`segmento→scope-sector`, `rede→scope-network`, `area→''`), `breadthRank`, `researchDir:'data/dossie-solla-tema'`, `series`, `title`, `bulletinTitle`, `bulletinMoreLimit`, `nounPlural:'temas'`, `subjectLabel:'Área'`); registro `DOSSIER_UNITS.theme` (`:63`); predicado `isSubjectUnit(unit)` (`shape === 'subject'`) — `isInstitutionUnit` permanece como alias fino (`id === 'institution'`) para não quebrar consumidores; `resolveDossierUnit` já fail-closed (`:74-82`).
- **`scripts/lib/dossieUnit.mjs` (descritor `subject`)** — campos que a forma genérica lê: `subjectName(snapshot)`, `identityBadges(snapshot)`, `scopeCopy`, `eraMethod`, `eraRecovery`, `reachCopy{ruleTitle,ruleBody}`, `coverageExtra`, `editorialSpheresLine`, `openingTitle(subjectName)`, `coverSubtitle`, `coverHowToUse`, `acervoNote`, `bulletinCopy`. `INSTITUTION_UNIT` recebe exatamente os literais de hoje (garante paridade); `THEME_UNIT` recebe os da família temática.
- **`src/lib/speechFacets.ts`** (editar o dono) — exportar `resolveSpeechTopic(token): SpeechTopic | null` (fecha sobre o `topicByKey`/`facetKey` já existentes), `speechTopicValues()` e `speechTopicLabel(value)`. Sem segundo mapa; o mesmo fold acento/caixa-insensível do parser do LLM.
**Pipeline generalizado (aditivo; C186/C187 verdes)**
- **`scripts/lib/dossieResearch.mjs`** — `THEME_RESEARCH_CHECKLIST` + `CHECKLIST_BY_UNIT.theme` (`:219`); nominal `era_a_formacao|era_a_sesab|era_a_consultor_ms|era_a_conquista|era_a_sas_ms|era_a_vinculo`; `era_b_sesab|era_b_politicas|era_b_investimentos|era_b_convenios|era_b_articulacao`; `era_c_discursos|era_c_proposicoes|era_c_relatorias|era_c_emendas|era_c_programas|era_c_audiencias|era_c_titulos`. Trocar o noun fixo do erro de merge (`:494`) por `unit.nounPlural` (`municípios|instituições|temas`).
- **`scripts/lib/dossieBlocks.mjs`** — generalizar `buildInstitutionReport` → `buildSubjectReport` (unit-driven): `directItems`/`scopeItem`/`speechFactsFromRows` usam `unit.defaultSphere`/`unit.spheres`; identidade/badges/`scopeCopy`/`eraMethod`/`reach`/`limits.coverage` vêm do descritor; `buildDossierReport` vira registro `{ municipality: buildMunicipalityReport, institution: buildSubjectReport, theme: buildSubjectReport }` (`:222`). `speechFactsFromRows(snapshot, sphere = default)` deixa de ter default `instituicao`.
- **`scripts/lib/dossieRender.mjs`** — generalizar `renderInstitutionDossierHtml/Md` → `renderSubjectDossierHtml/Md`; `unitHeader/unitFooter` (`:813-827`) passam a `isSubjectUnit(report.unit)`; `dossierPackAnchors` vira registro (`:1329`) com `THEME_PACK_ANCHORS` (`abrangencia-area|segmento|rede` → `scope:area|scope:segmento|scope:rede`); `INSTITUTION_SCOPE_WORDS` (`:1332`) sai do key do descritor. Reusa as classes já portadas (`.identity-badge`, `.scope-sector`, `.scope-network`, `.scope-cards`, `.more-note`, `.document-table`, etc.); o port das 14 cenas temáticas não introduz CSS novo além de copy.
- **`scripts/lib/dossieBulletin.mjs`** — sem mudança estrutural (já unit-aware: `breadthRank`/`bulletinMoreLimit`, `:51-68`).
- **`scripts/lib/dossieBulletinRender.mjs`** — generalizar `renderInstitutionBulletinHtml` → `renderSubjectBulletinHtml` (copy do descritor) e registrar `theme` (`:372-376`); mantém `defeso-band`, `identity-pill`, `lacuna-panel`, variação de poucos fatos.
**Extração read-only + builder (novos, reusando plumbing)**
- **`scripts/themeSnapshot.mjs`** (novo) — `composeThemeSnapshot({ payload, actor, value, label, taxonomyNote, readAt, codeSha, database })` → `{ meta:{kind:'theme'}, theme:{slug,value,label,taxonomyNote,topics:[value]}, speeches:{topics:[value],totalCount,rows}, gaps }`; reusa `buildSpeechListWhere({ page:1, topics:[value] })` (`speechListFilters.ts:38`) + `loadSegmentsForSpeeches`; projeta `summary`/links/ano/temas e **não** computa `matchNames`/`mentionExcerpt` (o recorte é a tag do acervo).
- **`scripts/extract-theme-snapshot.mjs`** (novo) — CLI `--theme=<valor|label> --out=<json>`; resolve com `resolveSpeechTopic` (fail-closed: desconhecido → lista os 18 canônicos, nunca slug inventado); `enterReadOnlySession({ confirmEnv:'THEME_REPORT_CONFIRM', … })`; serializado 1 tema por vez; escreve `data/dossie-solla-tema/<slug>.theme.snapshot.json`.
- **`scripts/build-dossie-solla-tema.mjs`** (novo) — CLI `--snapshot= --research-dir= --out-dir= [--generated-at=] --[--theme=]`; exige `snapshot.theme.slug`; lê `<slug>.{a,b,c}.research.json` (recusa slug divergente) e `<slug>.narrative.json` (opcional); **não** chama `fetchAuthorEmendas`/`fetchCamaraActivity`/`fetchHealthData`; `buildDossierReport({ …, unit: THEME_UNIT })` → dossiê + `buildBulletin({ facts, identity, unit: THEME_UNIT })`; `dossierPackAnchors(THEME_UNIT)`; HTML cache em `data/dossie-solla-tema/`; `.md` + dois PDFs via `buildPdf.mjs`/`dossiePack.mjs`; `DOSSIER_STRICT=1` falha em lacuna.
**Skill / command / subagentes**
- **`.agents/skills/dossie-solla-tema/SKILL.md`** (novo) — fonte canônica: `Lote`, `Áreas canônicas (taxonomia do acervo)`, `Pipeline (etapas)`, `Briefing por era (A/B/C)`, `Recibo do researcher`, `Recibo do redator`, `Contrato dos JSONs` (`themeSlug`, `sphere: area|segmento|rede`), `Conteúdo`, `Guardrails`, `Troubleshooting`, `Referências` (aponta os dois HTMLs hi-fi).
- **`.opencode/commands/dossie-solla-tema.md`** (novo) — frontmatter só `description:`; corpo carrega a skill pelo nome exato e termina em `$ARGUMENTS`.
- **`.opencode/agent/dossie-solla-tema.md`** + **`.opencode/agent/dossie-solla-tema-redacao.md`** (novos) — researcher por era / redator, espelhando C187; sem `ssh`/build/`--snapshot=`/`--theme=`/commit.
**Testes / CI / docs**
- Novos: `tests/unit/dossieTheme.unit.spec.ts`, `dossieThemeResearch.unit.spec.ts`, `dossieThemeBatchSkill.unit.spec.ts`, `tests/int/themeSnapshot.int.spec.ts`; spec do resolver em `tests/unit/speechFacets.unit.spec.ts` (estender).
- Editar: `tests/unit/opencodeCommands.unit.spec.ts` (`:13-21`, incluir `'dossie-solla-tema'`); `scripts/lib/test-affected-core.mjs` (`SCRIPTS_SPEC_PINNED` += `scripts/themeSnapshot.mjs`; `ciSkipInvariants` recalcula o closure até casar); `.gitignore` (`/data/dossie-solla-tema/`, `/docs/research/dossie-solla-tema/`); `docs/changelog/2026-09-18-c190.md`.
- **Manter verdes:** `dossie{Research,Blocks,Render,Bulletin}.unit.spec.ts`, `dossieInstitution*.unit.spec.ts`, `institutionCatalog.unit.spec.ts`, `speechFacets/speechListFilters/speechListUrl.unit.spec.ts` e o int institucional — provando o default aditivo.
### Dados → forma (pergunta 3 — data-presentation)
- **Dossiê — forma escolhida:** **tabela/lista com fonte por linha + badge de abrangência + badge de fase** (Objeto/Valor/Ano/Fase/Abrangência/Fonte; painel de abrangência com **3 listas e nenhum total combinado**; gráficos consolidados como no dono; **acervo por tema com universos/amostra declarados** — `Speech.topics = educacao` (2011+)). É a forma mais pobre que ainda desbloqueia a decisão (ângulo/entrega + o que falta). **Rejeitadas:** dashboard de área (perde a fonte item a item e convida a somar); gráfico de série entre áreas (não há série comparável); % estadual absoluto (anti-goal); painel territorial por município (território é o recorte do C186).
- **Boletim — forma escolhida:** **número destacado + rótulo curto** (≤6 `.highlight-card`) + **lista densa ✓** ("E mais", `bulletinMoreLimit`) + timeline; densidade > destaque grande. **Rejeitadas:** tabela/planilha (documento de gestor); parágrafo longo (estoura 1 página); fontes no boletim (vivem só no dossiê); CTA (defeso).
## Decisões de engenharia (A–G)
**A. Vocabulário de abrangência.** Opções: A) `area | segmento | rede` (default `area`); B) reusar `instituicao | setor | rede` do C187; C) só `area`. **Recomendação: A** — o render/blocks já resolvem esfera por descritor (`dossierSphereLabel/BadgeClass`, `scopeItem` vira `unit.spheres`); `area` é a política inteira, `segmento` o recorte interno (básica/superior/EJA), `rede` as entidades/escolas; preserva o guardrail "nunca somar" com vocabulário que não confunde com ente. **Rejeitadas:** B (uma "instituição" dentro do dossiê de área sugere atribuição de entidade — exatamente a confusão que o recorte evita; o hi-fi usa "Abrangência · área"); C (perde segmento/rede, que existem no material e no hi-fi).
**B. Generalização do dispatch e da forma.** Opções: A) registro por `unit.id` + generalizar a forma `subject` (instituição e tema no mesmo builder/renderer); B) terceiro branch tema irmão; C) descritor com funções `buildReport`/`render`. **Recomendação: A** — byte-parity C186/C187 (mesmas funções, agora resolvidas por tabela), sem terceiro fork do maior branch, honrando o gatilho do 3º recorte registrado no simplify do C187. **Rejeitadas:** B (twin da mecânica de `dossieBlocks`/`dossieRender`, drift garantido no próximo fix de pack/C188); C (import circular/TDZ e service locator).
**C. Onde vive a resolução do token.** Opções: A) exportar resolver do dono `src/lib/speechFacets.ts`; B) lib nova `src/lib/speechTopicCatalog.ts`; C) resolver na prosa da skill/script. **Recomendação: A** — `SPEECH_TOPICS`, `facetKey` e o `topicByKey` já vivem lá; exportar `resolveSpeechTopic` reusa o **mesmo** fold do parser do LLM (uma fonte de verdade para label↔valor). **Rejeitadas:** B (catálogo paralelo ao dono → drift); C (lógica de resolução não testável e duplicada em prompt).
**D. Composer e guard da extração read-only.** Opções: A) `scripts/themeSnapshot.mjs` + `extract-theme-snapshot.mjs` com `THEME_REPORT_CONFIRM=1`, reusando `readOnlyExtract.mjs`; B) parametrizar `composeInstitutionSnapshot`; C) reusar o extrator institucional com `--topics=`. **Recomendação: A** — a projeção do tema é diferente (sem `matchNames`/`mentionExcerpt`); a parte profunda (sessão read-only/ator/loaders) é reusada do dono, não geminada; guard próprio é o contrato de intenção explícita (o comando não adivinha o alvo). **Rejeitadas:** B (branch num compositor int-pinado por uma projeção que não compartilha o `institution` shape); C (o extrator institucional é institution-shaped e arrastaria `mentionExcerpt`/identidade de instituição para o dossiê temático).
**E. Escopo do recorte de dados em build.** Opções: A) sem APIs oficiais em build; checklist por era orientado a área; acervo por tema como **evidência de primeira classe**; emenda/proposição/relatoria só de item de pesquisa com fonte, senão lacuna; B) buscar Câmara por palavra-chave na ementa; C) reusar Portal/Câmara do C186. **Recomendação: A** — Portal não filtra por área e `fetchAuthorEmendas` exige município; match nominal/por tema é falso-positivo/omissão. O acervo (`Speech.topics`) é o recorte nativo e alimenta a seção de evidência e o ledger `bulletinFacts` (só fala com link). **Rejeitadas:** B/C (atribuição errada e "zero silencioso").
**F. Escape one-off.** Opções: A) **sem escape** — a taxonomia dos 18 temas é fechada; token desconhecido falha fechado com a lista canônica; B) espelhar `--slug=<x> --name="<Nome>"` do C187. **Recomendação: A** — o anti-goal é "inventar área/slug fora da taxonomia"; ao contrário do catálogo aberto de instituições, não existe "área desconhecida porém válida" (os labels são canônicos). **Rejeitada:** B (deixaria um slug inventado virar dossiê — contradiz outcome e recorte).
**G. Boletim por área.** Opções: A) generalizar `renderInstitutionBulletinHtml` → `renderSubjectBulletinHtml` (copy do descritor) e registrar `theme`; B) renderer tema separado. **Recomendação: A** — o delta é copy (pill `Área`/`educacao`, "pela <área>", rodapé "segmento e rede não são somados à área"); herança continua estrutural (`buildBulletin` só recebe `bulletinFacts`; ≤6 + `bulletinMoreLimit` "e mais"; sem fontes/CTA; `sparse`). **Rejeitada:** B (twin de ~130 linhas com delta de texto).
## Fases verificáveis (tracer bullet first)
1. **Tracer (seam + forma `subject` + resolução do token) — quota de meio dia.** `THEME_UNIT`/registro/`isSubjectUnit`; export do resolver em `speechFacets`; generalização de `dossieBlocks`/`dossieRender` para a forma `subject`; checklist tema em `dossieResearch`; um smoke de tema com snapshot/ pesquisa mínimos provando `buildDossierReport(unit=THEME)` → `renderDossierHtml/Md` e o `.md`. **Prova o encadeamento mais arriscado cedo** (paridade C187 + primeira composição temática) com as specs C186/C187 verdes.
2. **Pipeline agêntico + taxonomia.** `SKILL.md` + command + 2 subagentes; `dossieThemeBatchSkill.unit.spec.ts` (lote/resolução fail-closed/dedup/fan-out/sem escape/guarda); `opencodeCommands.unit.spec.ts` atualizado; `test-affected-core.mjs` atualizado; spec do resolver.
3. **Extração + builder.** `themeSnapshot.mjs`, `extract-theme-snapshot.mjs` (`THEME_REPORT_CONFIRM=1`), `build-dossie-solla-tema.mjs`; `tests/int/themeSnapshot.int.spec.ts`; decisão E exercida (sem emendas/Câmara/saúde em build; acervo alimenta evidência + ledger).
4. **Boletim + port visual + docs.** `renderSubjectBulletinHtml` tema (completo + variação de poucos fatos); port classe a classe das **14 cenas** do dossiê e das **2** do boletim; `.gitignore`; changelog `2026-09-18-c190.md`.
5. **Gates.** `pnpm gate:fast` (lint 0 warnings, `tsc --noEmit`, unit) → build local de **Educação** e de uma área de rótulo longo (ex.: Direitos Humanos) conferindo PDF/MD/boletim (aceite manual) → `pnpm push` (PR CI roda o resto). Sem e2e (não há runtime no app); `tests/int` só o snapshot novo.
## Rabbit holes / Não escopo (engenharia)
- Terceiro fork do pipeline (research/render/ledger/boletim) — twin proibido; tudo passa pelo dono + descritor registro.
- `INSTITUTION_SCOPE`/`institutionEraMethod`/copy hardcoded vazando para o tema; `sphere='instituicao'` default em `speechFactsFromRows`; noun fixo do `mergeDossierResearch`.
- Puxar emendas/Câmara/IBGE em build para "completar" a área; match por palavra-chave na ementa; tratar ausência como zero; somar segmento/rede à área.
- Catálogo/coleção Payload, schema/migration/DB write/segundo cadastro; UI in-app no `/campanha`; índice/PDF agregado de lote; fan-out das 18 áreas.
- Reabrir `/dossie-solla-cidade`/`/relatorio-cidade`/`/dossie-solla-instituicao` além da generalização aditiva; migrar o builder C163 para `buildPdf.mjs` agora.
- Reconstruir a carreira pré-2011 fora do acervo — vira **lacuna**, não invenção.
- Reabrir o design: os hi-fi estão prontos — só port; se faltar superfície, aciona-se o `designer` (não se redesenha na implementação).
## Riscos e mitigação
- **Regressão C186/C187 pela generalização:** mesmas funções resolvidas por tabela + descritor com os literais atuais; specs `dossie*`/`dossieInstitution*` no `gate:fast` são o oráculo; se a paridade falhar, a contingência é um `buildThemeReport` fino compondo os helpers já compartilhados (não geminar o branch inteiro).
- **`SCRIPTS_SPEC_PINNED` fora de sincronia:** `ciSkipInvariants` recalcula o closure e falha fechado; atualizar o mapa na mesma fase que cria `themeSnapshot.mjs`.
- **Recorte por tema impreciso (acervo):** o recorte é a tag `Speech.topics` (revisável na base); limite de amostra + "e mais N"; a skill declara "acervo cobre 2011+".
- **Token ambíguo/desconhecido:** valores e labels são únicos; o resolver do dono retorna `null` e o CLI/skill falham fechados com a lista canônica.
- **Fit/overflow de folha:** orçamentos explícitos + pack medido (`dossiePack` grow/shrink) + guarda A4 fail-closed; se estourar, aperta teto/copy — nunca espreme o layout nem corta texto.
- **PII/gitignore/fan-out:** projeção sem contato; artefatos gitignored; subagente proibido de commitar; `MAX_RESEARCHERS_IN_FLIGHT = 6`; extração serializada.
- **Appetite:** cortes embutidos — sem APIs em build (E), sem escape (F), sem agregado, sem casos especiais por área além do descritor.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: dossiê PDF A4 + `.md` (identificação da área label+valor canônico, eras A/B/C, abrangência `área×segmento×rede`, acervo por tema, lacunas, fontes, defeso) **e** boletim modelo 1×A4 (≤6 destaques + "E mais", sem fontes, sem CTA, rótulo "Modelo — insumo interno" + defeso, `NEEDS ASSET`).
- [ ] Taxonomia **fechada** fail-closed (18 canônicos) + dedup após resolução; **sem escape inventado**; eras A/B/C reusadas; abrangência `area|segmento|rede` **nunca somada**; fases por valor (empenho ≠ pago); "sem fonte, não publica"; acervo 2011+; PII mínima; artefato gitignored.
- [ ] Boletim herda **apenas** `bulletinFacts` (fato com fonte do dossiê); sem linha de fontes; sem CTA.
- [ ] Sem truncamento mecânico/descarte silencioso: lista capada declara "e mais N"; reformulação no `summary`.
- [ ] Generalização **aditiva** do dono; specs C186/C187 e int institucional verdes; `/dossie-solla-cidade` e `/dossie-solla-instituicao` sem mudança de comportamento; `buildPdf.mjs`/`dossiePack.mjs` seguem donos únicos do emit/paginação.
- [ ] **Sem schema, migration, Consent ou DB write**; extração read-only com `THEME_REPORT_CONFIRM=1`.
- [ ] `test-affected-core.mjs` sincronizado (closure exato) e `opencodeCommands.unit.spec.ts` com `dossie-solla-tema`; command/subagentes acoplados pelo nome exato.
- [ ] Unit novos verdes: `dossieTheme`, `dossieThemeResearch`, `dossieThemeBatchSkill` (+ resolver em `speechFacets`); int `themeSnapshot`; `.gitignore` + changelog; `pnpm gate:fast` verde; build local aceito em Educação + área de rótulo longo; `pnpm push`.
---
## Self-score decision-quality (gate ≥4/5)
1. **Decisões caras têm rejeitadas?** Sim — A–G com Opções/Recomendação/Rejeitadas explícitas (vocabulário, dispatch/forma, resolução do token, composer/guard, escopo de dados, escape, boletim). — **5/5**
2. **Abordagem cabe no appetite (2–3 dias)?** Sim — reuso máximo do dono C186/C187, sem schema/DB, cortes declarados (sem APIs em build, sem escape, sem agregado, sem e2e). — **4/5** (a generalização da forma `subject` é o trecho que mais consome quota; por isso o tracer cedo e o oráculo C187).
3. **Rabbit holes nomeados?** Sim — terceiro fork, copy hardcoded, APIs de área, match por palavra-chave, zero silencioso, catálogo/coleção, UI in-app, agregado, pré-2011 inventado, redesign. — **5/5**
4. **Depth check: reusa shells/helpers existentes?** Sim — `dossieUnit/Research/Blocks/Render/Bulletin/BulletinRender/Pack`, `buildPdf`, `readOnlyExtract`, `cityReportFormat`, `reportText`, `cli`, `buildSpeechListWhere`/`loadSegmentsForSpeeches`, `SPEECH_TOPICS` como catálogo; sem helper pass-through novo. — **4/5** (a generalização toca módulos em uso; mitigada por descritor com literais atuais + specs).
5. **Intenção (aceite de produto) permanece satisfeita?** Sim — os dois artefatos, a taxonomia fail-closed, os literais (1 página, ≤6/≤14, sem fontes, rótulo de modelo, abrangência não somável), o acervo como evidência de primeira classe e a regra C188 estão no aceite; a engenharia não reescreveu o outcome. — **5/5**
**Total 23/25 (média 4,6/5) — passa o gate ≥4/5.**
---
## Débitos capturados (triage pós-execute — C190)
Triage do simplify (2 revisores) + crítica do `designer`, via regras de
`capture-review-debts`. Nada aqui bloqueia a entrega certificada.
| # | Achado | Score | Tipo | Destino |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `scripts/build-dossie-solla-tema.mjs` é ~85% clone do builder institucional (3º clone do CLI); `extract-theme-snapshot.mjs` idem | 3 | cheap_polish | **defer** — lote `escala-dry-pos-c190` (runner único parametrizado por unit); gatilho: 4º recorte ou próxima edição simultânea dos builders |
| 2 | `SPEECH_SELECT`/limite/projeção base triplicados entre `themeSnapshot`/`institutionSnapshot`/`cityReportSnapshot` (drift real: typo `excerptTMS` corrigido nesta entrega) | 3 | cheap_polish | **defer** — mesmo lote (helper base + delta por unit) |
| 3 | Copy institucional vaza para o município (`MUNICIPALITY_UNIT.synthesisSumGuard`, arias "institucional" nas páginas compartilhadas) | 3 | cheap_polish | **descartar** do escopo C190 (pré-existente C186 sob byte-parity congelada); vira bug separado se o produto pedir |
| 4 | Pureza no seam: `scopeListWords` redundante com `scopeLists[].sphere`; regex de era duplicado; condição dupla no ramo de dinheiro | 2 | cheap_polish | **absorver** no lote acima (F3) |
| 5 | `dossieThemeBatchSkill.unit.spec.ts` é clone do spec institucional | 2 | cheap_polish | **absorver** (`describe.each`) no lote |
| 6 | Assert vacuoso herdado em `dossieTheme.unit.spec.ts` (`omitted >= 0`) | 2 | cheap_polish | **absorver** no lote |
| 7 | Sincronização dos artefatos hi-fi do C190 (tabelas 10,5pt; cenas medidas de continuidade 15+16+10 e 16+16+9) | 2 | cheap_polish | **absorver** — passo do artefato (designer), sem Issue; confirmação humana registrada no PR |
| 8 | Sem e2e (scripts/artefatos sem runtime no app) | 1 | defer_trigger | **descartar** — já declarado no impl; reavaliar se o pipeline ganhar rota/UI |
**Registro:** o lote `escala-dry-pos-c190` (itens 1, 2, 4, 5, 6) fica como
trabalho deferido deste plano, com gatilho explícito — sem Issue nova nesta
entrega para não editar/duplicar o tracker `in-progress`.

View file

@ -0,0 +1,250 @@
/**
* Dossiê + boletim builder (C190, theme/area unit): theme snapshot + per-era
* research → dossiê PDF A4 + `.md` and the one-page boletim PDF.
*
* Runs on the workstation: no database access, no persistence. Official
* sources are NOT fetched here — the Transparency Portal and the Câmara do not
* filter by area, so area-attributed emendas/proposições come from the per-era
* research items (with phase + source); anything unattributed becomes an
* explicit gap, never a silent zero.
*
* Usage:
* NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-tema.mjs \
* --snapshot=data/dossie-solla-tema/educacao.theme.snapshot.json \
* --research-dir=data/dossie-solla-tema \
* --out-dir=docs/research/dossie-solla-tema
*/
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 {
A4_PAGE_BUDGET_PX,
emitHtmlPairPdf,
launchPdfBrowser,
measureDocumentPackProbe,
measureDocumentSheets,
} from './lib/buildPdf.mjs'
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 { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs'
import { adjustPackPlan, packProbeSections } from './lib/dossiePack.mjs'
import { dossierPackAnchors, renderDossierHtml, renderDossierMd } from './lib/dossieRender.mjs'
import { mergeDossierResearch, normalizeDossierResearchInput } from './lib/dossieResearch.mjs'
import { THEME_UNIT } from './lib/dossieUnit.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-dossie-solla-tema'
const die = dieWithLabel(LABEL)
const DEFAULT_OUT_DIR = 'docs/research/dossie-solla-tema'
const CACHE_DIR = 'data/dossie-solla-tema'
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 outDir = typeof flags['out-dir'] === 'string' ? flags['out-dir'] : DEFAULT_OUT_DIR
const generatedAt =
typeof flags['generated-at'] === 'string' ? new Date(flags['generated-at']) : new Date()
if (!snapshotPath) {
die('Uso: --snapshot=<json> [--research-dir=<dir>] [--out-dir=<dir>] [--generated-at=<ISO>]')
}
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.theme?.slug
if (!slug) die('Snapshot sem "theme.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'))
if (raw.themeSlug !== slug) {
die(
`Pesquisa da Era ${era} é de "${raw.themeSlug}", não de "${slug}" (${path}) — pare e regenere.`,
)
}
if (raw.era !== era) {
die(`Pesquisa em ${path} declara Era ${raw.era}, não Era ${era} — pare e regenere.`)
}
return normalizeDossierResearchInput(raw, { unit: THEME_UNIT })
} catch (error) {
if (error?.code === 'ENOENT') {
console.log(`[${LABEL}] Era ${era}: sem pesquisa (${path}) — vira lacuna explícita.`)
return normalizeDossierResearchInput(
{
themeSlug: slug,
era,
researchedAt: generatedAt.toISOString(),
items: [],
},
{ unit: THEME_UNIT },
)
}
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, { unit: THEME_UNIT })
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 readNarrative = async () => {
const path = join(researchDir, `${slug}.narrative.json`)
try {
const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8'))
if (raw.themeSlug !== slug) {
die(`Redação é de "${raw.themeSlug}", não de "${slug}" (${path}) — pare e regenere.`)
}
return raw
} catch (error) {
if (error?.code === 'ENOENT') return null
die(`Redação inválida (${path}): ${error instanceof Error ? error.message : error}`)
}
}
const narrative = await readNarrative()
if (!narrative) {
console.log(
`[${LABEL}] sem redação autoral (${slug}.narrative.json) — a abertura repete os números.`,
)
}
const report = buildDossierReport({
snapshot,
research,
generatedAt,
unit: THEME_UNIT,
narrative,
})
const bulletin = buildBulletin({
facts: report.bulletinFacts,
identity: {
name: report.meta.subjectName,
badges: report.meta.identityBadges,
value: report.meta.identity?.value ?? null,
taxonomyNote: report.meta.identity?.taxonomyNote ?? null,
},
unit: THEME_UNIT,
generatedAt,
})
const dossierMd = renderDossierMd(report)
const bulletinHtml = renderBulletinHtml(bulletin)
const baseName = `${slug}-${generatedAt.toISOString().slice(0, 10)}`
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
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}`)
/**
* Flowing sheets: probe the real height of every unit, pack the sections, then
* re-render and measure — an overflowing sheet gives a row to the next chunk, a
* sheet with room takes the next row, until the plan stops changing. Nothing is
* capped and no page is left half empty; the final emit still guards the A4.
*/
const browser = await launchPdfBrowser()
let dossierHtml = null
let pack = null
try {
const probe = await measureDocumentPackProbe(browser, renderDossierHtml(report, { probe: true }))
const anchors = Object.fromEntries(
Object.entries(dossierPackAnchors(THEME_UNIT)).map(([anchor, key]) => [key, anchor]),
)
pack = packProbeSections(probe, A4_PAGE_BUDGET_PX)
console.log(
`[${LABEL}] probe: ${probe.map((section) => `${section.key}=${section.rows.length}`).join(' ')}`,
)
let settled = false
const lockedAnchors = new Set()
for (let attempt = 0; attempt < 16 && !settled; attempt += 1) {
dossierHtml = renderDossierHtml(report, { pack })
const sheets = await measureDocumentSheets(browser, dossierHtml)
for (const sheet of sheets) {
if (sheet.height > A4_PAGE_BUDGET_PX) lockedAnchors.add(sheet.page)
}
const adjusted = adjustPackPlan({
plan: pack,
probe,
sheets,
anchors,
budgetPx: A4_PAGE_BUDGET_PX,
lockedAnchors,
})
if (!adjusted) {
settled = true
break
}
pack = adjusted
}
if (!dossierHtml) dossierHtml = renderDossierHtml(report, { pack })
console.log(
`[${LABEL}] pack ${settled ? 'estável' : 'no teto de tentativas'}: ${Object.entries(pack)
.map(([key, sizes]) => `${key}=${sizes.join('+')}`)
.join(' ')}`,
)
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml)
await emitHtmlPairPdf(browser, {
dossierHtml,
bulletinHtml,
dossierPdf: resolve(ROOT, dossierPdfFile),
bulletinPdf: resolve(ROOT, bulletinPdfFile),
})
} catch (error) {
die(error instanceof Error ? error.message : String(error))
} finally {
await browser.close()
}
console.log(`[${LABEL}] PDF → ${dossierPdfFile}`)
console.log(`[${LABEL}] boletim PDF → ${bulletinPdfFile}`)
console.log(
`[${LABEL}] ${report.meta.subjectName}: eras=${report.eras.length} entregas=${report.page1.deliveries.items.length} ` +
`highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} páginas=${report.meta.pageTotal} 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.`,
)
}

View file

@ -0,0 +1,117 @@
/**
* Snapshot extractor for the theme/area dossiê (C190).
*
* Runs where production lives (homeserver), read-only by connection options,
* and writes a JSON snapshot consumed by `scripts/build-dossie-solla-tema.mjs`.
* Nothing is persisted.
*
* The token is resolved fail-closed against the canonical acervo taxonomy
* (`SPEECH_TOPICS`); the taxonomy is closed, so there is no one-off escape —
* an unknown token lists the 18 canonical areas and stops.
*
* Usage (from the homeserver, with `~/stack/teqo-1313.env` loaded):
* THEME_REPORT_CONFIRM=1 NODE_OPTIONS="--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs" \
* node scripts/extract-theme-snapshot.mjs --theme=educacao --out=data/dossie-solla-tema/educacao.theme.snapshot.json
*/
import { execFileSync } from 'node:child_process'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { resolveSpeechTopic, SPEECH_TOPICS } from '../src/lib/speechFacets.ts'
import { databaseLabel } from './lib/cityReportDatabase.mjs'
import { dieWithLabel, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
import { enterReadOnlySession, resolveCampaignActor } from './lib/readOnlyExtract.mjs'
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const LABEL = 'extract-theme-snapshot'
const die = dieWithLabel(LABEL)
const codeSha = () => {
try {
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim()
} catch {
return null
}
}
/** Catalog listing used by the fail-closed error (value + label, one per line). */
const topicCatalog = () =>
SPEECH_TOPICS.map(({ value, label }) => ` ${value} (${label})`).join('\n')
/**
* Resolve the canonical area: value or pt-BR label, accent/case insensitive.
* Unknown token fails closed with the full taxonomy — never an invented slug.
*/
const resolveIdentity = (token) => {
const entry = resolveSpeechTopic(token)
if (!entry) {
die(
`Área desconhecida na taxonomia do acervo: "${token}".\n` + ` Canônicas:\n${topicCatalog()}`,
)
}
return {
slug: entry.value,
value: entry.value,
label: entry.label,
taxonomyNote: 'Taxonomia do acervo',
}
}
loadCliEnv()
const { flags } = parseEqualsFlags(process.argv.slice(2))
const out = typeof flags.out === 'string' ? flags.out : null
const themeToken = typeof flags.theme === 'string' ? flags.theme.trim() : ''
if (!themeToken) {
die(
'Uso: --theme=<valor|label> --out=<arquivo.json>\n' +
` Áreas canônicas:\n${topicCatalog()}\n` +
' Env: THEME_REPORT_CONFIRM=1 (obrigatório), DATABASE_URL (read-only é forçado).',
)
}
const identity = resolveIdentity(themeToken)
if (!out) {
die('Uso: --theme=<valor|label> --out=<arquivo.json>.')
}
const rawDatabaseUrl = enterReadOnlySession({
confirmEnv: 'THEME_REPORT_CONFIRM',
label: LABEL,
die,
context: `área ${identity.value}`,
})
const { default: configPromise } = await import('../src/payload.config.ts')
const { getPayload } = await import('payload')
const { composeThemeSnapshot } = await import('./themeSnapshot.mjs')
const payload = await getPayload({ config: configPromise })
try {
const actor = await resolveCampaignActor(payload, { die })
console.log(`[${LABEL}] ator: ${actor.name ?? actor.id} (${actor.role})`)
const snapshot = await composeThemeSnapshot({
payload,
actor,
identity,
readAt: new Date().toISOString(),
codeSha: codeSha(),
database: databaseLabel(rawDatabaseUrl),
})
const outPath = resolve(ROOT, out)
await mkdir(dirname(outPath), { recursive: true })
await writeFile(outPath, `${JSON.stringify(snapshot, null, 2)}\n`)
console.log(
`[${LABEL}] snapshot de ${snapshot.theme.label} (${snapshot.theme.value}) → ${out} ` +
`(falas=${snapshot.speeches.totalCount} lacunas=${snapshot.gaps.length})`,
)
} catch (error) {
die(error instanceof Error ? error.message : String(error))
}
process.exit(0)

View file

@ -18,7 +18,7 @@ import {
import {
INSTITUTION_UNIT,
MUNICIPALITY_UNIT,
isInstitutionUnit,
isSubjectUnit,
resolveDossierUnit,
} from './dossieUnit.mjs'
import { capList, stripInlineSources, summarySurfaceText } from './reportText.mjs'
@ -220,7 +220,7 @@ const camaraItemsAsActions = (camara, era) =>
* @returns {DossierReport}
*/
export const buildDossierReport = (params) =>
isInstitutionUnit(params?.unit) ? buildInstitutionReport(params) : buildMunicipalityReport(params)
isSubjectUnit(params?.unit) ? buildSubjectReport(params) : buildMunicipalityReport(params)
const buildMunicipalityReport = ({
snapshot,
research,
@ -520,7 +520,9 @@ const buildMunicipalityReport = ({
}
/* ------------------------------------------------------------------ *
* Institution branch (C187) — same owner, institution-shaped sections. *
* Subject branch (C187 institution / C190 theme) — same owner, the *
* `subject` shape shares these sections; the vocabulary comes from *
* the unit descriptor. *
* ------------------------------------------------------------------ */
/**
@ -534,21 +536,6 @@ const MAX_INSTITUTION_TIMELINE = 3
const MAX_INSTITUTION_HOOKS = 2
const MAX_INSTITUTION_PENDING = 4
const INSTITUTION_SCOPE =
'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara dos Deputados 2015–2027. Instituição, setor e rede separados; sem fonte, não entra como entrega.'
const institutionEraMethod = {
A: 'Registros anteriores a 2007 que citem nominalmente a instituição ou comprovem vínculo. Cargo geral não prova ação institucional.',
B: 'A gestão estadual é lida por equipamento, programa, convênio e obra; cada linha informa abrangência e fonte. Setor e rede não somam à instituição.',
C: 'Mandato, relatorias, parcerias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase.',
}
const institutionEraRecovery = {
A: 'biografias oficiais · atos e diários · acervos institucionais · busca web datada',
B: 'DOE-BA (DOOL) · notícias SESAB/instituição · Transparência Bahia · busca web datada',
C: 'API Câmara · Portal da Transparência · acervo interno read-only · busca web datada',
}
/**
* Wraps a capped list so the renderer can declare what stayed out ("e mais N")
* instead of dropping it silently — the C188 no-truncation rule, enforced at
@ -567,17 +554,12 @@ const capped = (list, max) => {
*/
const uncapped = (list) => ({ items: list, total: list.length, omitted: 0 })
const identityBadges = (identity) =>
[identity.kindLabel, identity.sphereLabel, identity.scope ? identity.scopeLabel : null].filter(
Boolean,
)
/**
* Speeches from the internal acervo (read-only snapshot) that the research did
* not already carry: they are sourced evidence for the recorte, so they feed
* the bulletin ledger too — never a second, unsourced fact.
*/
const speechFactsFromRows = (snapshot, sphere = 'instituicao') =>
const speechFactsFromRows = (snapshot, sphere) =>
(snapshot.speeches?.rows ?? [])
.filter((row) => Boolean(row.officialTextUrl || row.youtubeUrl || row.vodPlaybackUrl))
.map((row) => ({
@ -644,6 +626,12 @@ const gapEraKey = (gap) => {
return match ? match[1].toUpperCase() : 'fora'
}
/** Theme gap field: checklist ids carry the era letter, everything else is Acervo. */
const gapEraLabel = (id) => {
const match = /^era_([abc])_/i.exec(id ?? '')
return match ? match[1].toUpperCase() : 'Acervo'
}
const buildDossierSynthesis = ({
items,
numberRows,
@ -754,7 +742,7 @@ const buildDossierSynthesis = ({
.join(' · ')}.`,
)
lines.push(
`Abrangência: ${bySphere.map((row) => `${row.count} ${row.label}`).join(' · ')}. Setor e rede não são somados à instituição.`,
`Abrangência: ${bySphere.map((row) => `${row.count} ${row.label}`).join(' · ')}. ${unit.synthesisSumGuard ?? 'Setor e rede não são somados à instituição.'}`,
)
if (topEra) {
lines.push(
@ -767,13 +755,20 @@ const buildDossierSynthesis = ({
}. Empenho não é pagamento.`,
)
if (moneyTotals.count) {
const parts = []
if (moneyTotals.executed)
parts.push(`${formatMoneyCompact(moneyTotals.executed)} pagos/liquidados`)
if (moneyTotals.authorized)
parts.push(`${formatMoneyCompact(moneyTotals.authorized)} autorizados/empenhados`)
if (moneyTotals.proposed)
parts.push(`${formatMoneyCompact(moneyTotals.proposed)} em propostas sem fase informada`)
// Theme keeps every phase on its own line (never fusing pago/liquidado or
// autorizado/empenhado); the totals and charts stay untouched.
const parts =
unit.moneyPhaseStyle === 'separate'
? moneyByPhase.map((row) => `${formatMoneyCompact(row.amount)} ${row.label}`)
: []
if (unit.moneyPhaseStyle !== 'separate') {
if (moneyTotals.executed)
parts.push(`${formatMoneyCompact(moneyTotals.executed)} pagos/liquidados`)
if (moneyTotals.authorized)
parts.push(`${formatMoneyCompact(moneyTotals.authorized)} autorizados/empenhados`)
if (moneyTotals.proposed)
parts.push(`${formatMoneyCompact(moneyTotals.proposed)} em propostas sem fase informada`)
}
lines.push(`Recursos localizados: ${parts.join(' · ')}.`)
}
if (byArea.length) {
@ -877,19 +872,15 @@ const buildEraSummary = ({ era, items, money, scopeLabel, unit = INSTITUTION_UNI
return parts.join(' ')
}
const buildInstitutionReport = ({
snapshot,
research,
generatedAt = new Date(),
narrative = null,
}) => {
const identity = snapshot.institution ?? {}
const subjectName = identity.name ?? identity.slug ?? 'Instituição'
const unit = INSTITUTION_UNIT
const speechFacts = speechFactsFromRows(snapshot)
const buildSubjectReport = (params) => {
const { snapshot, research, generatedAt = new Date(), narrative = null } = params
const unit = resolveDossierUnit(params.unit ?? INSTITUTION_UNIT)
const identity = snapshot[unit.snapshotField] ?? {}
const subjectName = identity.name ?? identity.label ?? identity.slug ?? '—'
const speechFacts = speechFactsFromRows(snapshot, unit.defaultSphere)
const items = research.items ?? []
const directItems = items.filter((item) => item.sphere === 'instituicao')
const directItems = items.filter((item) => item.sphere === unit.defaultSphere)
/** Honors live in their own sheet; they are not repeated as era action cards. */
const researchActionsByEra = (era) =>
@ -924,13 +915,13 @@ const buildInstitutionReport = ({
)
return {
...era,
method: institutionEraMethod[era.id],
recovery: institutionEraRecovery[era.id],
method: unit.eraMethod[era.id],
recovery: unit.eraRecovery[era.id],
summary: buildEraSummary({
era,
items: eraItems,
money: moneyRows(eraNumberRows),
scopeLabel: 'no recorte institucional',
scopeLabel: unit.scopeLabel,
unit,
}),
narrative:
@ -1006,6 +997,7 @@ const buildInstitutionReport = ({
label: gap.label ?? gap.id,
reason: gap.reason,
nextStep: 'Apurar em fonte primária.',
era: gapEraLabel(gap.id),
}))
const pendingList = (research.gaps ?? []).map((gap) => gap.label ?? gap.id)
const pending = capped(pendingList, MAX_INSTITUTION_PENDING)
@ -1019,9 +1011,12 @@ const buildInstitutionReport = ({
...uncapped(
sphereItems.map((item) => ({
id: item.id,
era: item.era,
sphere: item.sphere,
area: item.area,
year: item.numbers?.[0]?.year ?? (item.sourceDate ?? '').slice(0, 4) ?? null,
value: item.numbers?.[0]?.value ?? null,
phase: item.numbers?.[0]?.phase ?? null,
title: item.answer,
brief: item.brief ?? null,
evidence: item.details ?? item.label,
@ -1031,11 +1026,9 @@ const buildInstitutionReport = ({
),
}
}
const scopeLists = [
scopeItem('instituicao', 'institution', unit.sphereLabels.instituicao),
scopeItem('setor', 'sector', unit.sphereLabels.setor),
scopeItem('rede', 'network', unit.sphereLabels.rede),
]
const scopeLists = unit.scopeLists.map(({ key, sphere }) =>
scopeItem(sphere, key, unit.sphereLabels[sphere]),
)
const synthesis = buildDossierSynthesis({
items,
@ -1043,7 +1036,7 @@ const buildInstitutionReport = ({
speechFacts,
gaps: research.gaps ?? [],
acervoTotal: snapshot.speeches?.totalCount ?? speechFacts.length,
scopeLabel: 'no recorte institucional',
scopeLabel: unit.scopeLabel,
unit,
})
@ -1064,7 +1057,13 @@ const buildInstitutionReport = ({
: null,
value: item.numbers?.[0]?.value ?? null,
numberLabel: item.numbers?.[0]?.label ?? null,
year: item.numbers?.[0]?.year ?? null,
// Theme bulletin cards fall back to the sourced year when there is no
// number (C190); the institution ledger keeps its C187 shape.
year:
item.numbers?.[0]?.year ??
(unit.bulletinNumberFallback && item.sourceDate
? String(item.sourceDate).slice(0, 4)
: null),
phase: item.numbers?.[0]?.phase ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
@ -1081,7 +1080,7 @@ const buildInstitutionReport = ({
title:
typeof narrative?.title === 'string' && narrative.title.trim()
? narrative.title.trim()
: `O que Jorge Solla fez pela e na ${subjectName}`,
: unit.copy.openingTitle(subjectName),
paragraphs: narrativeParagraphs.length > 0 ? narrativeParagraphs : [synthesis.lines.join(' ')],
authored: narrativeParagraphs.length > 0,
}
@ -1092,7 +1091,7 @@ const buildInstitutionReport = ({
title: unit.title,
subjectName,
identity,
identityBadges: identityBadges(identity),
identityBadges: unit.identityBadges(identity),
generatedAt,
generatedAtLabel,
readAtLabel,
@ -1105,10 +1104,9 @@ const buildInstitutionReport = ({
internalNoteSub: 'Não circular · não publicar',
eyebrow: 'Dossiê de atuação pública',
title: subjectName,
subtitle: 'O que Jorge Solla fez por, na e com a instituição ao longo da carreira',
howToUse:
'Localize o vínculo ou a entrega, confira fonte, data, fase e abrangência. Transforme toda lacuna em tarefa de apuração. Este arquivo não é fala pronta nem peça pública.',
scope: INSTITUTION_SCOPE,
subtitle: unit.copy.coverSubtitle(subjectName),
howToUse: unit.copy.coverHowToUse,
scope: unit.copy.coverScope,
version: 'Documento de trabalho · versão 01',
},
page1: { timeline, deliveries, hooks, pending },
@ -1117,9 +1115,8 @@ const buildInstitutionReport = ({
acervo,
synthesis,
reach: {
ruleTitle: 'Setor/rede não é a instituição. Não some os recortes.',
ruleBody:
'Uma política para uma categoria ou uma articulação com entidades correlatas pode alcançar a instituição sem constituir entrega exclusiva para ela.',
ruleTitle: unit.copy.reachRuleTitle,
ruleBody: unit.copy.reachRuleBody,
lists: scopeLists,
hook: hooks.items[0] ?? null,
priorityGap: pending.items[0] ?? null,
@ -1135,18 +1132,8 @@ const buildInstitutionReport = ({
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.',
'Menção a entidade correlata (setor/rede) não é atribuição à instituição.',
],
editorial: [
'Sem fonte, não publica.',
'URL e data acompanham cada afirmação não trivial.',
'Instituição, setor e rede permanecem separados.',
'Valor sempre informa a fase de execução.',
],
coverage: unit.copy.limits.coverage,
editorial: unit.copy.limits.editorial,
},
bulletinFacts,
}

View file

@ -49,14 +49,22 @@ export const buildBulletin = ({
const resolvedUnit = resolveDossierUnit(unit)
const ordered = rankFacts(facts, resolvedUnit)
const subjectName = municipality ?? identity?.name ?? '—'
const highlights = ordered.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => ({
eyebrow: `${fact.area ?? 'Atuação'} · ${dossierSphereLabel(fact.sphere, resolvedUnit)}`,
number: fact.value,
title: fact.brief?.title ?? fact.headline,
note: fact.brief?.note ?? fact.detail ?? null,
phase: fact.value ? (fact.phase ?? null) : null,
phaseLabel: fact.value ? dossierPhaseLabel(fact.phase) : null,
}))
const highlights = ordered.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => {
// Theme-only fallback: a fact without money value shows its year; without
// either, the card is textual and prints no number slot at all (never zero).
const number = resolvedUnit.bulletinNumberFallback
? (fact.value ?? fact.year ?? null)
: fact.value
return {
eyebrow: `${fact.area ?? 'Atuação'} · ${dossierSphereLabel(fact.sphere, resolvedUnit)}`,
number,
...(resolvedUnit.bulletinNumberFallback && !number ? { textual: true } : {}),
title: fact.brief?.title ?? fact.headline,
note: fact.brief?.note ?? fact.detail ?? null,
phase: fact.value ? (fact.phase ?? null) : null,
phaseLabel: fact.value ? dossierPhaseLabel(fact.phase) : null,
}
})
const moreItems = ordered
.slice(
BULLETIN_HIGHLIGHT_LIMIT,
@ -73,12 +81,14 @@ export const buildBulletin = ({
sparse: ordered.length < 3,
meta: {
title: resolvedUnit.bulletinTitle,
kicker: 'Boletim informativo · atuação pública',
kicker: resolvedUnit.bulletinKicker ?? 'Boletim informativo · atuação pública',
subjectName,
municipality: subjectName,
region,
identity,
identityBadges: identity?.badges ?? [],
identityPills:
resolvedUnit.bulletinPills && identity ? resolvedUnit.bulletinPills(identity) : null,
generatedAt,
generatedAtLabel: formatDateTimeBr(generatedAt),
modelLabel: 'Modelo — insumo interno',

View file

@ -5,7 +5,7 @@
* as an internal model.
*/
import { isInstitutionUnit } from './dossieUnit.mjs'
import { isSubjectUnit } from './dossieUnit.mjs'
import { htmlEscape, moreItemsLabel } from './reportText.mjs'
const ASSET = (title, lines) =>
@ -21,9 +21,9 @@ const phaseModifier = (phase) =>
: ''
const renderHighlight = (highlight, { showPhase = false } = {}) => `
<article class="highlight-card">
<article class="highlight-card${highlight.textual ? ' highlight-card--textual' : ''}">
<p class="highlight-eyebrow">${htmlEscape(highlight.eyebrow)}</p>
${highlight.number ? `<p class="highlight-number tabular">${htmlEscape(highlight.number)}</p>` : ''}
${!highlight.textual && highlight.number ? `<p class="highlight-number tabular">${htmlEscape(highlight.number)}</p>` : ''}
${
showPhase && highlight.number && highlight.phaseLabel
? `<p class="highlight-phase"><span class="phase ${phaseModifier(highlight.phase)}">${htmlEscape(highlight.phaseLabel)}</span></p>`
@ -214,10 +214,11 @@ const renderMunicipalityBulletinHtml = (bulletin) => `
/* Institution boletim (C187) — ports the institutional boletim hi-fi. */
const INSTITUTION_BULLETIN_CSS = `
const SUBJECT_BULLETIN_CSS = `
${PRINT_CSS}
.defeso-band { margin-inline: -10mm; padding: 1.4mm 10mm 1.35mm; display: flex; align-items: center; justify-content: space-between; gap: 5mm; color: #68420f; background: #fff7e8; border-top: .25mm solid #e7cc9d; border-bottom: .25mm solid #e7cc9d; font-size: 7.15pt; line-height: 1.2; font-weight: 700; }
.identity-pill { display: inline-flex; align-items: center; min-height: 5mm; padding: .55mm 1.7mm; border: .25mm solid #cbd5dc; border-radius: 99px; color: #435264; background: #f3f6f8; font-size: 6.9pt; line-height: 1; font-weight: 700; letter-spacing: .045em; text-transform: uppercase; }
.identity-pill--code { font-family: ui-monospace, 'DejaVu Sans Mono', monospace; letter-spacing: .02em; }
.bulletin-head .subject { margin: 0; font-size: 11pt; font-weight: 800; }
.bulletin-head .pills { margin-top: 1.3mm; display: flex; flex-wrap: wrap; gap: 1.2mm; }
.lacuna-panel { border: .4mm dashed #b18549; color: #684819; background: #fff7e8; }
@ -230,6 +231,8 @@ const INSTITUTION_BULLETIN_CSS = `
.empty-box { min-height: 30mm; }
.lacuna-panel .warn-icon { width: 5mm; height: 5mm; flex-shrink: 0; margin-top: .4mm; color: #8a5a18; }
.highlight-phase { margin: 1.2mm 0 0; }
.highlight-card--textual { border-top-color: #435264; }
.highlight-card--textual .highlight-title { margin-top: 0; font-size: 11.6pt; }
.phase { display: inline-block; padding: .5mm 1.4mm; border-radius: 1mm; background: #e9eef1; color: #334553; font-size: 7.3pt; font-weight: 700; text-transform: uppercase; letter-spacing: .035em; }
.phase-paid { color: #285338; background: #e6f2e9; }
.phase-pending { color: #6b4918; background: #fff2d9; }
@ -238,18 +241,27 @@ const INSTITUTION_BULLETIN_CSS = `
const INST_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 renderInstitutionBulletinHtml = (bulletin) => {
const renderSubjectBulletinHtml = (bulletin) => {
const sparse = Boolean(bulletin.sparse)
const pills = (bulletin.meta.identityBadges ?? [])
.map((badge) => `<span class="identity-pill">${htmlEscape(badge)}</span>`)
const copy = bulletin.unit.copy.bulletin
// Theme pills come from the descriptor (`Área` + canonical token + taxonomy);
// institution keeps the identity badges untouched.
const pillList = bulletin.meta.identityPills
? bulletin.meta.identityPills.filter((pill) => Boolean(pill.label))
: (bulletin.meta.identityBadges ?? []).map((badge) => ({ label: badge }))
const pills = pillList
.map(
(pill) =>
`<span class="identity-pill${pill.code ? ' identity-pill--code' : ''}">${htmlEscape(pill.label)}</span>`,
)
.join('')
const highlights = bulletin.highlights
const more = bulletin.moreItems
return `<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.subjectName)}</title><style>${INSTITUTION_BULLETIN_CSS}</style></head>
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.subjectName)}</title><style>${SUBJECT_BULLETIN_CSS}</style></head>
<body>
<article class="sheet" data-page="boletim" aria-label="Modelo de boletim informativo institucional de uma página">
<article class="sheet" data-page="boletim" aria-label="${htmlEscape(copy.aria ?? 'Modelo de boletim informativo institucional de uma página')}">
<header class="bulletin-head">
<div>
<p class="kicker">${htmlEscape(bulletin.meta.kicker)}</p>
@ -266,16 +278,19 @@ const renderInstitutionBulletinHtml = (bulletin) => {
<section class="opening" aria-label="Abertura do boletim">
<div>
<p class="section-label">${sparse ? 'Somente o que está documentado' : 'Uma trajetória de trabalho junto à instituição'}</p>
<h1>O que Jorge Solla fez pela e na <span class="accent">${htmlEscape(bulletin.meta.subjectName)}</span></h1>
<p class="lead">${
sparse
? 'Esta versão tem poucos registros confirmados. Em vez de preencher espaço, mostra só os fatos que o dossiê sustenta.'
: 'Ações, recursos e articulações explicados de forma direta — somente o que já foi documentado no dossiê institucional.'
}</p>
<p class="section-label">${sparse ? 'Somente o que está documentado' : htmlEscape(copy.openingLabel)}</p>
<h1>${(() => {
const title = copy.openingTitle(bulletin.meta.subjectName)
const name = htmlEscape(bulletin.meta.subjectName)
const escaped = htmlEscape(title)
return escaped.includes(name)
? escaped.replace(name, `<span class="accent">${name}</span>`)
: escaped
})()}</h1>
<p class="lead">${sparse ? htmlEscape(copy.leadSparse) : htmlEscape(copy.lead)}</p>
</div>
${ASSET(sparse ? 'NEEDS ASSET · foto ou clipping' : 'NEEDS ASSET · foto real', [
sparse ? 'somente registro relacionado ao fato' : 'Solla na instituição ou na ação citada',
sparse ? copy.assetSparse : copy.asset,
'origem, autoria e licença',
])}
</section>
@ -284,7 +299,7 @@ const renderInstitutionBulletinHtml = (bulletin) => {
<div class="highlights-head">
<div>
<p class="section-label">Destaques</p>
<h2>${sparse ? 'Poucos fatos, sem enchimento' : 'Resultados em leitura rápida'}</h2>
<h2>${sparse ? htmlEscape(copy.highlightsTitleSparse) : htmlEscape(copy.highlightsTitle)}</h2>
</div>
<p class="side">${sparse ? 'não criar cards vazios para chegar a seis' : 'cada número mantém sua fase e seu alcance'}</p>
</div>
@ -297,8 +312,8 @@ const renderInstitutionBulletinHtml = (bulletin) => {
<section class="bulletin-timeline" aria-label="Trajetória">
<div class="timeline-head">
<h2>Uma trajetória de compromisso com o serviço público</h2>
<p class="timeline-side">${sparse ? 'contexto de carreira · não substitui fato institucional' : 'quatro momentos · linguagem direta'}</p>
<h2>${htmlEscape(copy.title)}</h2>
<p class="timeline-side">${sparse ? htmlEscape(copy.titleSparse) : 'quatro momentos · linguagem direta'}</p>
</div>
<div class="timeline-grid">
${bulletin.timeline
@ -317,14 +332,14 @@ const renderInstitutionBulletinHtml = (bulletin) => {
<div class="more-head">
<div>
<p class="section-label">E mais</p>
<h2>${sparse ? 'Outros registros confirmados' : 'Outras ações confirmadas'}</h2>
<h2>${sparse ? htmlEscape(copy.moreTitleSparse) : htmlEscape(copy.moreTitle)}</h2>
</div>
<p class="side">${sparse ? 'sem completar por inferência' : 'até 14 · sem completar por inferência'}</p>
</div>
${
more.length
? `<div class="more-grid">${more.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ê institucional antes de ampliar o boletim.</p></div>'
: `<div class="empty-box"><p><strong class="uppercase">Sem itens adicionais com fonte além dos destaques.</strong></p><p>${htmlEscape(copy.moreEmpty)}</p></div>`
}
</div>
${
@ -332,12 +347,12 @@ const renderInstitutionBulletinHtml = (bulletin) => {
? `<aside class="lacuna-panel" style="border-radius: .125rem; padding: 2.5mm" aria-label="Regra editorial para poucos fatos">
${INST_WARNING_ICON}
<p style="margin: 0; font-size: 10.5pt; font-weight: 800">A página termina com espaço — de propósito.</p>
<p style="font-size: 8.7pt; line-height: 1.35">Não repetir item, não ampliar efeito e não preencher com ação de setor ou rede como se fosse entrega exclusiva da instituição.</p>
<p style="font-size: 8.7pt; line-height: 1.35">${htmlEscape(copy.sparseRule)}</p>
<p class="lacuna-rule">Poucos fatos com lastro &gt; página cheia sem lastro</p>
</aside>`
: `<aside class="more-aside" aria-label="Ativos visuais pendentes">
${ASSET('NEEDS ASSET · selo', ['marca autorizada da instituição', 'arquivo vetorial'])}
${ASSET('NEEDS ASSET · clipping', ['registro real da ação', 'origem e licença'])}
${ASSET('NEEDS ASSET · selo', copy.assetSelo ?? [])}
${ASSET('NEEDS ASSET · clipping', copy.assetClipping ?? [])}
</aside>`
}
</section>
@ -349,7 +364,7 @@ const renderInstitutionBulletinHtml = (bulletin) => {
<p class="section-label">Nota de produção</p>
<p style="margin: 0; font-size: 9.2pt; line-height: 1.38; color: #435264">Se novos fatos forem documentados, eles entram por relevância — até seis destaques e até quatorze itens. A ausência nunca vira número zero, percentual ou afirmação genérica.</p>
</div>
${ASSET('NEEDS ASSET · selo', ['marca autorizada da instituição', 'arquivo vetorial'])}
${ASSET('NEEDS ASSET · selo', copy.assetSelo ?? [])}
</section>`
: ''
}
@ -357,8 +372,8 @@ const renderInstitutionBulletinHtml = (bulletin) => {
<footer class="bulletin-foot">
<div class="row">
<div>
<p class="orig">Conteúdo selecionado do dossiê institucional.</p>
<p class="defeso"><strong>Controle editorial:</strong> fase acompanha cada valor · instituição, setor e rede não são somados · sem percentual estadual absoluto.</p>
<p class="orig">${htmlEscape(copy.orig)}</p>
<p class="defeso"><strong>Controle editorial:</strong> ${htmlEscape(copy.defeso)}</p>
</div>
<p class="stamp">MODELO · INSUMO INTERNO<br />A4 · página 1/1<br />${htmlEscape(bulletin.meta.generatedAtLabel)}</p>
</div>
@ -368,8 +383,8 @@ const renderInstitutionBulletinHtml = (bulletin) => {
</html>`
}
/** Dispatch: the institution unit renders the institutional composition. */
/** Dispatch: subject-shaped units (institution/theme) render the shared subject composition. */
export const renderBulletinHtml = (bulletin) =>
isInstitutionUnit(bulletin?.unit)
? renderInstitutionBulletinHtml(bulletin)
isSubjectUnit(bulletin?.unit)
? renderSubjectBulletinHtml(bulletin)
: renderMunicipalityBulletinHtml(bulletin)

View file

@ -7,9 +7,12 @@
import { formatDateBr, formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs'
import { dossierPhaseLabel, dossierSphereBadgeClass, dossierSphereLabel } from './dossieBlocks.mjs'
import { MUNICIPALITY_UNIT, isInstitutionUnit } from './dossieUnit.mjs'
import { MUNICIPALITY_UNIT, isSubjectUnit, resolveDossierUnit } from './dossieUnit.mjs'
import { htmlEscape, moreItemsLabel, showingLabel, stripInlineSources } from './reportText.mjs'
/** Markdown column headers capitalize the sphere label (sentence case, as C187 shipped). */
const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1)
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>'
@ -742,6 +745,7 @@ const INSTITUTION_PRINT_CSS = `
.vinc-source { margin: 1mm 0 0; font-size: 8pt; line-height: 1.2; color: #435264; }
.scope-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3mm; margin-top: 4mm; }
.scope-card { border: .25mm solid #cbd5dc; border-radius: .25rem; padding: 3mm; }
.acervo-stat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 3mm; margin-top: 3mm; }
.scope-card.card-sector { border-color: #c9b483; background: #fffcf6; }
.scope-card.card-network { border-color: #b9acc3; background: #fcf9fd; }
.scope-count { margin: 3mm 0 0; font-size: 16pt; font-weight: 600; font-variant-numeric: tabular-nums; }
@ -754,28 +758,34 @@ const INSTITUTION_PRINT_CSS = `
* overflowing sheet back to its pack entry and shrinks that chunk (fallback of
* the measured pack plan).
*/
const MUNICIPALITY_PACK_ANCHORS = {
const SHARED_PACK_ANCHORS = {
'era-a': 'era:A',
'era-b': 'era:B',
'era-c': 'era:C',
'regiao-municipio': 'region:municipio',
'regiao-regional': 'region:regional',
lacunas: 'gaps',
noticias: 'news',
acervo: 'acervo',
}
const MUNICIPALITY_PACK_ANCHORS = {
...SHARED_PACK_ANCHORS,
'regiao-municipio': 'region:municipio',
'regiao-regional': 'region:regional',
}
export const INSTITUTION_PACK_ANCHORS = {
'era-a': 'era:A',
'era-b': 'era:B',
'era-c': 'era:C',
...SHARED_PACK_ANCHORS,
titulos: 'honors',
'abrangencia-instituicao': 'scope:instituicao',
'abrangencia-setor': 'scope:setor',
'abrangencia-rede': 'scope:rede',
lacunas: 'gaps',
noticias: 'news',
acervo: 'acervo',
}
const THEME_PACK_ANCHORS = {
...SHARED_PACK_ANCHORS,
'abrangencia-area': 'scope:area',
'abrangencia-segmento': 'scope:segmento',
'abrangencia-rede': 'scope:rede',
}
/**
@ -798,7 +808,7 @@ const INSTITUTION_PROBE_CSS = `
const instHeader = (report, title, subtitle, pageNo) => `
<header class="report-header">
<div>
<p class="report-kicker">Dossiê institucional · ${htmlEscape(report.meta.subjectName)}</p>
<p class="report-kicker">${htmlEscape(report.unit.docLabel)} · ${htmlEscape(report.meta.subjectName)}</p>
<h2 class="sheet-title">${htmlEscape(title)}</h2>
${subtitle ? `<p class="sheet-sub">${htmlEscape(subtitle)}</p>` : ''}
</div>
@ -811,18 +821,16 @@ const instHeader = (report, title, subtitle, pageNo) => `
/** Shared sheets (síntese, gráficos, carta, eras correntes) use the unit's shell. */
const unitHeader = (report, title, subtitle, pageNo) =>
isInstitutionUnit(report.unit)
isSubjectUnit(report.unit)
? instHeader(report, title, subtitle, pageNo)
: sheetHeader(report, title, subtitle, pageNo)
const unitFooter = (report, left, pageNo) =>
isInstitutionUnit(report.unit)
? instFooter(report, left, pageNo)
: sheetFooter(report, left, pageNo)
isSubjectUnit(report.unit) ? instFooter(report, left, pageNo) : sheetFooter(report, left, pageNo)
const instFooter = (report, left, pageNo) => `
<footer class="report-footer">
<span>Dossiê institucional · ${htmlEscape(report.meta.subjectName)} · ${htmlEscape(left)}</span>
<span>${htmlEscape(report.unit.docLabel)} · ${htmlEscape(report.meta.subjectName)} · ${htmlEscape(left)}</span>
<span>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))} · pág. ${pageNo}/${report.meta.pageTotal}</span>
</footer>`
@ -944,8 +952,17 @@ const valueList = ({ rows, format }) => {
</ul>`
}
/** Vertical columns with the value label above and the year label below. */
const columnChart = ({ rows, width = 330, height = 132, format = (row) => String(row.count) }) => {
/**
* Vertical columns with the value label above and the year label below. `fullYearLabel`
* prints the whole year (theme axis); the institution keeps the 2-digit label.
*/
const columnChart = ({
rows,
width = 330,
height = 132,
format = (row) => String(row.count),
fullYearLabel = false,
}) => {
const max = Math.max(1, ...rows.map((row) => row.value ?? row.count))
const baseY = height - 18
const plotHeight = baseY - 16
@ -959,14 +976,14 @@ const columnChart = ({ rows, width = 330, height = 132, format = (row) => String
const y = baseY - barHeight
return `<rect x="${x + 2}" y="${y}" width="${Math.max(2, columnWidth - 5)}" height="${barHeight}" fill="${row.color ?? CHART_COLORS.default}" />
<text x="${x + columnWidth / 2}" y="${y - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(String(row.key).slice(-2))}</text>`
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
})
.join('')}
</svg>`
}
/** Vertical columns stacked by phase (money by year), total label above. */
const stackedColumnChart = ({ rows, width = 330, height = 132, format }) => {
const stackedColumnChart = ({ rows, width = 330, height = 132, format, fullYearLabel = false }) => {
const max = Math.max(1, ...rows.map((row) => row.value))
const baseY = height - 18
const plotHeight = baseY - 16
@ -986,7 +1003,7 @@ const stackedColumnChart = ({ rows, width = 330, height = 132, format }) => {
const top = baseY - Math.round((row.value / max) * plotHeight)
return `${bars}
<text x="${x + columnWidth / 2}" y="${top - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(String(row.key).slice(-2))}</text>`
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
})
.join('')}
</svg>`
@ -997,8 +1014,25 @@ const identityBadgeList = (report) =>
.map((badge) => `<span class="identity-badge">${htmlEscape(badge)}</span>`)
.join(' ')
const renderInstitutionCover = (report) => `
<article class="sheet sheet-cover" data-page="capa" aria-label="Capa do dossiê institucional">
const coverRowValue = (report, row) => {
if (row.badges) return `<dd>${identityBadgeList(report)}</dd>`
if (row.code) {
return `<dd><code>${htmlEscape(row.code)}</code>${row.note ? ` · ${htmlEscape(row.note)}` : ''}</dd>`
}
return `<dd${row.tabular ? ' class="tabular"' : ''}>${htmlEscape(row.text)}</dd>`
}
const renderSubjectCover = (report) => {
const rows = [
...report.unit.coverRows(report),
{ label: 'Data de geração', text: formatDateTimeBr(report.meta.generatedAt), tabular: true },
{
label: 'Escopo e versão',
text: `Eras A / B / C · ${report.unit.copy.coverScopeWords} · versão 01`,
},
]
return `
<article class="sheet sheet-cover" data-page="capa" aria-label="Capa do ${htmlEscape(report.unit.docLabel.toLowerCase())}">
<div class="accent-bar"></div>
<header class="cover-head">
<div>
@ -1015,14 +1049,12 @@ const renderInstitutionCover = (report) => `
</div>
<dl class="cover-dl">
<dt>Identificação</dt><dd>${identityBadgeList(report)}</dd>
<dt>Data de geração</dt><dd class="tabular">${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</dd>
<dt>Escopo e versão</dt><dd>Eras A / B / C · instituição, setor e rede separados · versão 01</dd>
${rows.map((row) => `<dt>${htmlEscape(row.label)}</dt>${coverRowValue(report, row)}`).join('\n ')}
</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 da instituição', 'uso autorizado e origem'])}
${assetBox('NEEDS ASSET', report.unit.copy.coverAsset)}
</div>
<div class="cover-foot">
@ -1033,26 +1065,20 @@ const renderInstitutionCover = (report) => `
</div>
</div>
</article>`
}
const renderInstitutionIdentity = (report) => {
const identity = report.meta.identity ?? {}
const rows = [
['Nome', identity.name],
['Tipo', identity.kindLabel],
['Esfera', identity.sphereLabel],
['Alcance', identity.scopeLabel],
['Alias', (identity.aliases ?? []).join(' · ') || '—'],
].filter(([, value]) => Boolean(value))
const renderSubjectIdentity = (report) => {
const rows = report.unit.identityRows(report.meta.identity ?? {})
return `<div class="id-card">
<p class="eyebrow">Identificação</p>
<dl>${rows.map(([key, value]) => `<dt>${htmlEscape(key)}</dt><dd>${htmlEscape(value)}</dd>`).join('')}</dl>
</div>`
}
const renderInstitutionVincTimeline = (report) => {
const renderSubjectVincTimeline = (report) => {
const timeline = report.page1.timeline
if (timeline.items.length === 0) {
return '<p class="muted small">Nenhum vínculo datado com fonte — ver lacunas explícitas.</p>'
return `<p class="muted small">${htmlEscape(report.unit.copy.timelineEmpty)}</p>`
}
return `
<div class="vinc-timeline">
@ -1069,7 +1095,7 @@ const renderInstitutionVincTimeline = (report) => {
${moreNote(timeline, 'vínculos', 'com fonte — lista completa nas seções por era.')}`
}
const renderInstitutionDeliveries = (report) => {
const renderSubjectDeliveries = (report) => {
const deliveries = report.page1.deliveries
if (deliveries.items.length === 0) return ''
return `
@ -1101,7 +1127,7 @@ const renderInstitutionDeliveries = (report) => {
</section>`
}
const renderInstitutionHooksAndPending = (report) => {
const renderSubjectHooksAndPending = (report) => {
const hooks = report.page1.hooks
const pending = report.page1.pending
if (hooks.items.length === 0 && pending.items.length === 0) return ''
@ -1132,23 +1158,25 @@ const renderInstitutionHooksAndPending = (report) => {
</section>`
}
const renderInstitutionSummary = (report, pageNo) => `
<article class="sheet" data-page="resumo" aria-label="Resumo de uma olhada do dossiê institucional">
const renderSubjectSummary = (report, pageNo) => `
<article class="sheet" data-page="resumo" aria-label="Resumo de uma olhada do ${htmlEscape(report.unit.docLabel.toLowerCase())}">
${instHeader(report, 'Resumo de uma olhada', 'Informação datada e pronta para conferência', pageNo)}
<section class="summary-top">
${renderInstitutionIdentity(report)}
${renderSubjectIdentity(report)}
<div>
<p class="eyebrow">01 · vínculo com a instituição</p>
<p class="eyebrow">${htmlEscape(report.unit.summaryLinkEyebrow)}</p>
<h3 class="section-title">Linha do tempo documentada</h3>
${renderInstitutionVincTimeline(report)}
${renderSubjectVincTimeline(report)}
</div>
</section>
${renderInstitutionDeliveries(report)}
${renderInstitutionHooksAndPending(report)}
${renderSubjectDeliveries(report)}
${renderSubjectHooksAndPending(report)}
${instFooter(report, 'Resumo · INSUMO INTERNO', pageNo)}
</article>`
const renderEraEmptySheet = (report, era, pageNo) => `
const renderEraEmptySheet = (report, era, pageNo) => {
const empty = report.unit.copy.eraEmpty
return `
<article class="sheet" data-page="era-${era.id.toLowerCase()}" aria-label="${htmlEscape(era.label)}">
${unitHeader(report, `${era.label} · Pesquisa sem evidência suficiente`, era.period, pageNo)}
<div class="era-method">
@ -1162,25 +1190,19 @@ const renderEraEmptySheet = (report, era, pageNo) => `
${WARNING_ICON}
<p class="eyebrow warning-text">Lacuna explícita · não é “zero”</p>
<h3 class="rule-title">Nenhuma fonte suficiente foi localizada para esta era.</h3>
<p class="muted">A pesquisa não encontrou documento que sustente uma afirmação institucional. Isso não prova que não houve vínculo ou atuação; prova apenas que o dossiê ainda não pode afirmá-los.</p>
<p class="muted">${htmlEscape(empty.body)}</p>
</section>
<section class="page1-grid">
<div class="panel">
<p class="eyebrow">O que foi feito</p>
<ul class="tight-list">
<li>Busca nominal por instituição e aliases.</li>
<li>Consulta a fontes oficiais e acervos datados.</li>
<li>Triagem de resultados por vínculo e abrangência.</li>
<li>Descarte de menções sem lastro suficiente.</li>
${empty.work.map((item) => `<li>${htmlEscape(item)}</li>`).join('\n ')}
</ul>
</div>
<div class="panel">
<p class="eyebrow">O que falta fazer</p>
<ul class="tight-list">
<li>Solicitar consulta ao arquivo físico.</li>
<li>Validar nome histórico e unidade da instituição.</li>
<li>Recuperar ato, ata ou documento contemporâneo.</li>
<li>Repetir busca com alias confirmado.</li>
${empty.todo.map((item) => `<li>${htmlEscape(item)}</li>`).join('\n ')}
</ul>
</div>
</section>
@ -1188,11 +1210,18 @@ const renderEraEmptySheet = (report, era, pageNo) => `
${WARNING_ICON}
<div>
<p class="eyebrow warning-text">Regra editorial</p>
<p><strong>Não preencher a seção por memória, cargo provável ou texto de outra era.</strong> Registrar “${htmlEscape(era.label)}: evidência institucional não localizada nas fontes consultadas”.</p>
<p>${(() => {
const rule = empty.rule(era.label)
const splitAt = rule.indexOf('. ')
return splitAt === -1
? htmlEscape(rule)
: `<strong>${htmlEscape(rule.slice(0, splitAt + 1))}</strong> ${htmlEscape(rule.slice(splitAt + 2))}`
})()}</p>
</div>
</aside>
${unitFooter(report, `${era.label} · lacuna explícita`, pageNo)}
</article>`
}
/** Accepts the institution's capped objects and the city's plain arrays. */
const listOf = (value) => (Array.isArray(value) ? value : (value?.items ?? []))
@ -1325,15 +1354,15 @@ const renderEraSheets = ({ report, era, pack, probe, nextPage, includeHonors = f
}).map((entry) => renderEraSheet({ report, era, entry, probe, pageNo: nextPage() }))
}
const PACK_ANCHORS_BY_UNIT = {
municipality: MUNICIPALITY_PACK_ANCHORS,
institution: INSTITUTION_PACK_ANCHORS,
theme: THEME_PACK_ANCHORS,
}
/** Anchor map of the unit's packed sections (the builder inverts it). */
export const dossierPackAnchors = (unit) =>
isInstitutionUnit(unit) ? INSTITUTION_PACK_ANCHORS : MUNICIPALITY_PACK_ANCHORS
const INSTITUTION_SCOPE_WORDS = {
institution: 'instituicao',
sector: 'setor',
network: 'rede',
}
PACK_ANCHORS_BY_UNIT[resolveDossierUnit(unit).id] ?? MUNICIPALITY_PACK_ANCHORS
const listYearRange = (items) => {
const years = items
@ -1343,12 +1372,14 @@ const listYearRange = (items) => {
return years.length ? `${years[0]}–${years[years.length - 1]}` : null
}
const renderInstitutionScope = (report, pageNo) => {
const renderSubjectScope = (report, pageNo) => {
const reach = report.reach
const cardClass = { institution: '', sector: 'card-sector', network: 'card-network' }
const sphereTitle = report.unit.spheres
.map((sphere) => report.unit.sphereLabels[sphere])
.join(' × ')
return `
<article class="sheet" data-page="abrangencia" aria-label="Painel de abrangência institucional">
${instHeader(report, 'Abrangência: instituição × setor × rede', 'Três recortes, três leituras, nenhuma soma combinada', pageNo)}
<article class="sheet" data-page="abrangencia" aria-label="${htmlEscape(report.unit.aria?.scope ?? 'Painel de abrangência institucional')}">
${instHeader(report, `Abrangência: ${sphereTitle}`, 'Três recortes, três leituras, nenhuma soma combinada', pageNo)}
<section class="rule-warning">
${WARNING_ICON}
@ -1364,8 +1395,8 @@ const renderInstitutionScope = (report, pageNo) => {
<div class="scope-cards">
${reach.lists
.map(
(list) => `<article class="scope-card ${cardClass[list.key] ?? ''}">
<div class="action-head">${scopeBadge(list.sphere, null, report.unit)}<span class="meta strong">${list.sphere === 'instituicao' ? 'DIRETO' : 'NÃO SOMAR'}</span></div>
(list) => `<article class="scope-card ${report.unit.scopeCardClass[list.sphere] ?? ''}">
<div class="action-head">${scopeBadge(list.sphere, null, report.unit)}<span class="meta strong">${list.sphere === report.unit.defaultSphere ? 'DIRETO' : 'NÃO SOMAR'}</span></div>
<p class="scope-count tabular">${list.total} ${list.total === 1 ? 'item' : 'itens'}</p>
<p class="meta">com fonte${listYearRange(list.items) ? ` · ${listYearRange(list.items)}` : ''} · lista completa nas páginas seguintes</p>
<ul class="tight-list">${list.items
@ -1405,27 +1436,50 @@ const renderInstitutionScope = (report, pageNo) => {
}
/** Sphere lists are uncapped: each one flows across as many sheets as it needs. */
const institutionScopeUnits = (report, list, probe) =>
list.items.map((item, index) => ({
layout: 'tr',
html: `<tr${unitAttrs(probe, index, 'tr')}>
const subjectScopeUnits = (report, list, probe) =>
list.items.map((item, index) => {
const attrs = unitAttrs(probe, index, 'tr')
if (!report.unit.reachScene) {
return {
layout: 'tr',
html: `<tr${attrs}>
<td>${copyHtml(briefOr(item.brief, item.title))}</td>
<td>${scopeBadge(item.sphere, null, report.unit)}</td>
<td>${copyHtml(noteOr(item.brief, item.evidence))}</td>
<td>${sourceLink(item.sourceUrl)}</td>
</tr>`,
}))
}
}
return {
layout: 'tr',
html: `<tr${attrs}>
<td>${copyHtml(briefOr(item.brief, item.title))}</td>
<td class="nowrap">Era ${htmlEscape(item.era ?? '—')}${item.year ? ` · ${htmlEscape(item.year)}` : ''}</td>
<td>${valueCell(item.value)}${item.phase ? ` ${phaseBadge(item.phase)}` : ''}</td>
<td>${copyHtml(noteOr(item.brief, item.evidence))}</td>
<td>${sourceLink(item.sourceUrl)}</td>
</tr>`,
}
})
const renderInstitutionScopeListSheet = ({ report, list, entry, probe, pageNo }) => {
const word = INSTITUTION_SCOPE_WORDS[list.key] ?? list.key
const notSummable = list.sphere !== 'instituicao'
const scopeListWord = (report, list) => report.unit.scopeListWords[list.key] ?? list.key
const renderSubjectScopeListSheet = ({ report, list, entry, probe, pageNo }) => {
const scene = report.unit.reachScene
const word = scopeListWord(report, list)
const notSummable = list.sphere !== report.unit.defaultSphere
const columns = scene ? scene.columns : ['Item', 'Abrangência', 'Evidência de alcance', 'Fonte']
const tableClass = scene ? `document-table ${scene.tableClass}` : 'document-table'
const colgroup = scene
? '<colgroup><col style="width:24%" /><col style="width:13%" /><col style="width:16%" /><col style="width:29%" /><col style="width:18%" /></colgroup>'
: '<colgroup><col style="width:26%" /><col style="width:14%" /><col style="width:40%" /><col style="width:20%" /></colgroup>'
return `
<article class="sheet" data-page="${entry.anchor}"${probe ? ` data-pack-section="scope:${word}"` : ''} aria-label="${htmlEscape(`Abrangência · ${list.label}`)}">
${instHeader(
report,
`Abrangência · ${list.label}`,
entry.index === 0
? `${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte · ${notSummable ? 'não somar à instituição' : 'recorte direto'}`
? `${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte · ${notSummable ? `não somar ${report.unit.notSummedTo}` : 'recorte direto'}`
: `continuação ${entry.index + 1} · ${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte`,
pageNo,
)}
@ -1435,16 +1489,16 @@ const renderInstitutionScopeListSheet = ({ report, list, entry, probe, pageNo })
${WARNING_ICON}
<div>
<p class="rule-title">${htmlEscape(report.reach.ruleTitle)}</p>
<p class="muted">Cada linha informa sua abrangência; setor e rede não são somados à instituição.</p>
<p class="muted">Cada linha informa sua abrangência; ${htmlEscape(report.unit.sumGuardNote)}</p>
</div>
</section>`
: ''
}
<section class="block">
<table class="document-table">
<table class="${tableClass}">
<caption class="sr-only">Itens do recorte com evidência de alcance e fonte</caption>
<colgroup><col style="width:26%" /><col style="width:14%" /><col style="width:40%" /><col style="width:20%" /></colgroup>
${tableHead(['Item', 'Abrangência', 'Evidência de alcance', 'Fonte'])}
${colgroup}
${tableHead(columns)}
<tbody>${entry.chunk.map((unit) => unit.html).join('')}</tbody>
</table>
</section>
@ -1452,19 +1506,17 @@ const renderInstitutionScopeListSheet = ({ report, list, entry, probe, pageNo })
</article>`
}
const renderInstitutionScopeListSheets = ({ report, list, pack, probe, nextPage }) => {
const renderSubjectScopeListSheets = ({ report, list, pack, probe, nextPage }) => {
if (list.items.length === 0) return []
const word = INSTITUTION_SCOPE_WORDS[list.key] ?? list.key
const units = institutionScopeUnits(report, list, probe)
const word = scopeListWord(report, list)
const units = subjectScopeUnits(report, list, probe)
return packedSection({
key: `scope:${word}`,
anchor: `abrangencia-${word}`,
units,
pack,
probe,
}).map((entry) =>
renderInstitutionScopeListSheet({ report, list, entry, probe, pageNo: nextPage() }),
)
}).map((entry) => renderSubjectScopeListSheet({ report, list, entry, probe, pageNo: nextPage() }))
}
const honorDate = (honor) => {
@ -1473,10 +1525,10 @@ const honorDate = (honor) => {
return '—'
}
const institutionHonors = (report) => report.eras.find((era) => era.id === 'C')?.honors?.items ?? []
const subjectHonors = (report) => report.eras.find((era) => era.id === 'C')?.honors?.items ?? []
const institutionHonorUnits = (probe) =>
institutionHonors(probe.report).map((honor, index) => ({
const subjectHonorUnits = (probe) =>
subjectHonors(probe.report).map((honor, index) => ({
layout: 'tr',
html: `<tr${unitAttrs(probe.probe, index, 'tr')}>
<td class="numeric">${htmlEscape(honorDate(honor))}</td>
@ -1486,7 +1538,7 @@ const institutionHonorUnits = (probe) =>
</tr>`,
}))
const renderInstitutionHonorSheet = ({ report, entry, probe, pageNo }) => {
const renderSubjectHonorSheet = ({ report, entry, probe, pageNo }) => {
const gap = report.gaps?.[0] ?? null
const hook = report.reach.hook ?? report.page1.hooks.items[0] ?? null
return `
@ -1543,11 +1595,11 @@ const renderInstitutionHonorSheet = ({ report, entry, probe, pageNo }) => {
</article>`
}
const renderInstitutionHonorSheets = ({ report, pack, probe, nextPage }) => {
const units = institutionHonorUnits({ report, probe })
const renderSubjectHonorSheets = ({ report, pack, probe, nextPage }) => {
const units = subjectHonorUnits({ report, probe })
if (units.length === 0) return []
return packedSection({ key: 'honors', anchor: 'titulos', units, pack, probe }).map((entry) =>
renderInstitutionHonorSheet({ report, entry, probe, pageNo: nextPage() }),
renderSubjectHonorSheet({ report, entry, probe, pageNo: nextPage() }),
)
}
@ -1556,34 +1608,64 @@ const renderInstitutionHonorSheets = ({ report, pack, probe, nextPage }) => {
* the sources sheet keeps only the acervo, limits and defeso.
*/
const gapUnits = (report, probe) =>
report.gaps.map((gap, index) => ({
layout: 'tr',
html: `<tr${unitAttrs(probe, index, 'tr')}>
report.gaps.map((gap, index) => {
const attrs = unitAttrs(probe, index, 'tr')
if (!report.unit.gapsScene) {
return {
layout: 'tr',
html: `<tr${attrs}>
<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>`,
}))
}
}
const era = gap.era && gap.era !== 'Acervo' ? `Era ${gap.era}` : 'Acervo'
const status =
index === 0
? '<span class="phase phase-pending">prioridade</span>'
: '<span class="phase">aberta</span>'
return {
layout: 'tr',
html: `<tr${attrs}>
<td class="nowrap">${htmlEscape(era)}</td>
<td><strong>${htmlEscape(gap.label)}</strong></td>
<td>${htmlEscape(gap.reason)}</td>
<td>${htmlEscape(gap.nextStep)}</td>
<td>${status}</td>
</tr>`,
}
})
const renderGapSheet = ({ report, entry, probe, pageNo }) => `
const renderGapSheet = ({ report, entry, probe, pageNo, isLast = false }) => {
const scene = report.unit.gapsScene
const columns = scene
? scene.columns
: ['Lacuna', 'Situação da busca', 'Limite editorial', 'Próximo passo']
const colgroup = scene
? '<colgroup><col style="width:12%" /><col style="width:26%" /><col style="width:24%" /><col style="width:22%" /><col style="width:16%" /></colgroup>'
: '<colgroup><col style="width:26%" /><col style="width:30%" /><col style="width:24%" /><col style="width:20%" /></colgroup>'
return `
<article class="sheet" data-page="${entry.anchor}"${probe ? ' data-pack-section="gaps"' : ''} aria-label="Lacunas explícitas">
${unitHeader(report, 'Lacunas explícitas', `O que não foi encontrado${entry.index > 0 ? ` · continuação ${entry.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>
${tableHead(['Lacuna', 'Situação da busca', 'Limite editorial', 'Próximo passo'])}
${colgroup}
${tableHead(columns)}
<tbody>${
entry.chunk.length
? entry.chunk.map((unit) => unit.html).join('')
: '<tr><td colspan="4" class="muted">Nenhuma lacuna declarada — confira a cobertura das eras.</td></tr>'
: `<tr><td colspan="${columns.length}" class="muted">Nenhuma lacuna declarada — confira a cobertura das eras.</td></tr>`
}</tbody>
</table>
${scene && isLast ? `<p class="warning-text small strong">${htmlEscape(scene.alert)}</p>` : ''}
</section>
${unitFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)}
</article>`
}
const renderGapSheets = ({ report, pack, probe, nextPage }) => {
const units = gapUnits(report, probe)
@ -1594,11 +1676,19 @@ const renderGapSheets = ({ report, pack, probe, nextPage }) => {
entry: { anchor: 'lacunas', chunk: [], index: 0 },
probe: false,
pageNo: nextPage(),
isLast: true,
}),
]
}
return packedSection({ key: 'gaps', anchor: 'lacunas', units, pack, probe }).map((entry) =>
renderGapSheet({ report, entry, probe, pageNo: nextPage() }),
const entries = packedSection({ key: 'gaps', anchor: 'lacunas', units, pack, probe })
return entries.map((entry, index) =>
renderGapSheet({
report,
entry,
probe,
pageNo: nextPage(),
isLast: index === entries.length - 1,
}),
)
}
@ -1639,12 +1729,39 @@ const renderNewsSheets = ({ report, pack, probe, nextPage }) => {
/** Acervo sample (source panel): declared sample with the total carried by the header. */
const acervoUnits = (report, probe) =>
report.acervo.items.map((row, index) => ({
layout: 'tr',
html: `<li${unitAttrs(probe, index, 'tr')}><strong>${htmlEscape(row.period)}</strong> — ${copyHtml(row.text)} ${sourceLink(row.sourceUrl)}</li>`,
}))
report.acervo.items.map((row, index) => {
const attrs = unitAttrs(probe, index, 'tr')
if (!report.unit.acervoScene) {
return {
layout: 'tr',
html: `<li${attrs}><strong>${htmlEscape(row.period)}</strong> — ${copyHtml(row.text)} ${sourceLink(row.sourceUrl)}</li>`,
}
}
const identity = report.meta.identity ?? {}
const canonical = [
identity.label,
identity.value ? `<code>${htmlEscape(identity.value)}</code>` : null,
]
.filter(Boolean)
.join(' ')
return {
layout: 'tr',
html: `<tr${attrs}>
<td class="numeric nowrap">${htmlEscape(row.period)}</td>
<td>${canonical}</td>
<td>${copyHtml(row.text)}${row.excerpt ? `<br /><span class="meta">${copyHtml(row.excerpt)}</span>` : ''}</td>
<td>${scopeBadge('area', null, report.unit)}</td>
<td>${sourceLink(row.sourceUrl)}</td>
</tr>`,
}
})
const renderAcervoSheet = ({ report, entry, probe, pageNo, isLast }) => `
const acervoStatLabel = (scene, key, fallback) =>
scene.stats?.find((stat) => stat.key === key)?.label ?? fallback
const renderAcervoSheet = ({ report, entry, probe, pageNo, isLast }) => {
const scene = report.unit.acervoScene
return `
<article class="sheet" data-page="${entry.anchor}"${probe ? ' data-pack-section="acervo"' : ''} aria-label="Acervo interno de falas (amostra)">
${unitHeader(
report,
@ -1654,18 +1771,45 @@ const renderAcervoSheet = ({ report, entry, probe, pageNo, isLast }) => `
: `continuação ${entry.index + 1}`,
pageNo,
)}
${
scene && entry.index === 0
? `<section class="acervo-stat-grid">
<div class="scope-card">
<p class="eyebrow">${htmlEscape(acervoStatLabel(scene, 'universe', 'Universo recuperado'))}</p>
<p class="scope-count tabular">${report.acervo.recorte}</p>
<p class="meta">Falas classificadas no tema canônico do recorte (2011+).</p>
</div>
<div class="scope-card card-sector">
<p class="eyebrow warning-text">${htmlEscape(acervoStatLabel(scene, 'sample', 'Amostra exibida'))}</p>
<p class="scope-count tabular">${report.acervo.items.length} de ${report.acervo.recorte}</p>
<p class="meta">Quantidade e critério explícitos; a amostra não finge exaustividade.</p>
</div>
</section>`
: ''
}
<section class="block">
<p class="eyebrow">Falas do mandato sobre o recorte</p>
<h3 class="section-title">Amostra com link direto</h3>
<ul class="tight-list">${entry.chunk.map((unit) => unit.html).join('')}</ul>
${
scene
? `<table class="document-table">
<caption class="sr-only">Amostra do acervo por data, tema canônico, trecho e abrangência</caption>
<colgroup><col style="width:11%" /><col style="width:20%" /><col style="width:37%" /><col style="width:12%" /><col style="width:20%" /></colgroup>
${tableHead(scene.columns)}
<tbody>${entry.chunk.map((unit) => unit.html).join('')}</tbody>
</table>`
: `<ul class="tight-list">${entry.chunk.map((unit) => unit.html).join('')}</ul>`
}
${
isLast
? `<p class="more-note">Amostra das falas mais recentes do recorte (${report.acervo.items.length} de ${report.acervo.recorte}); o acervo completo fica na base Teqo. ${htmlEscape(report.unit.acervoNote ?? '')}</p>`
: ''
}
${scene && isLast ? `<p class="warning-text small strong">${htmlEscape(scene.alert)}</p>` : ''}
</section>
${unitFooter(report, 'Acervo interno · amostra', pageNo)}
</article>`
}
const renderAcervoSheets = ({ report, pack, probe, nextPage }) => {
const units = acervoUnits(report, probe)
@ -1683,9 +1827,29 @@ const renderAcervoSheets = ({ report, pack, probe, nextPage }) => {
}
const renderUnitSourcesPage = (report, pageNo) => `
<article class="sheet" data-page="fontes" aria-label="Fontes e limites do dossiê institucional">
<article class="sheet" data-page="fontes" aria-label="${htmlEscape(report.unit.aria?.sources ?? 'Fontes e limites do dossiê institucional')}">
${unitHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)}
${
report.unit.sourcesHierarchy
? `<section class="block">
<p class="eyebrow">Hierarquia de fontes</p>
<h3 class="section-title">Lastro visível, item a item</h3>
<table class="document-table">
<caption class="sr-only">Camadas de fonte, seu uso e seu limite</caption>
<colgroup><col style="width:24%" /><col style="width:38%" /><col style="width:38%" /></colgroup>
${tableHead(['Camada', 'Uso', 'Limite'])}
<tbody>${report.unit.sourcesHierarchy
.map(
(row) =>
`<tr><td><strong>${htmlEscape(row.layer)}</strong></td><td>${htmlEscape(row.use)}</td><td>${htmlEscape(row.limit)}</td></tr>`,
)
.join('')}</tbody>
</table>
</section>`
: ''
}
${
report.acervo?.recorte
? `<section class="rule-warning">
@ -1720,7 +1884,7 @@ const renderUnitSourcesPage = (report, pageNo) => `
</article>`
const renderUnitSynthesisPage = (report, pageNo) => `
<article class="sheet" data-page="sintese" aria-label="Síntese da atuação institucional">
<article class="sheet" data-page="sintese" aria-label="${htmlEscape(report.unit.aria?.synthesis ?? 'Síntese da atuação institucional')}">
${unitHeader(report, 'Síntese do que foi localizado', 'Leitura dos números com lastro — contagens por recorte, nunca soma entre eles', pageNo)}
<section class="block">
<p class="eyebrow">Leitura rápida</p>
@ -1736,7 +1900,9 @@ const renderUnitSynthesisPage = (report, pageNo) => `
`<li><strong>${htmlEscape(era.label)}</strong> — ${
era.empty
? 'sem evidência suficiente (lacuna explícita)'
: `${listOf(era.numbers).length + listOf(era.actions).length} pontos nesta seção`
: report.unit.eraCoverageFromLedger
? `${report.synthesis.byEra.find((row) => row.key === era.id)?.count ?? 0} pontos com fonte`
: `${listOf(era.numbers).length + listOf(era.actions).length} pontos nesta seção`
}</li>`,
)
.join('')}</ul>
@ -1744,7 +1910,7 @@ const renderUnitSynthesisPage = (report, pageNo) => `
<div class="panel panel-warning">
<p class="eyebrow warning-text">Guardas de leitura</p>
<ul class="tight-list">
<li>Contagens por recorte: setor e rede nunca somados à instituição.</li>
<li>Contagens por recorte: ${htmlEscape(report.unit.copy?.synthesisGuard ?? 'setor e rede nunca somados à instituição.')}</li>
<li>Cada valor mantém a fase — empenho ≠ pagamento.</li>
<li>Sem fonte, não publica: ausência vira lacuna explícita.</li>
</ul>
@ -1781,8 +1947,8 @@ const renderUnitChartsPage = (report, pageNo) => {
row.segments.some((segment) => executedPhases.has(segment.key)),
)
return `
<article class="sheet" data-page="graficos" aria-label="Gráficos consolidados da atuação institucional">
${unitHeader(report, 'Gráficos consolidados', 'Como os recursos e as entregas chegaram à instituição — valores sempre com a fase', pageNo)}
<article class="sheet" data-page="graficos" aria-label="${htmlEscape(report.unit.aria?.charts ?? 'Gráficos consolidados da atuação institucional')}">
${unitHeader(report, 'Gráficos consolidados', `Como os recursos e as entregas chegaram ${report.unit.notSummedTo ?? 'à instituição'} — valores sempre com a fase`, pageNo)}
<div class="chart-grid">
<div class="chart-card">
<p class="chart-title">Recursos com execução por ano (R$)</p>
@ -1792,6 +1958,7 @@ const renderUnitChartsPage = (report, pageNo) => {
rows: executedYears.map((row) => ({ ...row, value: row.amount })),
height: 136,
format: (row) => formatMoneyCompact(row.value),
fullYearLabel: Boolean(report.unit.fullYearAxis),
})
: '<p class="muted small">Nenhum valor com fase de execução informada.</p>'
}
@ -1826,11 +1993,15 @@ const renderUnitChartsPage = (report, pageNo) => {
<p class="chart-legend">${htmlEscape(report.unit.sumGuardNote ?? '')}</p>
</div>
<div class="chart-card">
<p class="chart-title">Trajetória: pontos com fonte por ano (20xx)</p>
${columnChart({ rows: synthesis.byYear, height: 156 })}
<p class="chart-title">${htmlEscape(report.unit.byYearTitle ?? 'Trajetória: pontos com fonte por ano (20xx)')}</p>
${columnChart({
rows: synthesis.byYear,
height: 156,
fullYearLabel: Boolean(report.unit.fullYearAxis),
})}
</div>
<div class="chart-card">
<p class="chart-title">Áreas com mais registros</p>
<p class="chart-title">${htmlEscape(report.unit.byAreaTitle ?? 'Áreas com mais registros')}</p>
${barChart({
rows: synthesis.byArea.slice(0, 6).map((row) => ({ label: row.key, count: row.count })),
})}
@ -1860,7 +2031,7 @@ const renderUnitOpeningPage = (report, pageNo) => {
const opening = report.opening
if (!opening?.paragraphs?.length) return ''
return `
<article class="sheet" data-page="carta" aria-label="A contribuição de Solla para a instituição">
<article class="sheet" data-page="carta" aria-label="A contribuição de Solla para a ${htmlEscape(report.unit.contributionLabel ?? 'instituição')}">
${unitHeader(report, opening.title, 'Redação de síntese sobre os pontos com fonte — nada aqui acrescenta fato novo', pageNo)}
<section class="block">
${opening.paragraphs.map((paragraph) => `<p class="opening-paragraph">${copyHtml(paragraph)}</p>`).join('')}
@ -1880,29 +2051,40 @@ const renderUnitOpeningPage = (report, pageNo) => {
</article>`
}
const renderInstitutionDossierHtml = (report, { pack = null, probe = false } = {}) => {
const renderSubjectDossierHtml = (report, { pack = null, probe = false } = {}) => {
const buildParts = () => {
let page = 0
const nextPage = () => ++page
const parts = []
parts.push(renderInstitutionCover(report))
parts.push(renderSubjectCover(report))
page += 1
const opening = renderUnitOpeningPage(report, page + 1)
if (opening) {
parts.push(opening)
page += 1
}
parts.push(renderInstitutionSummary(report, ++page))
parts.push(renderSubjectSummary(report, ++page))
parts.push(renderUnitSynthesisPage(report, nextPage()))
parts.push(renderUnitChartsPage(report, nextPage()))
for (const era of report.eras) {
parts.push(...renderEraSheets({ report, era, pack, probe, nextPage, includeHonors: false }))
parts.push(
...renderEraSheets({
report,
era,
pack,
probe,
nextPage,
includeHonors: !report.unit.honorsSheet,
}),
)
}
parts.push(renderInstitutionScope(report, nextPage()))
parts.push(renderSubjectScope(report, nextPage()))
for (const list of report.reach.lists) {
parts.push(...renderInstitutionScopeListSheets({ report, list, pack, probe, nextPage }))
parts.push(...renderSubjectScopeListSheets({ report, list, pack, probe, nextPage }))
}
if (report.unit.honorsSheet) {
parts.push(...renderSubjectHonorSheets({ report, pack, probe, nextPage }))
}
parts.push(...renderInstitutionHonorSheets({ report, pack, probe, nextPage }))
parts.push(...renderGapSheets({ report, pack, probe, nextPage }))
parts.push(...renderNewsSheets({ report, pack, probe, nextPage }))
parts.push(...renderAcervoSheets({ report, pack, probe, nextPage }))
@ -1926,7 +2108,7 @@ ${parts.join('\n')}
</html>`
}
const institutionMdEra = (era) => {
const subjectMdEra = (era) => {
const lines = [
`## ${era.label} (${era.period})`,
'',
@ -1973,7 +2155,7 @@ const institutionMdEra = (era) => {
return lines.join('\n')
}
const renderInstitutionDossierMd = (report) => {
const renderSubjectDossierMd = (report) => {
const lines = [
`# ${report.meta.title} — ${report.meta.subjectName}`,
'',
@ -1984,11 +2166,9 @@ const renderInstitutionDossierMd = (report) => {
'',
`## Identificação`,
'',
`- **Nome:** ${report.meta.identity?.name ?? '—'}`,
`- **Tipo:** ${report.meta.identity?.kindLabel ?? '—'}`,
`- **Esfera:** ${report.meta.identity?.sphereLabel ?? '—'}`,
`- **Alcance:** ${report.meta.identity?.scopeLabel ?? '—'}`,
`- **Alias:** ${(report.meta.identity?.aliases ?? []).join(' · ') || '—'}`,
...report.unit
.identityRows(report.meta.identity ?? {})
.map(([key, value]) => `- **${key}:** ${value}`),
'',
]
if (report.opening?.paragraphs?.length) {
@ -2025,17 +2205,20 @@ const renderInstitutionDossierMd = (report) => {
lines.push('', '_Autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor._')
}
for (const era of report.eras) lines.push('', institutionMdEra(era))
for (const era of report.eras) lines.push('', subjectMdEra(era))
if (report.synthesis?.lines?.length) {
lines.push('', '## Síntese do que foi localizado', '')
for (const line of report.synthesis.lines) lines.push(`- ${line}`)
lines.push('', '### Números por recorte', '')
lines.push('| Era | Instituição | Setor | Rede | Total |', '| --- | --- | --- | --- | --- |')
lines.push(
`| Era | ${report.unit.spheres.map((sphere) => capitalize(report.unit.sphereLabels[sphere])).join(' | ')} | Total |`,
`| --- | ${report.unit.spheres.map(() => '---').join(' | ')} | --- |`,
)
for (const row of report.synthesis.byEraSphere) {
const countFor = (key) => row.segments.find((segment) => segment.key === key)?.count ?? 0
lines.push(
`| ${row.label} | ${countFor('instituicao')} | ${countFor('setor')} | ${countFor('rede')} | ${row.segments.reduce((sum, segment) => sum + segment.count, 0)} |`,
`| ${row.label} | ${report.unit.spheres.map((sphere) => countFor(sphere)).join(' | ')} | ${row.segments.reduce((sum, segment) => sum + segment.count, 0)} |`,
)
}
if (report.synthesis.byPhase.length) {
@ -2052,7 +2235,11 @@ const renderInstitutionDossierMd = (report) => {
}
}
lines.push('', '## Abrangência: instituição × setor × rede', '')
lines.push(
'',
`## Abrangência: ${report.unit.spheres.map((sphere) => report.unit.sphereLabels[sphere]).join(' × ')}`,
'',
)
lines.push(`> ${report.reach.ruleTitle}`, `> ${report.reach.ruleBody}`, '')
for (const list of report.reach.lists) {
lines.push(`### ${list.label}`, '')
@ -2071,7 +2258,7 @@ const renderInstitutionDossierMd = (report) => {
'',
`## Acervo interno (read-only)`,
'',
`_Amostra de ${report.acervo.items.length} de ${report.acervo.recorte} falas do recorte temático, com link; o acervo completo fica na base Teqo. Recorte por tema, não nominal._`,
`_Amostra de ${report.acervo.items.length} de ${report.acervo.recorte} falas do recorte temático, com link; o acervo completo fica na base Teqo. ${report.unit.copy.mdAcervoNote}_`,
'',
)
for (const row of report.acervo.items) {
@ -2115,13 +2302,11 @@ const renderInstitutionDossierMd = (report) => {
return `${lines.join('\n')}\n`
}
/** Dispatch: institution reports render with the institutional sections. */
/** Dispatch: subject-shaped reports (institution/theme) render with the shared subject sections. */
export const renderDossierHtml = (report, options) =>
isInstitutionUnit(report.unit)
? renderInstitutionDossierHtml(report, options)
isSubjectUnit(report.unit)
? renderSubjectDossierHtml(report, options)
: renderMunicipalityDossierHtml(report, options)
export const renderDossierMd = (report) =>
isInstitutionUnit(report.unit)
? renderInstitutionDossierMd(report)
: renderMunicipalityDossierMd(report)
isSubjectUnit(report.unit) ? renderSubjectDossierMd(report) : renderMunicipalityDossierMd(report)

View file

@ -215,10 +215,123 @@ const INSTITUTION_RESEARCH_CHECKLIST = [
},
]
/** Theme/area checklist (C190) — same eras, area-oriented. */
const THEME_RESEARCH_CHECKLIST = [
{
id: 'era_a_formacao',
era: 'A',
area: 'Formação',
label: 'Formação, residência e pesquisa com interface na área',
},
{
id: 'era_a_sesab',
era: 'A',
area: 'Gestão pública',
label: 'Atuação na gestão pública com efeito ou menção à área',
},
{
id: 'era_a_consultor_ms',
era: 'A',
area: 'Ministério da Saúde',
label: 'Consultoria no Ministério da Saúde na área',
},
{
id: 'era_a_conquista',
era: 'A',
area: 'Gestão municipal',
label: 'Gestão municipal de saúde com ação na área',
},
{
id: 'era_a_sas_ms',
era: 'A',
area: 'Ministério da Saúde',
label: 'Secretaria de Atenção à Saúde do MS com interface na área',
},
{
id: 'era_a_vinculo',
era: 'A',
area: 'Vínculo',
label: 'Atuação documentada na área (pesquisa, conselho, docência)',
},
{
id: 'era_b_sesab',
era: 'B',
area: 'Gestão estadual',
label: 'Gestão da SESAB (2007–2014) com a área',
},
{
id: 'era_b_politicas',
era: 'B',
area: 'Políticas',
label: 'Políticas e programas estaduais de interesse da área',
},
{
id: 'era_b_investimentos',
era: 'B',
area: 'Investimentos',
label: 'Investimentos e equipamentos ligados à área',
},
{
id: 'era_b_convenios',
era: 'B',
area: 'Convênios',
label: 'Convênios, termos e parcerias formais na área',
},
{
id: 'era_b_articulacao',
era: 'B',
area: 'Articulação',
label: 'Articulação regional e setorial da área',
},
{
id: 'era_c_discursos',
era: 'C',
area: 'Mandato',
label: 'Pronunciamentos e falas sobre a área',
},
{
id: 'era_c_proposicoes',
era: 'C',
area: 'Mandato',
label: 'Proposições e relatorias de interesse da área',
},
{
id: 'era_c_relatorias',
era: 'C',
area: 'Relatorias',
label: 'Relatorias e pareceres ligados à área',
},
{
id: 'era_c_emendas',
era: 'C',
area: 'Emendas',
label: 'Emendas e recursos destinados à área',
},
{
id: 'era_c_programas',
era: 'C',
area: 'Programas',
label: 'Programas federais e ações na área',
},
{
id: 'era_c_audiencias',
era: 'C',
area: 'Audiências',
label: 'Audiências e articulação formal com a área',
},
{
id: 'era_c_titulos',
era: 'C',
area: 'Reconhecimento',
label: 'Títulos, honrarias e homenagens ligadas à área',
},
]
/** @type {Record<string, Array<{ id: string, era: string, area: string, label: string }>>} */
const CHECKLIST_BY_UNIT = {
municipality: MUNICIPALITY_RESEARCH_CHECKLIST,
institution: INSTITUTION_RESEARCH_CHECKLIST,
theme: THEME_RESEARCH_CHECKLIST,
}
const checklistFor = (unit) => CHECKLIST_BY_UNIT[unit.id] ?? MUNICIPALITY_RESEARCH_CHECKLIST
@ -491,7 +604,7 @@ export const mergeDossierResearch = (researches, { unit } = {}) => {
const slug = list[0][slugField]
for (const research of list) {
if (research[slugField] !== slug) {
const noun = resolvedUnit.id === 'institution' ? 'instituições' : 'municípios'
const noun = resolvedUnit.nounPlural ?? 'municípios'
throw new Error(
`Pesquisas de ${noun} diferentes ("${slug}" e "${research[slugField]}") — pare e regenere.`,
)

View file

@ -2,16 +2,50 @@
* Unit seam for the Solla dossiê pipeline (C187, owner: the C186 dossiê).
*
* The dossiê was born municipality-shaped (C186). C187 adds an INSTITUTION
* recorte that must not fork the pipeline, so the shared mechanics
* (research contract, print shell, PDF emit, bulletin ledger) take a `unit`
* descriptor and default to `MUNICIPALITY_UNIT` — the C186 behaviour stays
* byte-identical when no unit is passed.
* recorte and C190 a THEME/AREA recorte; neither may fork the pipeline, so the
* shared mechanics (research contract, print shell, PDF emit, bulletin ledger)
* take a `unit` descriptor and default to `MUNICIPALITY_UNIT` — the C186
* behaviour stays byte-identical when no unit is passed. Institution and theme
* share the same `subject` shape (three scope lists, per-era flowing sheets,
* honor handling, bulletin copy) and differ only in vocabulary/copy, which the
* descriptor carries.
*
* Vocabulary note: a unit's `spheres` are the item ABRANGÊNCIA values
* (`municipio|regiao|polo` × `instituicao|setor|rede`). The catalog `sphere`
* (federal/estadual/…) is a different object and never enters an item row.
* (`municipio|regiao|polo` × `instituicao|setor|rede` × `area|segmento|rede`).
* The catalog `sphere` (federal/estadual/…) is a different object and never
* enters an item row.
*/
/**
* Theme/area copy uses the preposition the canonical label asks for
* ("pela Educação", "pelo Esporte", "pelos Direitos Humanos…"), with an explicit
* map for the 18 `SPEECH_TOPICS` labels and a neutral fallback for any label
* outside the taxonomy — never a guessed gender.
*/
const THEME_SUBJECT_PHRASES = {
Saúde: 'pela Saúde',
Educação: 'pela Educação',
Cultura: 'pela Cultura',
Esporte: 'pelo Esporte',
'Segurança Pública': 'pela Segurança Pública',
'Meio Ambiente': 'pelo Meio Ambiente',
'Economia e Trabalho': 'pela Economia e Trabalho',
'Direitos Humanos e Assistência Social': 'pelos Direitos Humanos e Assistência Social',
'Infraestrutura e Transporte': 'pela Infraestrutura e Transporte',
'Ciência e Tecnologia': 'pela Ciência e Tecnologia',
'Política e Instituições': 'pela Política e Instituições',
'Agricultura e Agropecuária': 'pela Agricultura e Agropecuária',
'Habitação e Cidades': 'pela Habitação e Cidades',
'Comunicação e Mídia': 'pela Comunicação e Mídia',
'Igualdade Racial': 'pela Igualdade Racial',
'Mulheres e Gênero': 'pelas Mulheres e Gênero',
Juventude: 'pela Juventude',
'Pessoa com Deficiência': 'pela Pessoa com Deficiência',
}
/** @param {string} label @returns {string} */
export const themeSubjectPhrase = (label) => THEME_SUBJECT_PHRASES[label] ?? `pela área de ${label}`
export const MUNICIPALITY_UNIT = {
id: 'municipality',
/** Key that carries the resolved unit slug in research/snapshot files. */
@ -36,11 +70,16 @@ export const MUNICIPALITY_UNIT = {
eraNumbersTitle: 'Recursos e entregas quantificáveis',
eraActionsTitle: 'O que fez — item, alcance e lastro',
honorsTitle: 'Títulos, honrarias e vínculos locais',
/** C186 literal kept verbatim (byte-parity); subject units override it. */
synthesisSumGuard: 'Setor e rede não são somados à instituição.',
}
export const INSTITUTION_UNIT = {
id: 'institution',
shape: 'subject',
/** Key that holds the recorte identity in research/snapshot files. */
slugField: 'institutionSlug',
snapshotField: 'institution',
spheres: ['instituicao', 'setor', 'rede'],
defaultSphere: 'instituicao',
sphereLabels: { instituicao: 'instituição', setor: 'setor', rede: 'rede' },
@ -58,11 +97,336 @@ export const INSTITUTION_UNIT = {
eraNumbersTitle: 'Objeto, valor, fase e alcance',
eraActionsTitle: 'Papéis com evidência visível',
honorsTitle: 'Títulos, honrarias e vínculos institucionais',
synthesisSumGuard: 'Setor e rede não são somados à instituição.',
aria: {
synthesis: 'Síntese da atuação institucional',
charts: 'Gráficos consolidados da atuação institucional',
sources: 'Fontes e limites do dossiê institucional',
scope: 'Painel de abrangência institucional',
},
/** Subject-shape fields (shared with THEME_UNIT). */
nounPlural: 'instituições',
subjectNoun: 'instituição',
docLabel: 'Dossiê institucional',
contributionLabel: 'instituição',
scopeLabel: 'no recorte institucional',
summaryLinkEyebrow: '01 · vínculo com a instituição',
honorsSheet: true,
notSummedTo: 'à instituição',
scopeLists: [
{ key: 'institution', sphere: 'instituicao' },
{ key: 'sector', sphere: 'setor' },
{ key: 'network', sphere: 'rede' },
],
scopeListWords: { institution: 'instituicao', sector: 'setor', network: 'rede' },
scopeCardClass: { instituicao: '', setor: 'card-sector', rede: 'card-network' },
eraMethod: {
A: 'Registros anteriores a 2007 que citem nominalmente a instituição ou comprovem vínculo. Cargo geral não prova ação institucional.',
B: 'A gestão estadual é lida por equipamento, programa, convênio e obra; cada linha informa abrangência e fonte. Setor e rede não somam à instituição.',
C: 'Mandato, relatorias, parcerias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase.',
},
eraRecovery: {
A: 'biografias oficiais · atos e diários · acervos institucionais · busca web datada',
B: 'DOE-BA (DOOL) · notícias SESAB/instituição · Transparência Bahia · busca web datada',
C: 'API Câmara · Portal da Transparência · acervo interno read-only · busca web datada',
},
identityBadges: (identity) =>
[identity.kindLabel, identity.sphereLabel, identity.scope ? identity.scopeLabel : null].filter(
Boolean,
),
identityRows: (identity) =>
[
['Nome', identity.name],
['Tipo', identity.kindLabel],
['Esfera', identity.sphereLabel],
['Alcance', identity.scopeLabel],
['Alias', (identity.aliases ?? []).join(' · ') || '—'],
].filter(([, value]) => Boolean(value)),
coverRows: (report) => [{ label: 'Identificação', badges: report.meta.identityBadges }],
copy: {
coverSubtitle: () => 'O que Jorge Solla fez por, na e com a instituição ao longo da carreira',
coverHowToUse:
'Localize o vínculo ou a entrega, confira fonte, data, fase e abrangência. Transforme toda lacuna em tarefa de apuração. Este arquivo não é fala pronta nem peça pública.',
coverScope:
'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara dos Deputados 2015–2027. Instituição, setor e rede separados; sem fonte, não entra como entrega.',
coverScopeWords: 'instituição, setor e rede separados',
coverAsset: ['selo da instituição', 'uso autorizado e origem'],
openingTitle: (subjectName) => `O que Jorge Solla fez pela e na ${subjectName}`,
reachRuleTitle: 'Setor/rede não é a instituição. Não some os recortes.',
reachRuleBody:
'Uma política para uma categoria ou uma articulação com entidades correlatas pode alcançar a instituição sem constituir entrega exclusiva para ela.',
synthesisGuard: 'setor e rede nunca somados à instituição.',
coverageNote: 'Instituição, setor e rede separados; sem fonte, não entra como entrega.',
timelineEmpty: 'Nenhum vínculo datado com fonte — ver lacunas explícitas.',
eraEmpty: {
body: 'A pesquisa não encontrou documento que sustenta uma afirmação institucional. Isso não prova que não houve vínculo ou atuação; prova apenas que o dossiê ainda não pode afirmá-los.',
work: [
'Busca nominal por instituição e aliases.',
'Consulta a fontes oficiais e acervos datados.',
'Triagem de resultados por vínculo e abrangência.',
'Descarte de menções sem lastro suficiente.',
],
todo: [
'Solicitar consulta ao arquivo físico.',
'Validar nome histórico e unidade da instituição.',
'Recuperar ato, ata ou documento contemporâneo.',
'Repetir busca com alias confirmado.',
],
rule: (eraLabel) =>
`Não preencher a seção por memória, cargo provável ou texto de outra era. Registrar “${eraLabel}: evidência institucional não localizada nas fontes consultadas”.`,
},
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.',
'Menção a entidade correlata (setor/rede) não é atribuição à instituição.',
],
editorial: [
'Sem fonte, não publica.',
'URL e data acompanham cada afirmação não trivial.',
'Instituição, setor e rede permanecem separados.',
'Valor sempre informa a fase de execução.',
],
},
mdAcervoNote: 'Recorte por tema, não nominal.',
bulletin: {
aria: 'Modelo de boletim informativo institucional de uma página',
openingLabel: 'Uma trajetória de trabalho junto à instituição',
openingTitle: (subjectName) => `O que Jorge Solla fez pela e na ${subjectName}`,
lead: 'Ações, recursos e articulações explicados de forma direta — somente o que já foi documentado no dossiê institucional.',
leadSparse:
'Esta versão tem poucos registros confirmados. Em vez de preencher espaço, mostra só os fatos que o dossiê sustenta.',
asset: 'Solla na instituição ou na ação citada',
assetSparse: 'somente registro relacionado ao fato',
assetSelo: ['marca autorizada da instituição', 'arquivo vetorial'],
assetClipping: ['registro real da ação', 'origem e licença'],
moreEmpty:
'Conferir as lacunas explícitas no dossiê institucional antes de ampliar o boletim.',
defeso:
'fase acompanha cada valor · instituição, setor e rede não são somados · sem percentual estadual absoluto.',
orig: 'Conteúdo selecionado do dossiê institucional.',
sparseRule:
'Não repetir item, não ampliar efeito e não preencher com ação de setor ou rede como se fosse entrega exclusiva da instituição.',
title: 'Uma trajetória de compromisso com o serviço público',
titleSparse: 'contexto de carreira · não substitui fato institucional',
highlightsTitle: 'Resultados em leitura rápida',
highlightsTitleSparse: 'Poucos fatos, sem enchimento',
moreTitle: 'Outras ações confirmadas',
moreTitleSparse: 'Outros registros confirmados',
},
},
}
export const THEME_UNIT = {
id: 'theme',
shape: 'subject',
slugField: 'themeSlug',
snapshotField: 'theme',
spheres: ['area', 'segmento', 'rede'],
defaultSphere: 'area',
sphereLabels: { area: 'área', segmento: 'segmento', rede: 'rede' },
sphereBadgeClass: { area: '', segmento: 'scope-sector', rede: 'scope-network' },
breadthRank: { area: 0, segmento: 1, rede: 2 },
researchDir: 'data/dossie-solla-tema',
series: 'Série temática · por área',
title: 'Dossiê Solla por tema/área',
bulletinTitle: 'Boletim informativo modelo',
bulletinMoreLimit: 14,
bulletinKicker: 'Boletim informativo · série temática',
sphereColumnLabel: 'Abrangência',
notSummedInline: 'não somar à área',
sumGuardNote: 'segmento e rede não são somados à área.',
acervoNote: 'recorte pela tag do acervo (Speech.topics); segmento e rede não entram nesta conta.',
eraNumbersTitle: 'Objeto, valor, ano, fase e abrangência',
eraActionsTitle: 'Papéis e iniciativas com evidência visível',
honorsTitle: 'Títulos, honrarias e vínculos',
synthesisSumGuard: 'Segmento e rede não são somados à área.',
aria: {
synthesis: 'Síntese da atuação por área',
charts: 'Gráficos consolidados da atuação por área',
sources: 'Fontes e limites do dossiê temático',
scope: 'Painel de abrangência por área',
},
nounPlural: 'temas',
subjectNoun: 'área',
docLabel: 'Dossiê temático',
contributionLabel: 'área',
scopeLabel: 'no recorte da área',
summaryLinkEyebrow: '01 · vínculo com a área',
honorsSheet: false,
notSummedTo: 'à área',
scopeLists: [
{ key: 'area', sphere: 'area' },
{ key: 'segment', sphere: 'segmento' },
{ key: 'network', sphere: 'rede' },
],
scopeListWords: { area: 'area', segment: 'segmento', network: 'rede' },
scopeCardClass: { area: '', segmento: 'card-sector', rede: 'card-network' },
/** Theme-only scene overrides (the institution keeps the shared defaults). */
acervoScene: {
stats: [
{ key: 'universe', label: 'Universo recuperado' },
{ key: 'sample', label: 'Amostra exibida' },
],
columns: ['Data', 'Tema canônico', 'Trecho/contexto', 'Abrangência', 'Link'],
alert:
'O tema prova pertinência ao recorte do acervo; não prova por si só entrega, autoria material ou execução financeira.',
},
reachScene: {
columns: ['Item', 'Era / ano', 'Valor / fase', 'Evidência temática', 'Fonte'],
tableClass: 'document-table--reach',
},
gapsScene: {
columns: ['Era', 'Lacuna', 'Já consultado', 'Próxima busca', 'Status'],
alert:
'Portais que não filtram por área não autorizam atribuição. Sem fonte temática, a linha permanece lacuna — nunca zero.',
},
sourcesHierarchy: [
{
layer: 'Fontes primárias',
use: 'Atos, diários oficiais e documentos contemporâneos',
limit: 'Sem documento, não entra',
},
{
layer: 'Fontes institucionais',
use: 'Portais oficiais e de transparência',
limit: 'Não filtram por área',
},
{
layer: 'Acervo interno',
use: 'Falas classificadas por tema (2011+)',
limit: 'Prova pertinência, não entrega',
},
{
layer: 'Busca web datada',
use: 'Notícias e publicações com data',
limit: 'Resultado não prova ausência',
},
],
/** Síntese/rótulos temáticos (institution/município keep the shared literals). */
byAreaTitle: 'Tipos de atuação com mais registros',
byYearTitle: 'Pontos com fonte por ano',
fullYearAxis: true,
eraCoverageFromLedger: true,
moneyPhaseStyle: 'separate',
/**
* Theme bulletins read the year when a fact has no money value, and mark the
* card textual when neither exists (never a fake zero).
*/
bulletinNumberFallback: true,
eraMethod: {
A: 'Registros anteriores a 2007 que tratem diretamente da área ou comprovem atuação relacionada. Cargo geral não prova ação na política.',
B: 'A gestão estadual é lida por programa, projeto, convênio e obra ligados à área; cada linha informa abrangência e fonte. Segmento e rede não somam à área.',
C: 'Mandato, relatorias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase.',
},
eraRecovery: {
A: 'biografias oficiais · atos e diários · acervos de políticas públicas · busca web datada',
B: 'DOE-BA (DOOL) · notícias SESAB · Transparência Bahia · SIOPS/DATASUS · busca web datada',
C: 'API Câmara · Portal da Transparência · acervo interno read-only · busca web datada',
},
identityBadges: (identity) => [identity.label].filter(Boolean),
/** Theme bulletin pills: recorte label, canonical token (mono) and taxonomy origin. */
bulletinPills: (identity) => [
{ label: 'Área' },
{ label: identity.value, code: true },
{ label: identity.taxonomyNote ?? 'Taxonomia do acervo' },
],
identityRows: (identity) =>
[
['Área', identity.label],
['Token', identity.value],
['Origem', identity.taxonomyNote ?? 'Taxonomia do acervo'],
['Filtro', 'Speech.topics'],
].filter(([, value]) => Boolean(value)),
coverRows: (report) => [
{ label: 'Área', badges: report.meta.identityBadges },
{
label: 'Valor canônico',
code: report.meta.identity?.value,
note: 'taxonomia do acervo / Speech.topics',
},
],
copy: {
coverSubtitle: (subjectName) =>
`O que Jorge Solla fez ${themeSubjectPhrase(subjectName)} ao longo da carreira`,
coverHowToUse:
'Localize a entrega, confira fonte, data, fase e abrangência. A área vem da taxonomia canônica; segmento e rede não são somados a ela.',
coverScope:
'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara 2015–2027. Área, segmento e rede separados; sem fonte, não entra como entrega.',
coverScopeWords: 'área, segmento e rede separados',
coverAsset: ['selo autorizado', 'e origem'],
openingTitle: (subjectName) => `O que Jorge Solla fez ${themeSubjectPhrase(subjectName)}`,
reachRuleTitle: 'Segmento/rede não é a área. Não some os recortes.',
reachRuleBody:
'Uma ação dirigida a um segmento ou a uma rede pode alcançar a política de área sem constituir entrega exclusiva dela.',
synthesisGuard: 'segmento e rede nunca somados à área.',
coverageNote: 'Área, segmento e rede separados; sem fonte, não entra como entrega.',
timelineEmpty: 'Nenhum vínculo datado com fonte — ver lacunas explícitas.',
eraEmpty: {
body: 'A pesquisa não encontrou documento que sustente uma afirmação sobre a área. Isso não prova ausência de atuação; prova apenas que o dossiê ainda não pode afirmá-la.',
work: [
'Busca pelo tema canônico e termos correlatos.',
'Consulta a fontes oficiais e acervos datados.',
'Triagem de resultados por atribuição à área.',
'Descarte de menções sem lastro suficiente.',
],
todo: [
'Solicitar consulta a acervo especializado.',
'Validar a taxonomia e o recorte do tema.',
'Recuperar ato, publicação ou documento contemporâneo.',
'Repetir busca com termos correlatos.',
],
rule: (eraLabel) =>
`Não preencher a seção por memória, cargo provável ou tema vizinho. Registrar “${eraLabel}: evidência da área não localizada nas fontes consultadas”.`,
},
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.',
'Menção a segmento ou rede não é atribuição exclusiva da área.',
],
editorial: [
'Sem fonte, não publica.',
'URL e data acompanham cada afirmação não trivial.',
'Área, segmento e rede permanecem separados.',
'Valor sempre informa a fase de execução.',
'Atribuição temática a emenda ou proposição só entra com fonte; sem fonte, vira lacuna.',
],
},
mdAcervoNote: 'Recorte pela tag do acervo, não nominal.',
bulletin: {
aria: 'Modelo de boletim informativo temático de uma página',
openingLabel: 'Uma trajetória de trabalho pela área',
openingTitle: (subjectName) => `O que Jorge Solla fez ${themeSubjectPhrase(subjectName)}`,
lead: 'Ações, recursos e articulações explicados de forma direta — somente o que já foi documentado no dossiê da área.',
leadSparse:
'Esta versão tem poucos registros confirmados. Em vez de preencher espaço, mostra só os fatos que o dossiê sustenta.',
asset: 'Solla na ação citada',
assetSparse: 'somente registro relacionado ao fato',
assetSelo: ['selo autorizado', 'arquivo vetorial'],
assetClipping: ['registro real da ação', 'origem e licença'],
moreEmpty: 'Conferir as lacunas explícitas no dossiê da área antes de ampliar o boletim.',
defeso:
'fase acompanha cada valor · área, segmento e rede não são somados · sem percentual estadual absoluto.',
orig: 'Conteúdo selecionado do dossiê da área.',
sparseRule:
'Não repetir item, não ampliar efeito e não preencher com ação de segmento ou rede como se fosse entrega exclusiva da área.',
title: 'Uma trajetória de compromisso com o serviço público',
titleSparse: 'contexto de carreira · não substitui fato da área',
highlightsTitle: 'Resultados em leitura rápida',
highlightsTitleSparse: 'Poucos fatos, sem enchimento',
moreTitle: 'Outras ações confirmadas',
moreTitleSparse: 'Outros registros confirmados',
},
},
}
const DOSSIER_UNITS = {
municipality: MUNICIPALITY_UNIT,
institution: INSTITUTION_UNIT,
theme: THEME_UNIT,
}
/**
@ -81,5 +445,5 @@ export const resolveDossierUnit = (unit) => {
return unit
}
/** Central predicate for the dispatch points (dossiê, boletim, blocks). */
export const isInstitutionUnit = (unit) => resolveDossierUnit(unit).id === 'institution'
/** Institution and theme share the `subject` shape (one builder/renderer). */
export const isSubjectUnit = (unit) => resolveDossierUnit(unit).shape === 'subject'

View file

@ -32,6 +32,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/check-test-locations.mjs',
'scripts/cityReportSnapshot.mjs',
'scripts/institutionSnapshot.mjs',
'scripts/themeSnapshot.mjs',
'scripts/lib/agent-forgejo.mjs',
'scripts/lib/agent-plan-lifecycle.mjs',
'scripts/lib/agent-pool-cursor.mjs',

106
scripts/themeSnapshot.mjs Normal file
View file

@ -0,0 +1,106 @@
/**
* Snapshot composer for the theme/area dossiê (C190).
*
* Runs on the homeserver against production, read-only: the recorte IS the
* acervo tag (`Speech.topics`), so there is no institution-style bridge and no
* nominal match — the theme itself is the filter. Reuses the C163 speech
* loaders (no parallel source of truth), projects out PII and returns plain
* JSON. The render step never touches the database again.
*
* Integration layer, not a pure lib: it imports Payload-coupled utilities and
* is exercised by `tests/int/themeSnapshot.int.spec.ts`.
*/
import { excerptOffsetSeconds } from '../src/lib/speechVod.ts'
import { buildSpeechListWhere } from '../src/utilities/speech/speechListFilters.ts'
const SPEECH_LIMIT = 15
const SPEECH_SELECT = {
speechAt: true,
type: true,
phase: true,
summary: true,
officialTextUrl: true,
youtubeUrl: true,
vodPlaybackUrl: true,
excerptTMs: true,
eventStartAt: true,
topics: true,
}
/**
* Projects one speech: the official summary says what it is. There is no
* `mentionExcerpt` here — the acervo tag is the recorte, not a literal mention.
*/
const projectSpeechRow = (doc) => ({
id: doc.id,
speechAt: doc.speechAt ?? null,
type: doc.type ?? null,
phase: doc.phase ?? null,
summary: doc.summary ?? null,
officialTextUrl: doc.officialTextUrl ?? null,
youtubeUrl: doc.youtubeUrl ?? null,
vodPlaybackUrl: doc.vodPlaybackUrl ?? null,
youtubeExcerptStartSeconds: excerptOffsetSeconds(doc.excerptTMs, doc.eventStartAt),
topics: Array.isArray(doc.topics) ? doc.topics : [],
})
/**
* @param {{
* payload: any,
* actor: any,
* identity: { slug: string, value: string, label: string, taxonomyNote?: string | null },
* readAt: string,
* codeSha?: string | null,
* database?: string | null,
* }} params
* @returns {Promise<any>}
*/
export const composeThemeSnapshot = async ({
payload,
actor,
identity,
readAt,
codeSha = null,
database = null,
}) => {
const topics = [identity.value]
const gaps = []
const result = await payload.find({
collection: 'speech',
where: buildSpeechListWhere({ page: 1, topics }),
depth: 0,
limit: SPEECH_LIMIT,
sort: '-speechAt',
select: SPEECH_SELECT,
user: actor,
overrideAccess: false,
})
const docs = result.docs
const totalCount = result.totalDocs
if (docs.length === 0) {
gaps.push({
id: 'acervo_sem_falas',
reason: `Nenhuma fala do acervo no tema ${identity.value} — o acervo cobre 2011+.`,
})
}
return {
meta: { readAt, codeSha, database, kind: 'theme' },
theme: {
slug: identity.slug,
value: identity.value,
label: identity.label,
taxonomyNote: identity.taxonomyNote ?? 'Taxonomia do acervo',
topics,
},
speeches: {
topics,
totalCount,
rows: docs.map((doc) => projectSpeechRow(doc)),
},
gaps,
}
}

View file

@ -75,6 +75,18 @@ const scopeByKey = new Map(
]),
)
/**
* Resolves a canonical topic token (value or pt-BR label, accent/case
* insensitive) to its taxonomy entry. Fail-closed: unknown tokens return null —
* callers never invent a slug outside `SPEECH_TOPICS` (C190).
*/
export const resolveSpeechTopic = (token: string): (typeof SPEECH_TOPICS)[number] | null => {
const key = facetKey(token ?? '')
if (!key) return null
const value = topicByKey.get(key)
return SPEECH_TOPICS.find((topic) => topic.value === value) ?? null
}
// ---------------------------------------------------------------------------
// LLM response parsing and merge
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,124 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { composeThemeSnapshot } from '../../scripts/themeSnapshot.mjs'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
// C190: the theme/area snapshot recorte IS the acervo tag (`Speech.topics`), so
// there is no nominal bridge. Pins the read projection (no PII), the topic
// filter and the explicit gap when the recorte is empty.
let payload: Payload
const createdSpeechIds: number[] = []
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const identity = {
slug: 'educacao',
value: 'educacao',
label: 'Educação',
taxonomyNote: 'Taxonomia do acervo',
}
describe('theme snapshot (C190)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterAll(async () => {
for (const id of createdSpeechIds) {
try {
await payload.delete({ collection: 'speech', id, depth: 0, overrideAccess: true })
} catch {
// already deleted
}
}
})
it(
'projects the topic-matched speeches without PII and records the recorte',
{
timeout: 30_000,
},
async () => {
const fixtures = campaignFixtures()
const candidate = await fixtures.createCampaignUser('candidate')
const speech = await payload.create({
collection: 'speech',
data: {
sourceKey: `test-theme-${randomUUID()}`,
speechAt: '2024-05-10T17:28',
type: 'DISCURSO',
phase: 'Discurso',
summary: 'Defesa do financiamento da educação básica.',
officialTranscript: 'O SR. JORGE SOLLA (Bloco/PT - BA) - Sr. Presidente...',
keywords: ['escola', 'educação'],
topics: ['educacao'],
classifiedBy: 'gazetteer',
},
depth: 0,
overrideAccess: true,
})
createdSpeechIds.push(speech.id)
const actor = { ...candidate, collection: 'campaignUser' as const }
const snapshot = await composeThemeSnapshot({
payload,
actor,
identity,
readAt: '2026-09-18T11:00:00.000Z',
codeSha: 'test-sha',
database: 'localhost:5432/teqo_test',
})
expect(snapshot.meta.kind).toBe('theme')
expect(snapshot.theme).toMatchObject({
slug: 'educacao',
value: 'educacao',
label: 'Educação',
})
expect(snapshot.theme.topics).toEqual(['educacao'])
expect(snapshot.speeches.rows.some((row: { id: number }) => row.id === speech.id)).toBe(true)
const row = snapshot.speeches.rows.find(
(candidateRow: { id: number }) => candidateRow.id === speech.id,
)!
expect('officialTranscript' in row).toBe(false)
expect('mentionExcerpt' in row).toBe(false)
expect(JSON.stringify(snapshot)).not.toContain('@')
},
)
it('declares an explicit gap when the theme has no speech', async () => {
const fixtures = campaignFixtures()
const candidate = await fixtures.createCampaignUser('candidate')
const actor = { ...candidate, collection: 'campaignUser' as const }
const snapshot = await composeThemeSnapshot({
payload,
actor,
identity: {
slug: 'pessoa-deficiencia',
value: 'pessoa-deficiencia',
label: 'Pessoa com Deficiência',
taxonomyNote: 'Taxonomia do acervo',
},
readAt: '2026-09-18T11:00:00.000Z',
})
expect(snapshot.speeches.rows).toEqual([])
expect(snapshot.speeches.totalCount).toBe(0)
expect(snapshot.gaps).toContainEqual(expect.objectContaining({ id: 'acervo_sem_falas' }))
})
})

View file

@ -0,0 +1,518 @@
import { describe, expect, it } from 'vitest'
import { SPEECH_TOPICS } from '@/lib/speechFacets'
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'
import { THEME_UNIT, themeSubjectPhrase } from '../../scripts/lib/dossieUnit.mjs'
// C190: theme/area content model + render. Grounds the third recorte: area
// identity (label + canonical value), `area|segmento|rede` never summed, per-row
// phase/source, empty-era state instead of silent omission, and a one-page
// boletim that inherits only sourced facts.
const generatedAt = new Date('2026-09-18T12:00:00.000Z')
const snapshot = {
meta: { readAt: '2026-09-18T11:00:00.000Z' },
theme: {
slug: 'educacao',
value: 'educacao',
label: 'Educação',
taxonomyNote: 'Taxonomia do acervo',
topics: ['educacao'],
},
speeches: {
topics: ['educacao'],
totalCount: 1,
rows: [
{
id: 21,
speechAt: '2024-05-10T00:00:00.000Z',
summary: 'Defesa do financiamento da educação básica.',
officialTextUrl: 'https://camara.test/discurso-educacao',
},
],
},
}
const item = (id: string, era: 'A' | 'B' | 'C', overrides: Record<string, unknown> = {}) => ({
id,
answer: `Ação ${id} documentada`,
sphere: 'area',
sourceUrl: `https://educacao.test/${id}`,
sourceDate: '2024-06-01',
era,
...overrides,
})
const eraFile = (era: 'A' | 'B' | 'C', items: unknown[]) => ({
themeSlug: 'educacao',
era,
researchedAt: '2026-09-18T10:00:00.000Z',
items,
news: [
{
title: 'Notícia sobre educação',
outlet: 'Educação Hoje',
publishedAt: '2024-05-01',
url: 'https://educacao.test/noticia',
},
],
})
const research = mergeDossierResearch(
[
normalizeDossierResearchInput(
eraFile('A', [
item('era_a_formacao', 'A'),
item('era_a_vinculo', 'A', { sphere: 'segmento' }),
item('era_a_sesab', 'A', { sphere: 'rede' }),
]),
{ unit: THEME_UNIT },
),
normalizeDossierResearchInput(
eraFile('B', [
item('era_b_sesab', 'B', {
numbers: [{ label: 'Investimento', value: 'R$ 2,0 mi', year: '2012', phase: 'pago' }],
}),
]),
{ unit: THEME_UNIT },
),
normalizeDossierResearchInput(
eraFile('C', [
item('era_c_emendas', 'C', {
numbers: [{ label: 'Emenda', value: 'R$ 5,0 mi', year: '2024', phase: 'empenhado' }],
}),
item('era_c_titulos', 'C', { answer: 'Homenagem da área' }),
]),
{ unit: THEME_UNIT },
),
],
{ unit: THEME_UNIT },
)
const report = buildDossierReport({
snapshot,
research,
generatedAt,
unit: THEME_UNIT,
})
describe('theme era without evidence', () => {
const emptyReport = buildDossierReport({
snapshot,
research: mergeDossierResearch(
(['A', 'B', 'C'] as const).map((era) =>
normalizeDossierResearchInput(eraFile(era, []), { unit: THEME_UNIT }),
),
{ unit: THEME_UNIT },
),
generatedAt,
unit: THEME_UNIT,
})
it('marks an evidence-less era as an explicit gap, never a silent blank', () => {
expect(emptyReport.eras.every((era) => era.empty)).toBe(true)
const html = renderDossierHtml(emptyReport)
expect(html).toContain('Lacuna explícita · não é “zero”')
expect(html).toContain('Nenhuma fonte suficiente foi localizada para esta era.')
expect(html).toContain('evidência da área não localizada')
expect(html).toContain('empty-panel')
})
})
describe('buildDossierReport (theme unit)', () => {
it('maps the canonical area labels to the right subject preposition', () => {
expect(themeSubjectPhrase('Saúde')).toBe('pela Saúde')
expect(themeSubjectPhrase('Esporte')).toBe('pelo Esporte')
expect(themeSubjectPhrase('Direitos Humanos e Assistência Social')).toBe(
'pelos Direitos Humanos e Assistência Social',
)
expect(themeSubjectPhrase('Mulheres e Gênero')).toBe('pelas Mulheres e Gênero')
expect(themeSubjectPhrase('Fora da taxonomia')).toBe('pela área de Fora da taxonomia')
})
it('has a mapped phrase for every canonical topic label (no silent fallback)', () => {
for (const { label } of SPEECH_TOPICS) {
expect(themeSubjectPhrase(label), label).not.toBe(`pela área de ${label}`)
}
})
it('carries the area identity and canonical value', () => {
expect(report.unit.id).toBe('theme')
expect(report.meta.subjectName).toBe('Educação')
expect(report.meta.identity?.value).toBe('educacao')
expect(report.meta.identityBadges).toEqual(['Educação'])
})
it('keeps all three eras, marking evidence-less ones as explicit gaps', () => {
expect(report.eras.map((era) => era.id)).toEqual(['A', 'B', 'C'])
expect(report.eras.every((era) => typeof era.empty === 'boolean')).toBe(true)
})
it('never sums segmento/rede into the area list', () => {
const listFor = (key: string) => report.reach.lists.find((list) => list.key === key)
expect(listFor('area')?.items.every((row) => row.sphere === 'area')).toBe(true)
expect(listFor('area')?.items.some((row) => row.id === 'era_a_vinculo')).toBe(false)
expect(listFor('segment')?.total).toBe(1)
expect(listFor('network')?.total).toBe(1)
})
it('declares overflow instead of truncating silently (C188 rule)', () => {
for (const list of report.reach.lists) {
expect(list).toMatchObject({ total: expect.any(Number), omitted: expect.any(Number) })
}
expect(report.page1.deliveries.omitted).toBeGreaterThanOrEqual(0)
})
it('shows every sourced item — theme lists are never capped', () => {
const manyItems = [
item('era_c_emendas', 'C', { sphere: 'segmento' }),
item('era_c_programas', 'C', { sphere: 'segmento' }),
item('era_c_relatorias', 'C', { sphere: 'segmento' }),
]
const fullReport = buildDossierReport({
snapshot,
research: mergeDossierResearch(
[normalizeDossierResearchInput(eraFile('C', manyItems), { unit: THEME_UNIT })],
{ unit: THEME_UNIT },
),
generatedAt,
unit: THEME_UNIT,
})
const list = fullReport.reach.lists.find((row) => row.key === 'segment')
expect(list).toMatchObject({ total: 3, omitted: 0 })
expect(list?.items).toHaveLength(3)
const fullHtml = renderDossierHtml(fullReport)
expect(fullHtml).not.toContain('e mais')
for (const row of manyItems) expect(fullHtml).toContain(row.answer)
})
it('consolidates money by phase and never counts non-money numbers as money', () => {
const synthesis = report.synthesis
expect(synthesis).toBeTruthy()
if (!synthesis) return
const moneyItem = item('era_b_investimentos', 'B', {
numbers: [{ label: 'Convênio', value: 'R$ 1,2 mi', year: '2012', phase: 'pago' }],
})
const countItem = item('era_b_politicas', 'B', {
numbers: [{ label: 'Escolas', value: '175', year: '2010', phase: 'nao_informado' }],
})
const moneyReport = buildDossierReport({
snapshot,
research: mergeDossierResearch(
[normalizeDossierResearchInput(eraFile('B', [moneyItem, countItem]), { unit: THEME_UNIT })],
{ unit: THEME_UNIT },
),
generatedAt,
unit: THEME_UNIT,
})
const moneySynthesis = moneyReport.synthesis
expect(moneySynthesis).toBeTruthy()
if (!moneySynthesis) return
expect(moneySynthesis.totals.money).toBe(1)
expect(moneySynthesis.moneyTotals.executed).toBe(1_200_000)
const paid = moneySynthesis.moneyByPhase.find((row: { key: string }) => row.key === 'pago')
expect(paid).toMatchObject({ count: 1, amount: 1_200_000 })
expect(moneySynthesis.lines.join(' ')).toContain('R$ 1,2 mi pago')
const html = renderDossierHtml(moneyReport)
expect(html).toContain('R$ 1,2 mi')
expect(html).toContain('Recursos com execução por ano')
})
it('derives bulletinFacts only from sourced items', () => {
expect(report.bulletinFacts.length).toBeGreaterThan(0)
expect(report.bulletinFacts.every((fact) => Boolean(fact.sourceUrl))).toBe(true)
})
it('consumes the read-only acervo rows as sourced evidence (never silent)', () => {
expect(report.acervo.items.length).toBe(1)
expect(report.acervo.items[0]).toMatchObject({ period: '2024', sourceUrl: expect.any(String) })
expect(report.bulletinFacts.some((fact) => fact.id === 'fala-21')).toBe(true)
expect(renderDossierMd(report)).toContain('## Acervo interno (read-only)')
})
})
describe('renderDossierHtml/Md (theme unit)', () => {
const html = renderDossierHtml(report)
const md = renderDossierMd(report)
it('anchors every theme page', () => {
for (const anchor of [
'capa',
'resumo',
'sintese',
'graficos',
'era-a',
'era-b',
'era-c',
'abrangencia',
'abrangencia-area',
'abrangencia-segmento',
'abrangencia-rede',
'lacunas',
'fontes',
]) {
expect(html).toContain(`data-page="${anchor}"`)
}
// Theme has no separate honors sheet: títulos render inline in the era.
expect(html).not.toContain('data-page="titulos"')
expect(html).toContain('Homenagem da área')
})
it('prints the theme acervo scene with stats, canonical columns and the scope alert', () => {
expect(html).toContain('acervo-stat-grid')
expect(html).toContain('Universo recuperado')
expect(html).toContain('Amostra exibida')
expect(html).toContain('Tema canônico')
expect(html).toContain('Trecho/contexto')
expect(html).toContain('<code>educacao</code>')
expect(html).toContain(
'O tema prova pertinência ao recorte do acervo; não prova por si só entrega',
)
})
it('prints the theme reach scene with the five columns and the ledger year', () => {
expect(html).toContain('document-table--reach')
expect(html).toContain('Era / ano')
expect(html).toContain('Valor / fase')
expect(html).toContain('Evidência temática')
expect(html).toContain('Era A')
})
it('prints the theme gap scene with era, status and the attribution alert', () => {
expect(html).toContain('Já consultado')
expect(html).toContain('Próxima busca')
expect(html).toContain('prioridade')
expect(html).toContain('<span class="phase">aberta</span>')
expect(html).toContain(
'Portais que não filtram por área não autorizam atribuição. Sem fonte temática, a linha permanece lacuna — nunca zero.',
)
})
it('prints the theme source hierarchy above the limits', () => {
expect(html).toContain('Hierarquia de fontes')
expect(html).toContain('Fontes primárias')
expect(html).toContain('Atos, diários oficiais e documentos contemporâneos')
expect(html).toContain('Sem documento, não entra')
expect(html).toContain('Não filtram por área')
})
it('uses the theme synthesis labels and the pela/pelo subject phrase', () => {
expect(html).toContain('Tipos de atuação com mais registros')
expect(html).toContain('Pontos com fonte por ano')
expect(html).toContain('O que Jorge Solla fez pela Educação')
expect(html).toContain('pela Educação ao longo da carreira')
})
it('prints the synthesis lines and the consolidated charts', () => {
const synthesis = report.synthesis
expect(synthesis).toBeTruthy()
if (!synthesis) return
expect(synthesis.totals.items).toBeGreaterThan(0)
const eraTotal = synthesis.byEra.reduce(
(sum: number, row: { count: number }) => sum + row.count,
0,
)
expect(eraTotal).toBe(synthesis.totals.items)
const sphereTotal = synthesis.bySphere.reduce(
(sum: number, row: { count: number }) => sum + row.count,
0,
)
expect(sphereTotal).toBe(synthesis.totals.items)
expect(html).toContain('data-page="sintese"')
expect(html).toContain('data-page="graficos"')
expect(html).toContain('Síntese do que foi localizado')
expect(html).toContain('Gráficos consolidados')
expect(html).toContain('chart-grid')
expect(md).toContain('## Síntese do que foi localizado')
})
it('flows across continuation sheets when the pack plan splits a section', () => {
const bigReport = buildDossierReport({
snapshot,
research: mergeDossierResearch(
[
normalizeDossierResearchInput(
{
themeSlug: 'educacao',
era: 'C',
researchedAt: '2026-09-18T10:00:00.000Z',
items: [
'era_c_discursos',
'era_c_proposicoes',
'era_c_relatorias',
'era_c_emendas',
'era_c_programas',
'era_c_audiencias',
].map((id) => item(id, 'C', { sphere: 'segmento' })),
news: Array.from({ length: 12 }, (_, index) => ({
title: `Notícia ${index + 1}`,
outlet: 'Educação Hoje',
publishedAt: '2024-05-01',
url: `https://educacao.test/noticia-${index + 1}`,
})),
},
{ unit: THEME_UNIT },
),
],
{ unit: THEME_UNIT },
),
generatedAt,
unit: THEME_UNIT,
})
const bigHtml = renderDossierHtml(bigReport, {
pack: { 'era:C': [1], 'scope:segmento': [1], news: [1] },
})
expect(bigHtml).toContain('data-page="era-c"')
expect(bigHtml).toContain('data-page="era-c-2"')
expect(bigHtml).toContain('data-page="abrangencia-segmento"')
expect(bigHtml).toContain('data-page="abrangencia-segmento-2"')
expect(bigHtml).toContain('data-page="noticias"')
expect(bigHtml).toContain('data-page="noticias-2"')
expect(bigHtml).toContain('continuação')
const anchors = [...bigHtml.matchAll(/data-page="([^"]+)"/g)].map((match) => match[1])
expect(new Set(anchors).size, 'every sheet anchor must be unique').toBe(anchors.length)
expect(anchors.length).toBe(bigReport.meta.pageTotal)
})
it('ports the theme vocabulary and identity badges', () => {
expect(html).toContain('Dossiê temático')
expect(html).toContain('identity-badge')
expect(html).toContain('scope-sector')
expect(html).toContain('scope-network')
expect(html).toContain('phase-paid')
expect(html).toContain('phase-pending')
expect(html).toContain('Segmento/rede não é a área')
expect(html).toContain('Segmento e rede não são somados à área')
})
it('prints the defeso note', () => {
expect(html).toContain('Nota de defeso eleitoral 2026')
})
it('escapes untrusted research text', () => {
const hostile = buildDossierReport({
snapshot,
research: mergeDossierResearch(
[
normalizeDossierResearchInput(
eraFile('C', [item('era_c_emendas', 'C', { answer: '<script>alert(1)</script>' })]),
{ unit: THEME_UNIT },
),
],
{ unit: THEME_UNIT },
),
generatedAt,
unit: THEME_UNIT,
})
const hostileHtml = renderDossierHtml(hostile)
expect(hostileHtml).not.toContain('<script>alert(1)</script>')
expect(hostileHtml).toContain('&lt;script&gt;')
})
it('mirrors the theme sections in markdown', () => {
expect(md).toContain('# Dossiê Solla por tema/área — Educação')
expect(md).toContain('## Abrangência: área × segmento × rede')
expect(md).toContain('## Lacunas explícitas')
expect(md).toContain('[fonte](https://educacao.test/era_a_formacao)')
})
})
describe('buildBulletin (theme unit)', () => {
const identity = {
name: report.meta.subjectName,
badges: report.meta.identityBadges,
value: report.meta.identity?.value ?? null,
taxonomyNote: report.meta.identity?.taxonomyNote ?? null,
}
const bulletin = buildBulletin({
facts: report.bulletinFacts,
identity,
unit: THEME_UNIT,
generatedAt,
})
const html = renderBulletinHtml(bulletin)
it('is a single A4 page with the model label and identity pills', () => {
expect(html).toContain('data-page="boletim"')
expect((html.match(/data-page="boletim"/g) ?? []).length).toBe(1)
expect(html).toContain('aria-label="Modelo de boletim informativo temático de uma página"')
expect(html).toContain('Modelo — insumo interno')
expect(html).toContain('identity-pill')
expect(html).toContain('Educação')
expect(html).toContain('O que Jorge Solla fez pela <span class="accent">Educação</span>')
})
it('renders the theme pills with the canonical token in mono and the taxonomy note', () => {
expect(html).toContain('<span class="identity-pill">Área</span>')
expect(html).toContain('<span class="identity-pill identity-pill--code">educacao</span>')
expect(html).toContain('<span class="identity-pill">Taxonomia do acervo</span>')
expect(html).toContain('.identity-pill--code')
})
it('falls back to the year and then to a textual card (never a fake zero)', () => {
const fact = (id: string, overrides: Record<string, unknown> = {}) => ({
id,
era: 'C',
sphere: 'area',
area: 'Educação',
headline: `Fato ${id}`,
detail: null,
value: null,
brief: null,
year: null,
phase: null,
sourceUrl: `https://educacao.test/${id}`,
sourceDate: '2024-06-01',
...overrides,
})
const fallback = buildBulletin({
facts: [fact('a-ano', { year: '2019' }), fact('b-texto')],
identity,
unit: THEME_UNIT,
generatedAt,
})
const fallbackHtml = renderBulletinHtml(fallback)
expect(fallback.highlights[0]).toMatchObject({ number: '2019' })
expect(fallback.highlights[1]).toMatchObject({ number: null, textual: true })
expect(fallbackHtml).toContain('highlight-card--textual')
})
it('declares no sources and carries the defeso band', () => {
expect(html).not.toContain('source-link')
expect(html).not.toContain('Fonte:')
expect(html).toContain('defeso-band')
expect(html).toContain('sem CTA')
expect(html).toContain('área, segmento e rede não são somados')
})
it('keeps the phase next to a valued number (empenho ≠ pagamento)', () => {
expect(html).toContain('highlight-phase')
expect(html).toContain('phase-pending')
expect(html).toContain('empenhado')
expect(html).toContain('phase-paid')
expect(html).toContain('pago')
})
it('renders the sparse variant when there are fewer than three facts', () => {
const sparse = buildBulletin({
facts: [report.bulletinFacts[0]],
identity,
unit: THEME_UNIT,
generatedAt,
})
const sparseHtml = renderBulletinHtml(sparse)
expect(sparse.sparse).toBe(true)
expect(sparseHtml).toContain('lacuna-panel')
expect(sparseHtml).toContain('A página termina com espaço')
expect(sparseHtml).toContain('entrega exclusiva da área')
})
})

View file

@ -0,0 +1,138 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
// C190: pins the batch contract of the dossie-solla-tema 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/fail-closed wording
// is dropped, or if the subagent regains the extract/build pipeline.
const repoRoot = process.cwd()
const read = (path: string): string => readFileSync(resolve(repoRoot, path), 'utf8')
const skill = read('.agents/skills/dossie-solla-tema/SKILL.md')
const researcher = read('.opencode/agent/dossie-solla-tema.md')
const writer = read('.opencode/agent/dossie-solla-tema-redacao.md')
describe('skill /dossie-solla-tema documents the batch contract', () => {
it('keeps the comma-separated batch entry and the single-area case', () => {
expect(skill).toContain('## Lote (várias áreas)')
expect(skill, 'batch example must be literal').toContain('/dossie-solla-tema Educação, Saúde')
expect(skill, 'single-area must remain valid').toContain('/dossie-solla-tema Educação')
expect(skill, 'comma is the separator').toContain('vírgula')
expect(skill, 'N=1 is the preserved path').toContain('N=1')
})
it('pins the fail-closed taxonomy resolution (no invented slug)', () => {
expect(skill).toContain('resolveSpeechTopic')
expect(skill).toContain('SPEECH_TOPICS')
expect(skill, 'unknown token must fail closed').toContain('falha fechado')
expect(skill, 'must never invent a slug').toContain('nunca')
expect(skill, 'the taxonomy is closed (no one-off escape)').toContain('taxonomia é fechada')
})
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 (C190)', () => {
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"')
expect(skill, 'the unit slug field is themeSlug').toContain('"themeSlug": "educacao"')
for (const id of ['era_a_vinculo', 'era_b_politicas', 'era_c_relatorias', 'era_c_audiencias']) {
expect(skill, `checklist must list ${id}`).toContain(id)
}
expect(skill, 'segmento/rede is never summed').toMatch(/nunca\s+(\*\*)?são?\s+somados/)
})
})
describe('subagent .opencode/agent/dossie-solla-tema.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('--theme=')
expect(researcher, 'researcher must not run the builder CLI').not.toContain('--snapshot=')
})
it('leaves the narrative to the redator step', () => {
expect(researcher, 'the researcher does not write the narrative file').not.toContain(
'narrative.json',
)
expect(researcher).toContain('redator')
})
})
describe('dossiê temático sem caps + redação (revisão 2026-09-18)', () => {
it('documents the flowing sheets and the measured packing', () => {
expect(skill, 'no caps in the theme dossiê').toContain('não usa caps')
expect(skill).toContain('grow/shrink')
expect(skill).toContain('dossiePack.mjs')
expect(skill, 'continuation sheets carry the header').toContain('continuação N')
})
it('documents the opening letter, the synthesis and the charts', () => {
expect(skill).toContain('A contribuição (carta)')
expect(skill).toContain('**Síntese**')
expect(skill).toContain('Gráficos consolidados')
expect(skill, 'the era opens with a consolidation paragraph').toMatch(
/O que esta era\s+entrega/,
)
})
it('pins the narrative contract and the writer receipt', () => {
expect(skill).toContain('## Recibo do redator')
expect(skill).toContain('"themeSlug": "educacao"')
expect(skill, 'the narrative file carries the era paragraphs').toContain('"eras": {')
expect(skill, 'the orchestrator audits the citations').toMatch(/audita/i)
})
})
describe('subagent .opencode/agent/dossie-solla-tema-redacao.md is writer-only', () => {
it('stays a subagent that writes the narrative and returns the receipt', () => {
expect(writer).toMatch(/^---\n[\s\S]*mode: subagent[\s\S]*\n---/)
expect(writer).toContain('narrative.json')
expect(writer).toContain('Recibo do redator')
})
it('does not run the deterministic extract/build stages', () => {
expect(writer, 'writer must not run the extractor CLI').not.toContain('--theme=')
expect(writer, 'writer must not run the builder CLI').not.toContain('--snapshot=')
expect(writer, 'writer must not edit the research files').toMatch(/não\*\* edite os/)
})
})

View file

@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import {
DOSSIER_RESEARCH_CHECKLIST_IDS,
dossierChecklistForEra,
dossierResearchReceipt,
mergeDossierResearch,
normalizeDossierResearchInput,
} from '../../scripts/lib/dossieResearch.mjs'
import { THEME_UNIT } from '../../scripts/lib/dossieUnit.mjs'
// C190: the theme/area branch of the shared research contract. Same eras,
// `themeSlug` and `area|segmento|rede` spheres; the C186 municipality and C187
// institution behaviours are untouched.
const validItem = (id: string, overrides: Record<string, unknown> = {}) => ({
id,
answer: 'Entrega documentada.',
sphere: 'area',
sourceUrl: 'https://educacao.test/ato',
sourceDate: '2024-02-01',
...overrides,
})
const eraResearch = (era: 'A' | 'B' | 'C', overrides: Record<string, unknown> = {}) => ({
themeSlug: 'educacao',
era,
researchedAt: '2026-09-18T10:00:00.000Z',
items: dossierChecklistForEra(era, THEME_UNIT).map((item) => validItem(item.id)),
...overrides,
})
describe('theme checklist', () => {
it('exposes a theme checklist id per era (distinct ids)', () => {
const ids = [
...dossierChecklistForEra('A', THEME_UNIT),
...dossierChecklistForEra('B', THEME_UNIT),
...dossierChecklistForEra('C', THEME_UNIT),
].map((row) => row.id)
expect(new Set(ids).size).toBe(ids.length)
expect(ids).toContain('era_b_politicas')
expect(ids).toContain('era_c_relatorias')
expect(ids).toContain('era_c_audiencias')
// Municipality ids remain the C186 public surface.
expect(DOSSIER_RESEARCH_CHECKLIST_IDS).toContain('era_a_formacao')
})
})
describe('normalizeDossierResearchInput (theme unit)', () => {
it('requires themeSlug and keeps theme items', () => {
const research = normalizeDossierResearchInput(eraResearch('C'), { unit: THEME_UNIT })
expect(research.themeSlug).toBe('educacao')
expect(research.items.length).toBe(dossierChecklistForEra('C', THEME_UNIT).length)
expect(research.items.every((item) => item.sphere === 'area')).toBe(true)
})
it('rejects the municipality slug field', () => {
expect(() =>
normalizeDossierResearchInput(
{ municipalitySlug: 'ilheus', era: 'C', researchedAt: '2026-09-18T10:00:00.000Z' },
{ unit: THEME_UNIT },
),
).toThrow(/themeSlug/)
})
it('turns an invalid sphere into a gap with the theme vocabulary', () => {
const items = dossierChecklistForEra('A', THEME_UNIT).map((item) => validItem(item.id))
items[0] = validItem(items[0].id, { sphere: 'instituicao' })
const research = normalizeDossierResearchInput(eraResearch('A', { items }), {
unit: THEME_UNIT,
})
expect(research.gaps).toContainEqual(
expect.objectContaining({
id: items[0].id,
reason: expect.stringMatching(/area\/segmento\/rede/),
}),
)
})
it('defaults the sphere to area and keeps segmento/rede', () => {
const research = normalizeDossierResearchInput(
eraResearch('B', {
items: [
validItem('era_b_sesab', { sphere: undefined }),
validItem('era_b_politicas', { sphere: 'segmento' }),
validItem('era_b_investimentos', { sphere: 'rede' }),
],
}),
{ unit: THEME_UNIT },
)
const byId = Object.fromEntries(research.items.map((item) => [item.id, item.sphere]))
expect(byId.era_b_sesab).toBe('area')
expect(byId.era_b_politicas).toBe('segmento')
expect(byId.era_b_investimentos).toBe('rede')
})
it('builds a theme receipt with the theme research path', () => {
const receipt = dossierResearchReceipt(
normalizeDossierResearchInput(eraResearch('A'), { unit: THEME_UNIT }),
{ unit: THEME_UNIT },
)
expect(receipt.slug).toBe('educacao')
expect(receipt.researchPath).toBe('data/dossie-solla-tema/educacao.a.research.json')
expect(receipt.itemCount).toBeGreaterThan(0)
})
})
describe('mergeDossierResearch (theme unit)', () => {
it('merges the theme eras', () => {
const merged = mergeDossierResearch(
(['A', 'B', 'C'] as const).map((era) =>
normalizeDossierResearchInput(eraResearch(era), { unit: THEME_UNIT }),
),
{ unit: THEME_UNIT },
)
expect(merged.themeSlug).toBe('educacao')
expect(merged.eras).toEqual(['A', 'B', 'C'])
})
it('fails closed when theme slugs disagree', () => {
const a = normalizeDossierResearchInput(eraResearch('A'), { unit: THEME_UNIT })
const b = normalizeDossierResearchInput(eraResearch('B', { themeSlug: 'saude' }), {
unit: THEME_UNIT,
})
expect(() => mergeDossierResearch([a, b], { unit: THEME_UNIT })).toThrow(/temas diferentes/)
})
})

View file

@ -18,6 +18,7 @@ const commands = [
'relatorio-cidade',
'dossie-solla-cidade',
'dossie-solla-instituicao',
'dossie-solla-tema',
] as const
describe('opencode commands couple to their skill by exact name', () => {

View file

@ -2,9 +2,31 @@
import { describe, expect, it } from 'vitest'
import { mergeFacetClassification, parseLlmFacetResponse } from '@/lib/speechFacets'
import {
mergeFacetClassification,
parseLlmFacetResponse,
resolveSpeechTopic,
} from '@/lib/speechFacets'
import { classifySpeechByGazetteer, matchMunicipalityMentions } from '@/lib/speechGazetteer'
describe('resolveSpeechTopic (C190)', () => {
it('resolves the canonical value and the pt-BR label (accent/case insensitive)', () => {
expect(resolveSpeechTopic('educacao')?.value).toBe('educacao')
expect(resolveSpeechTopic('Educação')?.value).toBe('educacao')
expect(resolveSpeechTopic(' EDUCAÇÃO ')?.value).toBe('educacao')
expect(resolveSpeechTopic('Seguranca Publica')?.value).toBe('seguranca-publica')
expect(resolveSpeechTopic('Direitos Humanos e Assistência Social')?.value).toBe(
'direitos-humanos',
)
})
it('fails closed on an unknown or empty token (never an invented slug)', () => {
expect(resolveSpeechTopic('educacao-infantil')).toBeNull()
expect(resolveSpeechTopic('')).toBeNull()
expect(resolveSpeechTopic(' ')).toBeNull()
})
})
describe('classifySpeechByGazetteer', () => {
it('detects topics, explicit scopes and municipality mentions', () => {
const result = classifySpeechByGazetteer({