feat(C187): dossiê Solla por instituição + boletim modelo (skill + builder) (Closes #1135)

Generaliza aditivamente o pipeline C186 (default município; specs verdes) via o
descritor scripts/lib/dossieUnit.mjs (municipality|institution), extrai o emit
A4/fit-guard para scripts/lib/buildPdf.mjs e a receita read-only para
scripts/lib/readOnlyExtract.mjs.

- Catálogo fail-closed src/lib/institutionCatalog.ts + institutionNameAliases.ts
  (seed UFBA/Correios/Enfermagem; escape one-off --slug/--name; dedup).
- Skill/command/subagente /dossie-solla-instituicao; eras A/B/C reusadas.
- Abrangência instituição × setor × rede nunca somada; era sem fonte vira página
  de lacuna explícita; lista capada declara "e mais N" (regra C188 no dono).
- Boletim modelo 1×A4 com fase junto ao valor, sem fontes, sem CTA; herda apenas
  fatos com fonte do dossiê (incl. falas do acervo read-only).
- Sem schema/migration/DB write; artefatos gitignored.
This commit is contained in:
Francisco Solla 2026-09-17 18:54:49 -03:00
parent f658d5eaaa
commit cb5854d17e
30 changed files with 3791 additions and 179 deletions

View file

@ -0,0 +1,271 @@
---
name: dossie-solla-instituicao
description: 'Gera o dossiê Solla por instituição (PDF A4 + .md, tudo o que Solla fez pela/na/com a instituição por era) e o Boletim Informativo modelo de 1 página, a partir da base Teqo read-only + pesquisa web datada + fontes oficiais; aceita uma instituição ou um lote separado por vírgula.'
---
# Dossiê Solla por instituição + boletim modelo (C187)
Entrega, por instituição (universidade, instituto, empresa pública, autarquia,
órgão, conselho/entidade de classe, categoria profissional, movimento, rede), um
**par PDF A4 datado + companion `.md`** com tudo o que **Jorge Solla fez por, na
e com a instituição 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): mesmo pipeline, recorte
institucional. Os dois layouts vêm dos artefatos hi-fi aprovados pelo `designer`.
## Quando usar
- A comunicação pede "o dossiê da <instituição>" e/ou o "boletim modelo da
<instituição>" — ou várias numa invocação (`/dossie-solla-instituicao UFBA,
Correios`).
- Quem executa são o **orquestrador** (agente principal) + um sub-agente
**researcher por era, por instituição**, em paralelo + os scripts (`extract` no
homeserver, `build` local). O detalhe está em "Pipeline (etapas)".
## Lote (várias instituições)
A invocação aceita **uma** instituição ou **várias**, separadas por **vírgula**:
```text
/dossie-solla-instituicao UFBA # N=1
/dossie-solla-instituicao UFBA, Correios # lote
```
Parsing (orquestrador, **antes** de qualquer pesquisa):
- separa por `,`; aplica `trim`; descarta vazio;
- cada token é aceito como **slug canônico** (`isInstitutionSlug`) **ou** como
**nome** (fold acento-insensível `resolveInstitutionName`; `null` = nome
desconhecido → token inválido) → entrada do catálogo;
- **0 entradas** = token inválido; **>1 entrada** = ambíguo (peça o slug
canônico explícito) — o token **falha fechado** com mensagem acionável
(adicionar alias/entrada em `src/lib/institutionCatalog.ts` ou usar o escape
one-off `--slug=<x> --name="<Nome>"`), **nunca** inventa slug;
- **dedupe após a resolução**, preservando a ordem;
- token inválido/ambíguo vira **falha isolada** com o motivo no summary final —
**nunca** aborta o lote.
Uma instituição = um dossiê (PDF+MD) + **um** boletim PDF; **sem** índice/PDF
agregado. Falha de uma instituição/era **não** cancela as demais (**sucesso
parcial** explícito).
## Pipeline (etapas)
1. **Orquestrador (agente principal).** Parseia a lista (seção "Lote"), resolve
as entradas no catálogo (`src/lib/institutionCatalog.ts`) e dispara **um
researcher por era (A/B/C), por instituição** (Task), em paralelo, com guarda
de concorrência (`MAX_RESEARCHERS_IN_FLIGHT = 6`). Não invente slug: confirme
no catálogo ou use o escape one-off. Nunca retém pesquisa web nem o corpo de
um `research.json` — só os recibos.
2. **Researcher (sub-agente, ×3 por instituição, em paralelo).**
`.opencode/agent/dossie-solla-instituicao.md` — o **único** passo pesado de
contexto. Faz a pesquisa web datada da sua era e escreve
`data/dossie-solla-instituicao/<slug>.<era>.research.json` (cada item com
`sourceUrl` + `sourceDate`; sem fonte, **não escreva o item** — ele vira
lacuna). Devolve **apenas o recibo curto** (seção "Recibo do researcher").
Não roda ssh nem build.
3. **Extração read-only no homeserver — serializada, 1 instituição por vez.**
`scripts/extract-institution-snapshot.mjs` (`INSTITUTION_REPORT_CONFIRM=1`,
sessão read-only) lê o acervo interno de falas por **tema→instituição**
(`topics` da entrada do catálogo; o acervo cobre 2011+ e não tem campo
instituição) e grava
`data/dossie-solla-instituicao/<slug>.institution.snapshot.json`.
4. **Fontes oficiais em tempo de build.** O Portal da Transparência **não filtra
por instituição** e a Câmara tampouco; emendas/proposições institucionais
entram como **itens de pesquisa** (com fase e fonte). Sem atribuição → lacuna,
**nunca zero silencioso**. O builder **não** busca emendas/Câmara/IBGE.
5. **Render local (por instituição):**
```bash
NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-instituicao.mjs \
--snapshot=data/dossie-solla-instituicao/<slug>.institution.snapshot.json \
--research-dir=data/dossie-solla-instituicao \
--out-dir=docs/research/dossie-solla-instituicao
```
Saídas: `docs/research/dossie-solla-instituicao/<slug>-<YYYY-MM-DD>-dossie.pdf`
+ `-dossie.md` + `-boletim.pdf`. Intermediários gitignored em
`data/dossie-solla-instituicao/` (HTML, JSONs, logs). Flags úteis:
`--generated-at=<ISO>`, `DOSSIER_STRICT=1` (falha o run se houver lacuna —
conferência, não entrega).
6. **Summary final.** Uma linha por entrada, na ordem da lista: `entrada · status
(ok|failed) · dossiê/boletim (quando ok) · motivo (quando falha)`.
## Briefing por era (A/B/C)
O researcher recebe **uma era** e usa o checklist da era (ver "Contrato dos
JSONs"). As eras são as mesmas da C186 (não criar eras novas):
- **Era A (até 2006)** — formação/residência/pesquisa com vínculo nominal;
SESAB; consultoria no MS; gestão municipal; Secretaria de Atenção à Saúde do
MS; vínculo institucional documentado. 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 instituição; equipamentos;
programas; obras; convênios/termos. Fontes: DOE-BA (DOOL), SESAB/instituição,
Transparência Bahia.
- **Era C (2015–2027)** — pronunciamentos; proposições/relatorias de interesse;
emendas; títulos/honrarias; atuação; parcerias/audiências. 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" <instituição>` + `<tema>`; recorte por data
`after:YYYY-MM-DD before:YYYY-MM-DD`; `site:` por fonte institucional/oficial
(ufba.br, correios.com.br, cofen.gov.br, camara.leg.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,
parceria, 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": "ufba",
"era": "C",
"status": "ok", // ou "failed"
"researchPath": "data/dossie-solla-instituicao/ufba.c.research.json",
"researchedAt": "2026-09-17T10:00:00.000Z",
"itemCount": 5, // itens da era com fonte
"gapCount": 2, // == gaps.length
"newsCount": 3,
"gaps": ["era_c_parcerias", "…"],
"failureReason": "…" // opcional (só quando status = failed)
}
```
`status: "failed"` carrega o motivo e **não** escreve corpo parcial; o
orquestrador agrega os recibos no summary final — é o único dado de pesquisa que
cruza para ele.
## Contrato dos JSONs
`<slug>.<era>.research.json` (um arquivo por era; `era` ∈ `A|B|C`):
```jsonc
{
"institutionSlug": "ufba", // obrigatório
"era": "C", // obrigatório: A | B | C
"researchedAt": "2026-09-17T10:00:00.000Z", // obrigatório
"items": [
{
"id": "era_c_emendas", // id do checklist da era
"answer": "R$ 5,0 milhões para estrutura de ensino e pesquisa",
"details": "opcional; aceita {{fonte}} / {{fonte:N}}",
"sphere": "instituicao", // instituicao | setor | rede (default instituicao)
"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-17T09:00:00.000Z"
}
],
"news": [
{ "title": "…", "outlet": "…", "publishedAt": "2024-06-01", "url": "https://…", "summary": "…" }
],
"gaps": [{ "id": "…", "label": "…", "reason": "…" }]
}
```
Checklist por era (ids):
- **A:** `era_a_formacao`, `era_a_sesab`, `era_a_consultor_ms`, `era_a_conquista`,
`era_a_sas_ms`, `era_a_vinculo`.
- **B:** `era_b_sesab`, `era_b_equipamentos`, `era_b_programas`, `era_b_obras`,
`era_b_convenios`.
- **C:** `era_c_discursos`, `era_c_proposicoes`, `era_c_emendas`,
`era_c_titulos`, `era_c_atuacao`, `era_c_parcerias`.
A **abrangência** (`sphere`) é `instituicao` | `setor` | `rede`; `setor` e `rede`
não são a instituição 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.
## Conteúdo do dossiê
- **Capa** — série institucional, "INSUMO INTERNO — defeso 2026", identificação
(tipo/esfera/alcance em badges), data, "como usar", escopo/versão.
- **Resumo de uma olhada** — identificação + 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**.
- **Seção por era (A/B/C)** — recorte e método + trilha de recuperação; tabela
**Objeto/Valor/Ano/Fase/Abrangência/Fonte**; cards "Papéis com evidência";
títulos/honrarias. Uma era **sem evidência** vira **página de lacuna
explícita** (nunca seção em branco nem "zero").
- **Abrangência: instituição × setor × rede** — painel "Setor/rede não é a
instituição. Não some os recortes."; três listas com contagem por recorte
(**nenhum total combinado**); tabela de evidência de alcance; gancho e lacuna
prioritária.
- **Títulos, honrarias e vínculos** — tabela reconhecimento/natureza/fonte.
- **Fontes e limites** — tabela de lacunas; notícias/documentos consultados
(larguras fixas 12/13/47/28%); limites de cobertura; regras para uso editorial;
nota de defeso eleitoral 2026.
- **Nada some por não caber:** listas capadas declaram o resto ("e mais N") e a
reformulação fica no `summary` do researcher — nunca "…" silencioso (regra
C188 aplicada no dono).
## Conteúdo do Boletim modelo (1 página A4)
- Cabeçalho com o nome da instituição + identity pills + rótulo **"Modelo —
insumo interno"**; faixa de **defeso** ("sem CTA · sem propaganda"); abertura
"O que Jorge Solla fez pela e na <instituição>"; **≤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.
- **Empenho ≠ pagamento**: cada valor acompanha sua fase; nunca consolidar.
- **Abrangência explícita** (`instituição`/`setor`/`rede`): setor e rede
**nunca** são somados como se fossem a instituição.
- **Leitura relativa**: nunca % estadual absoluto.
- **Atribuição de emenda** de bancada/relator só com autoria checada; sem
atribuição → lacuna (o Portal não filtra por instituição).
- **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/instituição 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
- **Página do dossiê / boletim estourou**: o builder aborta com a altura medida.
**Aperte o teto de conteúdo/corte copy — nunca mexa no layout para espremer.**
- **Era sem pesquisa**: o builder sintetiza o arquivo da era com lacunas
explícitas — e a era sai como página de lacuna, não inventada.
- **Câmara 429/500**: `getJsonWithBackoff` + paginação com teto; falha degrada
para lacuna datada. Os discursos sempre passam janela (`dataInicio`/`dataFim`).
- **Sem chave do Portal da Transparência**: emendas institucionais viram lacuna
(o dossiê sai).
- **Slug de pesquisa ≠ snapshot**: o builder recusa o par — regenere, nunca
edite o JSON à mão para casar.
- **Token desconhecido no lote**: vira linha `failed` no summary com o motivo.
Use `--slug=<x> --name="<Nome>"` para uma instituição fora do catálogo.
- **Chromium**: vem do `@playwright/test`; se faltar binário,
`pnpm exec playwright install chromium`.
## Referências
- Intenção: `docs/plans/dossie-solla-instituicao.md`; impl:
`docs/plans/dossie-solla-instituicao-impl.md`.
- Designs hi-fi (fonte de verdade do port):
`docs/plans/dossie-solla-instituicao-ui-design.html` e
`docs/plans/dossie-solla-instituicao-boletim-ui-design.html`.
- Skill irmã (dona do pipeline): `.agents/skills/dossie-solla-cidade/SKILL.md`.
- Scripts: `scripts/build-dossie-solla-instituicao.mjs`,
`scripts/extract-institution-snapshot.mjs`, `scripts/institutionSnapshot.mjs`,
`scripts/lib/dossie*.mjs`, `scripts/lib/buildPdf.mjs`,
`scripts/lib/readOnlyExtract.mjs`.
- Catálogo: `src/lib/institutionCatalog.ts`, `src/lib/institutionNameAliases.ts`.
- Runbook de extração read-only: `docs/ops/teqo-1313-deploy.md` §C163.

4
.gitignore vendored
View file

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

View file

@ -0,0 +1,42 @@
---
description: Researcher por era — pesquisa web datada e escreve o research.json de uma era (A/B/C) do dossiê Solla por instituição; devolve só o recibo curto (etapa do lote /dossie-solla-instituicao)
mode: subagent
---
# Dossiê Solla por instituição — researcher (por era)
Você é a etapa **researcher** da skill
`.agents/skills/dossie-solla-instituicao/SKILL.md` (fonte canônica: contrato dos
JSONs, checklist por era, guardrails, recibo). Leia-a antes de rodar qualquer
comando. Você é invocado **uma vez por era** (o orquestrador diz qual: `A`, `B`
ou `C`) pelo agente principal, que já resolveu o slug e coordena as demais
eras/instituições em paralelo.
## Papel
1. Confirmar o slug canônico da instituição (catálogo
`src/lib/institutionCatalog.ts`) e a era atribuída.
2. Fazer a **pesquisa web datada** da era e escrever
`data/dossie-solla-instituicao/<slug>.<era>.research.json` no contrato da
skill (ex.: `ufba.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` (`instituicao`/`setor`/`rede`); setor e rede **nunca** são somados à
instituição.
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-institution-snapshot.mjs` no homeserver,
`scripts/build-dossie-solla-instituicao.mjs` local). Não faça `ssh`, `scp` nem
render.
- **Nunca** escreva na base de produção: não use `db:pull`/snapshot de banco.
- **Nunca** commite o `research.json`/PDF/MD/snapshot (dado interno; repo público
— artefato gitignored).
- Não invente fato sem fonte; não trate empenho como pagamento (informe a fase);
não inclua telefone/e-mail de liderança; não reconstrua a carreira pré-2011
fora do acervo (é lacuna).
- Não edite os scripts nem o layout para "caber".

View file

@ -0,0 +1,9 @@
---
description: Gera o dossiê Solla por instituição (PDF A4 + .md) e o Boletim Informativo modelo de 1 página para uma instituição ou um lote separado por vírgula
---
Carregue a skill `dossie-solla-instituicao` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os slugs canônicos no catálogo de instituições, dispara um researcher por era (A/B/C) por instituição, roda a extração read-only do acervo (por tema→instituição) e o build local (dossiê + boletim) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-instituicao/SKILL.md` — não a transcreva nem recrie o fluxo.
Uso: `/dossie-solla-instituicao <Instituição> [<Instituição>, ...]` — ex.: `/dossie-solla-instituicao UFBA, Correios`. Uma instituição = dossiê (`<slug>-<YYYY-MM-DD>-dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). Token fora do catálogo falha fechado; use o escape one-off `--slug=<x> --name="<Nome>"`.
$ARGUMENTS

View file

@ -0,0 +1 @@
**C187 — Dossiê Solla por instituição + Boletim modelo (2026-09-17):** nova skill `/dossie-solla-instituicao` (command + subagente researcher por era A/B/C), irmã da C186, que reúne com fonte e data tudo o que Jorge Solla fez por/na/com uma **instituição** (universidade, empresa pública, autarquia, órgão, conselho/entidade de classe, categoria profissional, movimento, rede) e entrega o **dossiê** (PDF A4 + companion `.md`: capa com identificação por tipo/esfera/alcance, resumo com linha do tempo do vínculo, seções por era com tabela Objeto/Valor/Ano/Fase/**Abrangência**/Fonte, painel `instituição × setor × rede` sem total combinado, títulos/honrarias, lacunas, fontes, defeso — era sem evidência vira **página de lacuna explícita**, nunca "zero") e o **Boletim modelo** (1 página A4, linguagem de eleitor, ≤6 destaques + ≤14 "E mais", fase junto ao valor, sem fontes, sem CTA). Arquitetura: **edita o dono, não gema um irmão** — o descritor `scripts/lib/dossieUnit.mjs` (`municipality|institution`) generaliza **aditivamente** o pipeline C186 (`dossieResearch`/`dossieBlocks`/`dossieRender`/`dossieBulletin`/`dossieBulletinRender` seguem com default município; specs C186 verdes) e o emit/fit-guard virou dono único em `scripts/lib/buildPdf.mjs` (C186 migrado). Catálogo novo e **fail-closed** (`src/lib/institutionCatalog.ts` + `institutionNameAliases.ts`; seed UFBA/Correios/Enfermagem; token desconhecido → mensagem acionável + escape one-off `--slug/--name`; dedup; falha isolada no lote). Extração read-only nova (`scripts/institutionSnapshot.mjs` + `extract-institution-snapshot.mjs`, recorte **tema→instituição** — o acervo cobre 2011+ e não tem campo instituição), reusando `scripts/lib/readOnlyExtract.mjs` (dono da receita fail-closed, compartilhado com o extrator C163). O builder **não** busca emendas/Câmara/IBGE (o Portal não filtra por instituição; emenda institucional sem atribuição vira lacuna). O contrato sem-truncamento do C188 vale no dono (lista capada declara "e mais N"). Sem schema, migration, escrita no DB, Consent ou URL pública — artefatos gitignored (repo público). Novos specs: `institutionCatalog`, `dossieInstitutionResearch`, `dossieInstitution`, `dossieInstitutionBatchSkill`, `readOnlyExtract`, `buildPdf` (+ int `institutionSnapshot`; `opencodeCommands` e `test-affected-core` atualizados). `pnpm gate:fast` verde (353 arquivos / 3658 testes) + int 92/824, `knip`/`cycles` limpos, smoke local dos PDFs (UFBA 9 págs + boletim 1 pág; Enfermagem esparsa com era vazia). Issue: #1135.

View file

@ -0,0 +1,178 @@
# Impl: Dossiê Solla por instituição + Boletim modelo (skill → dossiê + boletim)
Status: aprovado
Atualizado em: 2026-09-17
Issue: #1135
Intenção: docs/plans/dossie-solla-instituicao.md
Appetite restante: ~3–4 dias eng (herdado)
> Aprovado no modo `--auto` (`/work-issue --issue 1135 --auto`): a pausa do GATE foi convertida em apresentação-no-chat. Literais e cortes de escopo do plano de intenção foram assumidos; nada de schema/Consent/URL público entra aqui.
## Leitura da intenção (Outcome / o que NÃO negociar / o que reavaliar)
- **Outcome:** `/dossie-solla-instituicao <instituição>` entrega, numa invocação, um par **PDF A4 datado + companion `.md`** (identificação por `kind`/`sphere`/`scope`; seções por era A/B/C; tabelas **Objeto/Valor/Ano/Fase/Abrangência/Fonte**; painel de abrangência `instituição × setor × rede`; 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" + defeso, `NEEDS ASSET`). Lote de 1+N; falha isolada não cancela o lote.
- **O que NÃO negociar:** catálogo **fail-closed** (token desconhecido → mensagem acionável + escape `--slug=<x> --name="<Nome>"`, nunca slug inventado) e dedup após resolução; eras A/B/C **reusadas** do C186 (não criar eras novas); abrangência `instituicao | setor | rede` **nunca somada**; fases `autorizado|empenhado|liquidado|pago|restos` (default `nao_informado`, empenho ≠ pago); "sem fonte, não publica" + lacuna explícita; PII mínima; leitura relativa (nunca % estadual absoluto); artefato **gitignored** (repo público); **sem schema/migration/DB write/segundo cadastro**; **não alterar** `/dossie-solla-cidade` nem `/relatorio-cidade` (só generalização aditiva do dono, com as specs C186/C163 verdes); **sem truncamento mecânico/descarte silencioso** (reformular no `summary` ou declarar "e mais N" — regra C188, aplicada no dono).
- **O que reavaliar (hipóteses da "Direção no codebase"):**
- "reusar o extrator C186/C163 sem mudanças" — falso: `--municipality=`, `resolveAccessibleMunicipalityContext` e `loadSpeeches(municipalities:[id])` prendem o snapshot a município. O recorte institucional é **tema→instituição**; exige um composer novo que reusa a plumbing read-only, não o composer.
- "reusar emendas/Câmara/saúde do C186 em tempo de build" — falso: o Portal **não filtra por instituição** e `fetchAuthorEmendas` exige `municipalityCode/Name`; a Câmara não filtra por instituição. Em `build`, emenda/proposição institucional sem autoria/fonte → **lacuna**; os números vêm dos itens de pesquisa (com fase).
- "build reusa `build-dossie-solla-cidade.mjs`" — só a **técnica** (Chromium/`page.pdf`/fit-guard); a plumbing vira dono próprio (`buildPdf.mjs`) porque este é o 3º builder CLI (gatilho já registrado na review do C186).
- **Design (fonte de verdade do port — portar classe a classe, sem redesenhar):** dossiê `docs/plans/dossie-solla-instituicao-ui-design.html`, **9 cenas**: 01 Capa; 02 Resumo; 03/04/05 Eras A/B/C; 06 Abrangência `instituição × setor × rede`; 07 Títulos/honrarias/vínculos; 08 Fontes/lacunas/defeso; 09 **empty-state** "era sem fonte". Boletim `docs/plans/dossie-solla-instituicao-boletim-ui-design.html`, **2 cenas** (composição completa + variação de poucos fatos). A estrutura visual é do `designer`; o implementer **porta classe a classe**.
## Abordagem recomendada
```mermaid
flowchart LR
A["/dossie-solla-instituicao UFBA, Correios"] --> B["orquestrador: resolve token<br/>isInstitutionSlug / resolveInstitutionName<br/>fail-closed + escape --slug/--name + dedup"]
B --> C["N researchers por era (A|B|C × instituição)<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 instituição por vez<br/>extract-institution-snapshot.mjs<br/>composeInstitutionSnapshot (por tema)"]
E --> S["<slug>.institution.snapshot.json"]
R --> G["build-dossie-solla-instituicao.mjs"]
S --> G
G --> I["dossieBlocks(unit=INSTITUTION):<br/>capa+resumo+eras+abrangência+fontes<br/>→ bulletinFacts ledger (só fato com fonte)"]
I --> J["dossieRender → PDF A4 multi-página + .md<br/>(buildPdf.mjs: fit-guard + emit)"]
I --> K["dossieBulletin(unit=INSTITUTION) → dossieBulletinRender"]
K --> L["PDF 1×A4 boletim modelo"]
J --> M["docs/research/dossie-solla-instituicao/<slug>-<YYYY-MM-DD>-*.pdf/.md"]
L --> M
```
**Opções consideradas:** A) generalizar os libs do C186 in-place com `if` de unidade espalhado; B) novo conjunto de libs institucionais reusando só helpers puros; C) **hibrido**: seam explícito de unidade (`dossieUnit.mjs`) + generalização **aditiva** do dono (default município) + módulo fino de conteúdo institucional.
**Recomendação:** **C** — mantém **um dono** do pipeline (research/render/ledger/PDF) e uma só cópia das mecânicas compartilhadas, ao mesmo tempo em que dá à instituição seu vocabulário (checklist, abrangência 3-listas, copy) sem forçar a seção `região/polo` do C186 a virar uma união estruturalmente estranha.
**Rejeitadas:** **B** — twin do normalizador de pesquisa, do shell de render e do ledger do boletim (exatamente a drift que o repo proíbe); **A** — a seção de abrangência (3 listas) e a de região/polo (2 listas + "não some") são **estruturalmente** diferentes; um `if` por seção em ~5 libs deixa o C186 mais frágil e o diff maior.
## Componentes / mudanças
**Skill / command / subagente**
- **`.agents/skills/dossie-solla-instituicao/SKILL.md`** (novo) — fonte canônica; seções `Lote`, `Catálogo de instituições` (fail-closed/escape/dedup), `Pipeline (etapas)`, `Briefing por era (A/B/C)`, `Recibo do researcher`, `Contrato dos JSONs` (`institutionSlug`, `sphere: instituicao|setor|rede`), `Conteúdo` (dossiê + boletim), `Guardrails`, `Troubleshooting`, `Referências` (aponta os dois HTMLs hi-fi).
- **`.opencode/commands/dossie-solla-instituicao.md`** (novo) — frontmatter só `description:`; corpo carga a skill pelo **nome exato** e termina em `$ARGUMENTS`.
- **`.opencode/agent/dossie-solla-instituicao.md`** (novo) — `description:` + `mode: subagent`; researcher **por era**; escreve `data/dossie-solla-instituicao/<slug>.<era>.research.json`; devolve só o recibo; proibido `ssh`/`scp`/build/`--snapshot=`/commitar.
**Catálogo (novo, TS em `src/lib` pelo precedente `municipalityCatalog`)**
- **`src/lib/institutionCatalog.ts`** — `InstitutionKind` (`universidade|instituto_federal|empresa_publica|autarquia|orgao_publico|conselho_classe|entidade_classe|categoria_profissional|movimento|rede|outro`), `InstitutionSphere` (`federal|estadual|municipal|nao_governamental`), `InstitutionScope` (`nacional|BA`), `InstitutionCatalogEntry { slug, name, aliases, kind, sphere, scope, topics }`, `institutionCatalog` (seed **ufba** universidade/federal/BA; **correios** empresa_publica/federal/nacional; **enfermagem** categoria_profissional/federal/nacional com âncoras ABEn-BA/COFEN/Coren-BA), `getInstitutionCatalogEntry`, `isInstitutionSlug`, `institutionCatalogEntriesForName`.
- **`src/lib/institutionNameAliases.ts`** — `INSTITUTION_NAME_ALIASES`, `resolveInstitutionName` (fold `normalizeSearchPhrase`, espelhando `municipalityNameAliases.ts`).
**Seam + libs generalizados (aditivos; C186 verde)**
- **`scripts/lib/dossieUnit.mjs`** (novo) — `MUNICIPALITY_UNIT`, `INSTITUTION_UNIT`, `DOSSIER_UNITS`; campos `id`, `slugField` (`municipalitySlug`/`institutionSlug`), `sphereValues`+`defaultSphere`+`sphereLabels`+`sphereBadgeClass`, `breadthRank`, `researchDir`, `outDir`, `title`, `coverCopy`, `limits`, `checklist`, `identity(snapshot)`. Checklist institucional: A `era_a_formacao|sesab|consultor_ms|conquista|sas_ms|vinculo_institucional`; B `era_b_sesab|equipamentos|programas|obras|convenios`; C `era_c_discursos|proposicoes|emendas|titulos|atuacao|parcerias`.
- **`scripts/lib/dossieResearch.mjs`** (editar) — `normalizeDossierResearchInput(raw, { unit = MUNICIPALITY_UNIT })`, `mergeDossierResearch(researches, { unit })`, `dossierResearchReceipt(research, { unit })`, `dossierChecklistForEra(era, unit)`. Sem opções → comportamento C186 idêntico (mensagens `/municipalitySlug/`, `/researchedAt/`, `/era/` preservadas).
- **`scripts/lib/dossieBlocks.mjs`** (editar) — `buildDossierReport({ snapshot, research, emendas=null, camara=null, health=null, generatedAt, unit = MUNICIPALITY_UNIT })`; mecânicas compartilhadas (number rows, `bulletinFacts`, limites, `phaseLabel`) ficam; o shape de seção ramifica em `regiao` (município) vs `abrangencia` (3 listas `instituicao/setor/rede`, sem total combinado); adiciona contagem `omitted` por lista capada.
- **`scripts/lib/dossieRender.mjs`** (editar) — `renderDossierHtml/Md(report)` lê `report.unit`; shell/`document-table`/`asset-box` compartilhados; classes novas `identity-badge`, `scope-sector`, `scope-network`, `phase-paid`, `phase-pending`, `timeline-item`, `empty-panel`; `<dl>` da capa e `data-page` dirigidos pelo descritor.
- **`scripts/lib/dossieBulletin.mjs`** (editar) — `buildBulletin({ facts, municipality, region, identity = null, unit = MUNICIPALITY_UNIT, generatedAt })`; ranking por `unit.breadthRank` (`instituicao` antes de `setor`/`rede`); tetos 6/14 preservados.
- **`scripts/lib/dossieBulletinRender.mjs`** (editar) — `renderBulletinHtml(bulletin)` + `identity-pill`, `defeso-band`, `lacuna-panel`; reusa `highlight-card/-number/-title/-note/-eyebrow`, `more-item`, `timeline-step`, `model-label`, `asset-box`; copy de `unit.bulletinCopy`.
- **`scripts/lib/buildPdf.mjs`** (novo) — `A4_PAGE_BUDGET_PX`, `launchPdfBrowser`, `assertPageFits(page, { selector, budgetPx, label })`, `printPdf(page, outPath)`. Consumido por `build-dossie-solla-cidade.mjs` (editado p/ importar) e pelo builder novo. C163 intocado.
**Extração read-only + builder**
- **`scripts/lib/readOnlyExtract.mjs`** (novo) — `resolveCampaignActor(payload)` + `enterReadOnlySession()` (receita fail-closed do ator real + sessão read-only); consumido pelo extrator C163 (editar, sem mudança de comportamento) e pelo novo.
- **`scripts/institutionSnapshot.mjs`** (novo) — `composeInstitutionSnapshot({ payload, actor, slug, readAt, codeSha, database })` → `{ meta, institution:{ slug,name,kind,sphere,scope,aliases,topics }, speeches:{ topics, totalCount, rows }, gaps }`; reusa `buildSpeechListWhere` + `loadSegmentsForSpeeches` + `overrideAccess:false`, recorte por `topics` do catálogo (mapa tema→instituição).
- **`scripts/extract-institution-snapshot.mjs`** (novo) — CLI `--institution=<slug> --out=<json>`; `INSTITUTION_REPORT_CONFIRM=1`; sessão read-only; serializado 1 instituição por vez.
- **`scripts/build-dossie-solla-instituicao.mjs`** (novo) — CLI `--snapshot= --research-dir= --out-dir= [--generated-at=]`; exige `snapshot.institution.slug`; lê `<slug>.{a,b,c}.research.json` (recusa slug divergente); **não** chama `fetchAuthorEmendas`/`fetchCamaraActivity`/`fetchHealthData`; `buildDossierReport({ ..., unit: INSTITUTION_UNIT })` → dossiê + `buildBulletin({ facts, identity, unit })`; HTML cache + `.md` + dois PDFs (guardas `[data-page="resumo"]` e `[data-page="boletim"]`); `DOSSIER_STRICT=1` falha em lacuna.
**Testes / CI / docs**
- Novos: `tests/unit/institutionCatalog.unit.spec.ts`, `dossieInstitutionResearch.unit.spec.ts`, `dossieInstitutionBlocks.unit.spec.ts`, `dossieInstitutionRender.unit.spec.ts`, `dossieInstitutionBulletin.unit.spec.ts`, `dossieInstitutionBatchSkill.unit.spec.ts`, `tests/int/institutionSnapshot.int.spec.ts`.
- Editar: `tests/unit/opencodeCommands.unit.spec.ts` (incluir `'dossie-solla-instituicao'`); `scripts/lib/test-affected-core.mjs` (`SCRIPTS_SPEC_PINNED` += `dossieUnit.mjs`, `buildPdf.mjs`, `readOnlyExtract.mjs`, `institutionSnapshot.mjs`; `ciSkipInvariants` recalcula o closure e falha até casar); `.gitignore` (`/data/dossie-solla-instituicao/`, `/docs/research/dossie-solla-instituicao/`); `docs/changelog/2026-09-17-c187.md`.
- **Manter verdes:** specs C186 (`dossie*`) e C163 (`cityReport*`), provando os defaults aditivos.
### Dados → forma (pergunta 3 — data-presentation)
- **Dossiê — forma escolhida:** **tabelas/listas com fonte por linha** + badge de abrangência + badge de fase (tabela Objeto/Valor/Ano/Fase/Abrangência/Fonte; painel de abrangência com **3 listas e nenhum total combinado**; timeline de vínculo na página 1 + timeline de carreira reusada em página própria; tabela de lacunas). É a forma mais pobre que ainda desbloqueia a decisão (escolher ângulo/entrega e saber o que falta), com a abrangência visível em cada linha. **Rejeitadas:** dashboard/KPIs agregados (perdem a fonte item a item e convidam a somar); gráfico de série (não há série comparável entre eras/entes); mapa (sem geografia no escopo); % estadual absoluto (anti-goal).
- **Boletim — forma escolhida:** **número destacado + rótulo curto** (≤6 `.highlight-card`) + **lista densa ✓** (≤14 "E mais") + timeline de 4 passos; densidade > destaque grande. **Rejeitadas:** tabela/planilha (documento de gestor, não de eleitor); parágrafo longo (estoura 1 página); fontes no boletim (vivem só no dossiê); CTA de campanha (defeso).
## Decisões de engenharia (A–H)
**A. Seam de unidade.** Opções: A) `if` de unidade espalhado nos libs do C186; B) libs institucionais novas; C) descritor `dossieUnit.mjs` + generalização aditiva + módulo fino de conteúdo. **Recomendação: C** — um só dono por concern, C186 verde por default, vocabulário institucional isolado. **Rejeitadas:** B (twin do normalizador/render/ledger); A (união estrutural região×abrangência em 5 libs).
**B. Local do catálogo.** Opções: A) `src/lib/institutionCatalog.ts` + `src/lib/institutionNameAliases.ts`; B) `scripts/lib/*.mjs`; C) coleção Payload `Institution` + migration/seed. **Recomendação: A** — segue o precedente (`municipalityCatalog`/`municipalityNameAliases`), é importável pelo tsx dos scripts e testável nos specs TS. **Rejeitadas:** B (segundo lugar/idioma para o mesmo conceito); C (DB/migration/segundo cadastro para insumo interno sem URL pública — appetite e invariante).
**C. Extração read-only do acervo.** Opções: A) modo instituição em `composeCityReportSnapshot`/`extract-city-report-snapshot.mjs`; B) `institutionSnapshot.mjs` + `extract-institution-snapshot.mjs` reusando a plumbing read-only e os loaders de fala com recorte por tema; C) sem extração DB. **Recomendação: B** — o acervo + mapa tema→instituição é fonte declarada do produto, e o recorte não é município. **Rejeitadas:** A (reabre o contrato do snapshot C163 pinado no int spec); C (descarta fonte-chave e contraria a intenção).
**D. Fontes oficiais em tempo de build.** Opções: A) reusar `fetchAuthorEmendas`/`fetchCamaraActivity`/`fetchHealthData`; B) não buscar em build — emendas/proposições institucionais vêm dos itens de pesquisa com fonte; C) filtrar Câmara por nome da instituição na ementa. **Recomendação: B** — o Portal não filtra por instituição, a Câmara tampouco, e `fetchAuthorEmendas` exige município (imprimiria emenda municipal em dossiê institucional). "Sem atribuição → lacuna, nunca zero silencioso". **Rejeitadas:** A (atribuição errada); C (heurística de match nominal = falso positivo/omissão).
**E. Builders e plumbing de PDF.** Opções: A) builder único genérico com `--unit=`; B) `build-dossie-solla-instituicao.mjs` novo + extrair `buildPdf.mjs` (fit-guard + emit) e migrar o C186; C) copiar a plumbing do builder C186. **Recomendação: B** — este é o 3º builder CLI, exatamente o gatilho registrado na review do C186; um dono do "emitir A4 com guarda". C163 fica intocado (fora do escopo deste item). **Rejeitadas:** A (flag gêmea, duas identidades/saídas num entry só); C (twin da plumbing Chromium/orçamento/fit-guard).
**F. Herança de fatos pelo boletim.** Opções: A) derivar de `bulletinFacts` (ledger de fato com fonte) na mesma execução; B) segunda pesquisa. **Recomendação: A** — garantia estrutural: `buildBulletin` institucional recebe apenas o ledger; o renderer não vê research/snapshot crus. **Rejeitadas:** B (reintroduz síntese sem lastro).
**G. Sem truncamento/descarte silencioso (C188 no dono).** Opções: A) declarar "e mais N" no bloco compartilhado quando um cap corta, e a reformulação fica no `summary`; B) paginador de conteúdo completo além das lacunas; C) truncar. **Recomendação: A** — a lista capada é seleção de página, e o resto é **declarado** (`omitted`), nunca sumido; aplica-se ao dono (ambos os recortes), não só à instituição. **Rejeitadas:** B (fora do appetite; C188 dono da regra); C (viola o aceite).
**H. Campo do item para abrangência.** Opções: A) manter `sphere` genérico com enum da unidade (`municipio|regiao|polo` × `instituicao|setor|rede`); B) novo campo `breadth` só para instituição. **Recomendação: A** — um campo genérico, C186 (`item.sphere === 'municipio'`, pinado) verde; o descritor fornece enum/labels/classe. Ressalva documentada: o `sphere`/`scope` do **catálogo** (esfera/alcance do ente) é outro objeto e não deve ser confundido com `sphere` do **item** (abrangência). **Rejeitadas:** B (dois nomes para o mesmo conceito; ramificação no render/blocks).
## Fases verificáveis (tracer bullet first)
1. **Tracer (seam + contrato + conteúdo/render, sem extractor) — quota de meio dia.** `dossieUnit.mjs`; generalização aditiva de `dossieResearch`/`dossieBlocks`/`dossieRender`; módulo institucional com capa + resumo + Era C + abrangência + fontes; `buildPdf.mjs` extraído e C186 migrado; specs C186 verdes e `dossieInstitution{Research,Blocks,Render}` verdes. Prova o encadeamento mais arriscado cedo.
2. **Catálogo + pipeline agêntico.** `institutionCatalog.ts`/`institutionNameAliases.ts` + spec; `SKILL.md` + command + subagente; `dossieInstitutionBatchSkill.unit.spec.ts` (lote/resolução/fail-closed/escape/dedup/fan-out/guarda); `opencodeCommands.unit.spec.ts` atualizado; `test-affected-core.mjs` atualizado.
3. **Extração + builder.** `readOnlyExtract.mjs` (+ edição aditiva do extrator C163), `institutionSnapshot.mjs`, `extract-institution-snapshot.mjs`, `build-dossie-solla-instituicao.mjs`; `tests/int/institutionSnapshot.int.spec.ts`; decisão D exercida (sem emendas/Câmara/saúde em build).
4. **Boletim + port visual + docs.** `buildBulletin`/`dossieBulletinRender` institucionais (completo + variação de poucos fatos); port classe a classe das 9 cenas do dossiê e das 2 do boletim; guardas de fit (`[data-page="resumo"]`, `[data-page="boletim"]`); `.gitignore`; changelog.
5. **Gates.** `pnpm gate:fast` (lint 0 warnings, `tsc --noEmit`, unit) → build local de **UFBA** e de uma instituição esparsa (Enfermagem) 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)
- Forkar o pipeline institucional (research/render/ledger/PDF) — twin proibido; tudo passa pelo dono + descritor.
- Estender o snapshot C163/`assertSnapshotShape` ou editar `build-city-report.mjs` (só a extração aditiva de `readOnlyExtract` no extrator, sem mudança de comportamento).
- Puxar emendas por município / Câmara / IBGE–DATASUS para instituição (decisão D); "completar" com % estadual absoluto; somar `setor`/`rede` à instituição.
- Coleção Payload/migration/segundo cadastro; UI in-app no `/campanha`; índice/PDF agregado de lote.
- Reconstruir a carreira pré-2011 fora do acervo — vira **lacuna**, não invenção.
- Reabrir o design: os hi-fi estão aprovados — só port; se faltar superfície, aciona-se o `designer` (não se redesenha na implementação).
- `--unit` num builder único; migrar o builder C163 para `buildPdf.mjs` agora (fora do escopo).
## Riscos e mitigação
- **Regressão C186/C163 pela generalização:** defaults preservam assinaturas/erros; specs `dossie*`/`cityReport*` no `gate:fast`; build local do C186 conferido após migrar `buildPdf.mjs`.
- **`SCRIPTS_SPEC_PINNED` fora de sincronia:** `ciSkipInvariants` recalcula o closure e falha fechado; atualizar o mapa na mesma fase que cria o módulo.
- **Fit/overflow de página:** orçamentos explícitos e caps; se estourar, **aperta o teto/reformula copy — nunca espreme o layout**; "e mais N" onde cortar.
- **Recorte por tema impreciso (acervo):** `topics` por entrada do catálogo (revisável), cap por tema + "e mais N", e o SKILL declara o limite (acervo cobre 2011+).
- **Autoria de emenda ambígua:** sem atribuição/fonte → lacuna; nunca imprimir emenda municipal como institucional.
- **Fan-out/gitignore/PII:** `MAX_RESEARCHERS_IN_FLIGHT = 6` + extração serializada; artefatos gitignored; subagente proibido de commitar; telefone/e-mail nunca entram.
- **Appetite:** cortes já embutidos — sem APIs oficiais em build (D), sem health, sem agregado de lote, C163 intocado.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: dossiê PDF A4 + `.md` (identificação, eras A/B/C, abrangência 3-listas, títulos/vínculos, lacunas, fontes, defeso) **e** boletim modelo 1×A4 (≤6 destaques + ≤14 "E mais", sem fontes, sem CTA, rótulo "Modelo — insumo interno" + defeso, `NEEDS ASSET`).
- [ ] Catálogo fail-closed acionável + escape `--slug/--name` + dedup; eras A/B/C reusadas; abrangência `instituicao|setor|rede` **nunca somada**; fases por valor (empenho ≠ pago); "sem fonte, não publica"; 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 e C163 verdes; `/dossie-solla-cidade` e `/relatorio-cidade` sem mudança de comportamento; `buildPdf.mjs` é dono único do emit A4 (C186 migrado).
- [ ] `test-affected-core.mjs` sincronizado (closure exato) e `opencodeCommands.unit.spec.ts` com `dossie-solla-instituicao`; command/subagente acoplados pelo nome exato.
- [ ] Unit novos verdes: `institutionCatalog`, `dossieInstitutionResearch`, `dossieInstitutionBlocks`, `dossieInstitutionRender`, `dossieInstitutionBulletin`, `dossieInstitutionBatchSkill`; int `institutionSnapshot`.
- [ ] `.gitignore` + changelog `docs/changelog/2026-09-17-c187.md`; `pnpm gate:fast` verde; build local aceito em UFBA + Enfermagem; `pnpm push`.
---
## Self-score decision-quality (gate ≥4/5)
1. **Decisões caras têm rejeitadas?** Sim — A–H com Opções/Recomendação/Rejeitadas explícitas (seam, catálogo, extração, APIs, builders/PDF, herança, truncamento, campo de abrangência). — **5/5**
2. **Abordagem cabe no appetite (3–4 dias)?** Sim — reuso máximo do dono C186, sem schema/DB, e cortes declarados (sem emendas/Câmara/IBGE em build, sem agregado, C163 intocado). — **4/5** (a generalização aditiva de 5 libs + o novo composer é o trecho que mais consome quota; por isso o tracer cedo e o corte de APIs).
3. **Rabbit holes nomeados?** Sim — fork do pipeline, extensão do snapshot C163, emenda municipal em dossiê institucional, match nominal da Câmara, coleção Payload, UI in-app, agregado, pré-2011 inventado, redesign. — **5/5**
4. **Depth check: reusa shells/helpers existentes?** Sim — `dossieCareer`, `dossieResearch/Blocks/Render/Bulletin/Render`, `cityReportFormat`, `reportText`, `cli`, `cityReportDatabase`/`buildSpeechListWhere`/`loadSegmentsForSpeeches`, `normalizeSearchPhrase`, e extrai `buildPdf.mjs`/`readOnlyExtract.mjs` onde há ≥2 consumidores. — **4/5** (a generalização toca módulos em uso; mitigada por defaults + specs).
5. **Intenção (aceite de produto) permanece satisfeita?** Sim — os dois artefatos, o catálogo fail-closed, os literais (1 página, ≤6/≤14, sem fontes, rótulo de modelo, abrangência não somável) 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.**
## Simplificação e débitos (Passo 5–6 do `work-issue`)
**Já resolvido no simplify (não reabrir):** bug de string `"iten"` no `moreNote`
(mapa de singular + testes de era vazia e `omitted > 0`); `abrangencia` → `reach`
(valor `data-page="abrangencia"` mantido); campos mortos do descritor removidos;
`resolveDossierUnit` fail-closed; exports órfãos desexportados e
`emitHtmlPairPdf` como dono único do emit (C186 migrado); `speeches.rows`
consumido como evidência com fonte (`acervo` no dossiê + ledger do boletim);
`isInstitutionUnit` centralizando o dispatch; caps em constantes nomeadas.
**Adiado com gatilho (não é Issue — DRY/polish em `scripts/**`, 2 call sites):\*\*
- Boilerplate gêmeo dos dois builders (`readJson`, `readEraResearch`, mkdir/write,
`DOSSIER_STRICT`) — gatilho: 4º builder CLI ou edição simultânea; a parte
valiosa (emit/fit-guard) já saiu para `buildPdf.mjs`.
- Mecânica copiada no branch institucional de `dossieBlocks.mjs`
(`researchActionsByEra`, comparador, `bulletinFacts`, `gaps`/`pending`) —
gatilho: 3º unit/recorte ou próxima edição dessas mecânicas.
- `instHeader`/`instFooter` × `sheetHeader`/`sheetFooter`; fase→classe CSS em dois
renderers; typedef `DossierReport` frouxo (`checkJs` off); specs institucionais
~90% cópia do C186; guarda de PII do int spec por `not.toContain('@')`.
**Explicitamente fora (descartado):** renomear `sphere` do item para `breadth`
(opção B rejeitada na decisão H; documentado no `dossieUnit.mjs`); simetria
`unit` no report municipal; `meta.municipality`→`subjectName` no boletim.

View file

@ -13,12 +13,12 @@
* --out-dir=docs/research/dossie-solla-cidade
*/
import { chromium } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { emitHtmlPairPdf, launchPdfBrowser } 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'
@ -38,13 +38,6 @@ const CACHE_DIR = 'data/dossie-solla-cidade'
/** The whole federal mandate, not just the C163 default window. */
const DOSSIER_EMENDAS_YEARS = Array.from({ length: 12 }, (_value, index) => 2015 + index)
const MM_TO_PX = 96 / 25.4
const A4_HEIGHT_PX = Math.round(297 * MM_TO_PX)
const A4_WIDTH_PX = Math.round(210 * MM_TO_PX)
/** Rounding slack of the mm→px conversion; content must still fit the page. */
const PAGE_FIT_TOLERANCE_PX = 10
const PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX
loadCliEnv()
const { flags } = parseEqualsFlags(process.argv.slice(2))
@ -193,66 +186,16 @@ const bulletinPdfFile = join(outDir, `${baseName}-boletim.pdf`)
await writeFile(resolve(ROOT, dossierMdFile), dossierMd)
console.log(`[${LABEL}] companion → ${dossierMdFile}`)
const measureOverflows = async (page, budget) =>
page.evaluate(
(max) =>
[...document.querySelectorAll('[data-page]')]
.map((element) => ({
page: element.getAttribute('data-page'),
height: element.scrollHeight,
}))
.filter((row) => row.height > max),
budget,
)
const browser = await chromium.launch()
const browser = await launchPdfBrowser()
try {
const page = await browser.newPage()
await page.setViewportSize({ width: A4_WIDTH_PX, height: A4_HEIGHT_PX })
await page.setContent(dossierHtml, { waitUntil: 'load' })
await page.emulateMedia({ media: 'print' })
const hasSummary = await page.evaluate(() =>
Boolean(document.querySelector('[data-page="resumo"]')),
)
if (!hasSummary) die('Página de resumo ausente no HTML do dossiê — renderer quebrado.')
const dossierOverflows = await measureOverflows(page, PAGE_BUDGET_PX)
if (dossierOverflows.length > 0) {
die(
`Página(s) do dossiê estouraram o A4 (${dossierOverflows
.map((row) => `${row.page}: ${row.height}px`)
.join(', ')} > ${PAGE_BUDGET_PX}px úteis). ` +
'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.',
)
}
await page.pdf({
path: resolve(ROOT, dossierPdfFile),
printBackground: true,
preferCSSPageSize: true,
margin: { top: '0', bottom: '0', left: '0', right: '0' },
})
await page.setContent(bulletinHtml, { waitUntil: 'load' })
await page.emulateMedia({ media: 'print' })
const hasBulletin = await page.evaluate(() =>
Boolean(document.querySelector('[data-page="boletim"]')),
)
if (!hasBulletin) die('Bloco do boletim ausente no HTML — renderer quebrado.')
const bulletinOverflows = await measureOverflows(page, PAGE_BUDGET_PX)
if (bulletinOverflows.length > 0) {
die(
`O boletim estourou (${bulletinOverflows
.map((row) => `${row.height}px`)
.join(', ')} > ${PAGE_BUDGET_PX}px úteis). ` +
'Corte itens/caps — o boletim TEM de caber em uma página.',
)
}
await page.pdf({
path: resolve(ROOT, bulletinPdfFile),
printBackground: true,
preferCSSPageSize: true,
margin: { top: '0', bottom: '0', left: '0', right: '0' },
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()
}

View file

@ -0,0 +1,171 @@
/**
* Dossiê + boletim builder (C187, institution unit): institution 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 does not filter by
* institution and the Câmara does not either, so institutional emendas and
* propositions 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-instituicao.mjs \
* --snapshot=data/dossie-solla-instituicao/<slug>.institution.snapshot.json \
* --research-dir=data/dossie-solla-instituicao \
* --out-dir=docs/research/dossie-solla-instituicao
*/
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 { emitHtmlPairPdf, launchPdfBrowser } 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 { renderDossierHtml, renderDossierMd } from './lib/dossieRender.mjs'
import { mergeDossierResearch, normalizeDossierResearchInput } from './lib/dossieResearch.mjs'
import { INSTITUTION_UNIT } from './lib/dossieUnit.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-dossie-solla-instituicao'
const die = dieWithLabel(LABEL)
const DEFAULT_OUT_DIR = 'docs/research/dossie-solla-instituicao'
const CACHE_DIR = 'data/dossie-solla-instituicao'
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.institution?.slug
if (!slug) die('Snapshot sem "institution.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.institutionSlug !== slug) {
die(
`Pesquisa da Era ${era} é de "${raw.institutionSlug}", 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: INSTITUTION_UNIT })
} catch (error) {
if (error?.code === 'ENOENT') {
console.log(`[${LABEL}] Era ${era}: sem pesquisa (${path}) — vira lacuna explícita.`)
return normalizeDossierResearchInput(
{
institutionSlug: slug,
era,
researchedAt: generatedAt.toISOString(),
items: [],
},
{ unit: INSTITUTION_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: INSTITUTION_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 report = buildDossierReport({
snapshot,
research,
generatedAt,
unit: INSTITUTION_UNIT,
})
const bulletin = buildBulletin({
facts: report.bulletinFacts,
identity: { name: report.meta.subjectName, badges: report.meta.identityBadges },
unit: INSTITUTION_UNIT,
generatedAt,
})
const dossierHtml = renderDossierHtml(report)
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}.dossie.html`)), dossierHtml)
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml)
await mkdir(resolve(ROOT, outDir), { recursive: true })
const dossierMdFile = join(outDir, `${baseName}-dossie.md`)
const dossierPdfFile = join(outDir, `${baseName}-dossie.pdf`)
const bulletinPdfFile = join(outDir, `${baseName}-boletim.pdf`)
await writeFile(resolve(ROOT, dossierMdFile), dossierMd)
console.log(`[${LABEL}] companion → ${dossierMdFile}`)
const browser = await launchPdfBrowser()
try {
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} 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

@ -15,18 +15,9 @@ import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
databaseLabel,
READ_ONLY_PG_OPTIONS,
withReadOnlyDatabaseUrl,
} from './lib/cityReportDatabase.mjs'
import {
databaseHostname,
dieWithLabel,
isTruthyEnv,
loadCliEnv,
parseEqualsFlags,
} from './lib/cli.mjs'
import { databaseLabel } from './lib/cityReportDatabase.mjs'
import { dieWithLabel, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
import { enterReadOnlySession, resolveCampaignActor } from './lib/readOnlyExtract.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'extract-city-report-snapshot'
@ -40,24 +31,6 @@ const codeSha = () => {
}
}
const resolveActor = async (payload) => {
for (const role of ['candidate', 'coordinator']) {
const result = await payload.find({
collection: 'campaignUser',
where: { role: { equals: role } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
const doc = result.docs[0]
if (doc) return { ...doc, collection: 'campaignUser' }
}
die(
'Nenhum campaignUser com papel candidate/coordinator encontrado — o relatório exige um ator real que enxergue tudo.',
)
}
loadCliEnv()
const { flags } = parseEqualsFlags(process.argv.slice(2))
@ -70,18 +43,12 @@ if (!slug || !out) {
' Env: CITY_REPORT_CONFIRM=1 (obrigatório), DATABASE_URL (read-only é forçado), PORTAL da vida real.',
)
}
if (!isTruthyEnv(process.env.CITY_REPORT_CONFIRM)) {
die(
'Opt-in explícito ausente: rode com CITY_REPORT_CONFIRM=1.\n' +
` Alvo: ${databaseHostname(process.env.DATABASE_URL) ?? 'DATABASE_URL ausente'} — a leitura é read-only, mas o comando não adivinha o alvo.`,
)
}
const rawDatabaseUrl = process.env.DATABASE_URL
process.env.DATABASE_URL = withReadOnlyDatabaseUrl(rawDatabaseUrl, READ_ONLY_PG_OPTIONS)
console.log(
`[${LABEL}] alvo ${databaseLabel(rawDatabaseUrl)} · sessão read-only · município ${slug}`,
)
const rawDatabaseUrl = enterReadOnlySession({
confirmEnv: 'CITY_REPORT_CONFIRM',
label: LABEL,
die,
context: `município ${slug}`,
})
const { default: configPromise } = await import('../src/payload.config.ts')
const { getPayload } = await import('payload')
@ -90,7 +57,7 @@ const { composeCityReportSnapshot } = await import('./cityReportSnapshot.mjs')
const payload = await getPayload({ config: configPromise })
try {
const actor = await resolveActor(payload)
const actor = await resolveCampaignActor(payload, { die })
console.log(`[${LABEL}] ator: ${actor.name ?? actor.id} (${actor.role})`)
const snapshot = await composeCityReportSnapshot({

View file

@ -0,0 +1,157 @@
/**
* Snapshot extractor for the institutional dossiê (C187).
*
* Runs where production lives (homeserver), read-only by connection options,
* and writes a JSON snapshot consumed by
* `scripts/build-dossie-solla-instituicao.mjs`. Nothing is persisted.
*
* The token is resolved fail-closed against the catalog; a one-off institution
* uses the explicit escape `--slug=<x> --name="<Nome>"`.
*
* Usage (from the homeserver, with `~/stack/teqo-1313.env` loaded):
* INSTITUTION_REPORT_CONFIRM=1 NODE_OPTIONS="--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs" \
* node scripts/extract-institution-snapshot.mjs --institution=ufba --out=data/dossie-solla-instituicao/ufba.institution.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 {
getInstitutionCatalogEntry,
INSTITUTION_KIND_LABELS,
INSTITUTION_KINDS,
INSTITUTION_SCOPE_LABELS,
INSTITUTION_SCOPES,
INSTITUTION_SPHERE_LABELS,
INSTITUTION_SPHERES,
institutionSlugs,
} from '../src/lib/institutionCatalog.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-institution-snapshot'
const die = dieWithLabel(LABEL)
const codeSha = () => {
try {
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim()
} catch {
return null
}
}
const oneOf = (value, allowed, fallback) => {
if (value === undefined || value === null || value === '') return fallback
const normalized = String(value).trim()
return allowed.includes(normalized) ? normalized : fallback
}
/**
* Resolve the identity: catalog entry, or the explicit one-off escape. Unknown
* token fails closed with an actionable message — never an invented slug.
*/
const resolveIdentity = (flags) => {
if (typeof flags.institution === 'string' && flags.institution.trim()) {
const entry = getInstitutionCatalogEntry(flags.institution.trim())
if (!entry) {
die(
`Instituição desconhecida no catálogo: "${flags.institution}".\n` +
` Conhecidas: ${institutionSlugs().join(', ')}.\n` +
' Adicione alias/entrada em src/lib/institutionCatalog.ts ou use o escape one-off: --slug=<x> --name="<Nome>".',
)
}
return {
slug: entry.slug,
name: entry.name,
kind: entry.kind,
sphere: entry.sphere,
scope: entry.scope,
aliases: [...entry.aliases],
topics: [...entry.topics],
}
}
const slug = typeof flags.slug === 'string' ? flags.slug.trim() : ''
const name = typeof flags.name === 'string' ? flags.name.trim() : ''
if (!slug || !name) {
die(
'Uso: --institution=<slug> --out=<arquivo.json>\n' +
' Ou escape one-off: --slug=<x> --name="<Nome>" [--kind= --sphere= --scope= --topics=a,b] --out=<arquivo.json>\n' +
' Env: INSTITUTION_REPORT_CONFIRM=1 (obrigatório), DATABASE_URL (read-only é forçado).',
)
}
const topics =
typeof flags.topics === 'string'
? flags.topics
.split(',')
.map((topic) => topic.trim())
.filter(Boolean)
: []
return {
slug,
name,
kind: oneOf(flags.kind, INSTITUTION_KINDS, 'outro'),
sphere: oneOf(flags.sphere, INSTITUTION_SPHERES, 'nao_governamental'),
scope: oneOf(flags.scope, INSTITUTION_SCOPES, 'nacional'),
aliases: [],
topics,
}
}
loadCliEnv()
const { flags } = parseEqualsFlags(process.argv.slice(2))
const out = typeof flags.out === 'string' ? flags.out : null
const identity = resolveIdentity(flags)
if (!out) {
die('Uso: --institution=<slug> --out=<arquivo.json> (ou o escape --slug/--name).')
}
const rawDatabaseUrl = enterReadOnlySession({
confirmEnv: 'INSTITUTION_REPORT_CONFIRM',
label: LABEL,
die,
context: `instituição ${identity.slug}`,
})
const { default: configPromise } = await import('../src/payload.config.ts')
const { getPayload } = await import('payload')
const { composeInstitutionSnapshot } = await import('./institutionSnapshot.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 composeInstitutionSnapshot({
payload,
actor,
identity: {
...identity,
kindLabel: INSTITUTION_KIND_LABELS[identity.kind],
sphereLabel: INSTITUTION_SPHERE_LABELS[identity.sphere],
scopeLabel: INSTITUTION_SCOPE_LABELS[identity.scope],
},
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.institution.name} → ${out} ` +
`(falas=${snapshot.speeches.totalCount} temas=${snapshot.speeches.topics.join(',') || '—'} lacunas=${snapshot.gaps.length})`,
)
} catch (error) {
die(error instanceof Error ? error.message : String(error))
}
process.exit(0)

View file

@ -0,0 +1,145 @@
/**
* Snapshot composer for the institutional dossiê (C187).
*
* Runs on the homeserver against production, read-only: the institutional
* recorte does not exist as a relationship on `speech`, so the bridge is
* theme→institution (`identity.topics`, from the catalog). 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/institutionSnapshot.int.spec.ts`.
*/
import { normalizeForSearch } from '../src/lib/speechSearch.ts'
import { excerptOffsetSeconds } from '../src/lib/speechVod.ts'
import { buildSpeechListWhere } from '../src/utilities/speech/speechListFilters.ts'
import { loadSegmentsForSpeeches } from '../src/utilities/speech/speechPageData.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; `mentionExcerpt`
* is the passage that literally names the institution (null when the acervo tag
* has no literal mention in the ASR segments).
*/
const projectSpeechRow = (doc, segments, matchNames) => {
const candidates = matchNames
.map((name) => ({ name, key: normalizeForSearch(name) }))
.filter((candidate) => candidate.key)
const matched = candidates.find(({ key }) =>
segments.some((segment) => normalizeForSearch(segment.text).includes(key)),
)
const mentionSegment = matched
? segments.find((segment) => normalizeForSearch(segment.text).includes(matched.key))
: undefined
return {
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),
mentionExcerpt: mentionSegment?.text ?? null,
matchedName: matched?.name ?? null,
topics: Array.isArray(doc.topics) ? doc.topics : [],
}
}
/**
* @param {{
* payload: any,
* actor: any,
* identity: { slug: string, name: string, kind: string, kindLabel: string, sphere: string, sphereLabel: string, scope: string, scopeLabel: string, aliases: string[], topics: string[] },
* readAt: string,
* codeSha?: string | null,
* database?: string | null,
* }} params
* @returns {Promise<any>}
*/
export const composeInstitutionSnapshot = async ({
payload,
actor,
identity,
readAt,
codeSha = null,
database = null,
}) => {
const topics = Array.isArray(identity.topics) ? identity.topics : []
const matchNames = [identity.name, ...(identity.aliases ?? [])]
let docs = []
let totalCount = 0
const gaps = []
if (topics.length === 0) {
gaps.push({
id: 'acervo_sem_tema',
reason: 'A instituição não declara temas no catálogo — o recorte do acervo fica vazio.',
})
} else {
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,
})
docs = result.docs
totalCount = result.totalDocs
if (docs.length === 0) {
gaps.push({
id: 'acervo_sem_falas',
reason: `Nenhuma fala do acervo nos temas ${topics.join(', ')} — o acervo cobre 2011+.`,
})
}
}
const segmentsBySpeech = await loadSegmentsForSpeeches(
payload,
actor,
docs.map((doc) => doc.id),
)
return {
meta: { readAt, codeSha, database, kind: 'institution' },
institution: {
slug: identity.slug,
name: identity.name,
kind: identity.kind,
kindLabel: identity.kindLabel,
sphere: identity.sphere,
sphereLabel: identity.sphereLabel,
scope: identity.scope,
scopeLabel: identity.scopeLabel,
aliases: identity.aliases ?? [],
topics,
},
speeches: {
topics,
totalCount,
rows: docs.map((doc) =>
projectSpeechRow(doc, segmentsBySpeech.get(doc.id) ?? [], matchNames),
),
},
gaps,
}
}

95
scripts/lib/buildPdf.mjs Normal file
View file

@ -0,0 +1,95 @@
/**
* A4 print plumbing for the report builders (owner: the C163/C186/C187
* pipeline). Chromium comes from `@playwright/test`; every document is one
* `.sheet` per page with a `data-page` anchor, and no page may be cut by the
* overflow — if it does not fit, the content budget is reduced, the layout is
* never squeezed.
*
* Extracted from `build-dossie-solla-cidade.mjs` when the institution builder
* (C187) became the third consumer, so there is one owner of the mm→px budget,
* the launch and the emit.
*/
import { chromium } from '@playwright/test'
const MM_TO_PX = 96 / 25.4
export const A4_HEIGHT_PX = Math.round(297 * MM_TO_PX)
export const A4_WIDTH_PX = Math.round(210 * MM_TO_PX)
/** Rounding slack of the mm→px conversion; content must still fit the page. */
export const PAGE_FIT_TOLERANCE_PX = 10
export const A4_PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX
export const launchPdfBrowser = () => chromium.launch()
/** Every `[data-page]` taller than the budget, in document order. */
const measurePageOverflows = (page, budgetPx = A4_PAGE_BUDGET_PX) =>
page.evaluate(
(max) =>
[...document.querySelectorAll('[data-page]')]
.map((element) => ({
page: element.getAttribute('data-page'),
height: element.scrollHeight,
}))
.filter((row) => row.height > max),
budgetPx,
)
/** Throws with the list of overflowing pages — callers turn it into `die`. */
export const assertPageFits = (overflows, label, hint) => {
if (overflows.length === 0) return
throw new Error(
`${label} estourou o A4 (${overflows
.map((row) => `${row.page}: ${row.height}px`)
.join(', ')} > ${A4_PAGE_BUDGET_PX}px úteis). ${hint}`,
)
}
const printA4Pdf = (page, outPath) =>
page.pdf({
path: outPath,
printBackground: true,
preferCSSPageSize: true,
margin: { top: '0', bottom: '0', left: '0', right: '0' },
})
/**
* One browser run, two HTML documents: sets each document, asserts its anchor
* exists, guards the fit and emits the PDF. Shared by the C186 (city) and C187
* (institution) builders — the pages differ, the emit contract does not.
*/
export const emitHtmlPairPdf = async (
browser,
{ dossierHtml, bulletinHtml, dossierPdf, bulletinPdf },
) => {
const page = await browser.newPage()
await page.setViewportSize({ width: A4_WIDTH_PX, height: A4_HEIGHT_PX })
await page.setContent(dossierHtml, { waitUntil: 'load' })
await page.emulateMedia({ media: 'print' })
const hasSummary = await page.evaluate(() =>
Boolean(document.querySelector('[data-page="resumo"]')),
)
if (!hasSummary)
throw new Error('Página de resumo ausente no HTML do dossiê — renderer quebrado.')
assertPageFits(
await measurePageOverflows(page, A4_PAGE_BUDGET_PX),
'Página(s) do dossiê',
'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.',
)
await printA4Pdf(page, dossierPdf)
await page.setContent(bulletinHtml, { waitUntil: 'load' })
await page.emulateMedia({ media: 'print' })
const hasBulletin = await page.evaluate(() =>
Boolean(document.querySelector('[data-page="boletim"]')),
)
if (!hasBulletin) throw new Error('Bloco do boletim ausente no HTML — renderer quebrado.')
assertPageFits(
await measurePageOverflows(page, A4_PAGE_BUDGET_PX),
'O boletim',
'Corte itens/caps — o boletim TEM de caber em uma página.',
)
await printA4Pdf(page, bulletinPdf)
await page.close()
}

View file

@ -15,6 +15,12 @@ import {
DOSSIER_SCOPE,
careerTimelineHighlights,
} from './dossieCareer.mjs'
import {
INSTITUTION_UNIT,
MUNICIPALITY_UNIT,
isInstitutionUnit,
resolveDossierUnit,
} from './dossieUnit.mjs'
import { stripInlineSources } from './reportText.mjs'
const MAX_DELIVERIES_PAGE_ONE = 3
@ -26,8 +32,13 @@ const MAX_ERA_CAMARA_ACTIONS = 4
const MAX_REGION_ITEMS = 3
const MAX_SCOPE_LIST = 3
const sphereLabels = { municipio: 'município', regiao: 'região', polo: 'polo' }
export const dossierSphereLabel = (sphere) => sphereLabels[sphere] ?? sphere
/** @param {string} sphere @param {any} [unit] @returns {string} */
export const dossierSphereLabel = (sphere, unit = MUNICIPALITY_UNIT) =>
resolveDossierUnit(unit).sphereLabels?.[sphere] ?? sphere
/** @param {string} sphere @param {any} [unit] @returns {string} */
export const dossierSphereBadgeClass = (sphere, unit = MUNICIPALITY_UNIT) =>
resolveDossierUnit(unit).sphereBadgeClass?.[sphere] ?? ''
const phaseLabels = {
autorizado: 'autorizado',
empenhado: 'empenhado',
@ -130,8 +141,42 @@ const camaraItemsAsActions = (camara, era) =>
}))
/**
* Builds the dossiê report model. `research` is the merged per-era research
* (see `mergeDossierResearch`); `emendas`/`camara`/`health` may be gaps.
* @typedef {Object} DossierReport
* @property {any} meta
* @property {any} cover
* @property {any} page1
* @property {any[]} trajectory
* @property {Array<{ id: string, label: string, period: string, subtitle: string, empty?: boolean, method: string, recovery: string, numbers: any[], actions: any[], honors: any[] }>} eras
* @property {{
* ruleTitle: string,
* ruleBody: string,
* municipal: { label: string, items: any[], total: number },
* regional: { label: string, items: any[], total: number },
* items: any[],
* context: any[],
* hook: any,
* priorityGap: any,
* }} region
* @property {{
* ruleTitle: string,
* ruleBody: string,
* lists: Array<{ key: string, label: string, sphere: string, items: any[], total: number, omitted: number }>,
* evidence: { items: any[], total: number, omitted: number },
* hook: any,
* priorityGap: any,
* }} reach
* @property {{ items: any[], total: number, omitted: number }} acervo
* @property {any[]} gaps
* @property {any[]} news
* @property {any} limits
* @property {any[]} bulletinFacts
* @property {any} [unit]
*/
/**
* Builds the dossiê report model. Dispatches on the unit descriptor: the
* municipality (C186) branch remains the default and byte-identical; the
* institution branch (C187) shares helpers but has its own sections.
*
* @param {{
* snapshot: any,
@ -140,9 +185,14 @@ const camaraItemsAsActions = (camara, era) =>
* camara?: any,
* health?: any,
* generatedAt?: Date,
* unit?: any,
* }} params
* @returns {DossierReport}
*/
export const buildDossierReport = ({
export const buildDossierReport = (params) =>
isInstitutionUnit(params?.unit) ? buildInstitutionReport(params) : buildMunicipalityReport(params)
const buildMunicipalityReport = ({
snapshot,
research,
emendas = null,
@ -189,7 +239,7 @@ export const buildDossierReport = ({
const honors =
era.id === 'C'
? items
.filter((item) => item.id === 'era_c_titulos')
.filter((item) => item.era === era.id && item.id === 'era_c_titulos')
.map((item) => ({
text: item.answer,
brief: item.brief ?? null,
@ -385,4 +435,296 @@ export const buildDossierReport = ({
}
}
/* ------------------------------------------------------------------ *
* Institution branch (C187) — same owner, institution-shaped sections. *
* ------------------------------------------------------------------ */
const MAX_INSTITUTION_DELIVERIES = 3
const MAX_INSTITUTION_TIMELINE = 3
const MAX_INSTITUTION_HOOKS = 2
const MAX_INSTITUTION_PENDING = 4
const MAX_INSTITUTION_SCOPE_LIST = 5
const MAX_INSTITUTION_EVIDENCE = MAX_INSTITUTION_SCOPE_LIST * 2
const MAX_INSTITUTION_ACERVO = 6
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
* the owner for both recortes.
*/
const capped = (list, max) => ({
items: limit(list, max),
total: list.length,
omitted: Math.max(0, list.length - max),
})
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 institution recorte, so
* they feed the bulletin ledger too — never a second, unsourced fact.
*/
const institutionSpeechFacts = (snapshot) =>
(snapshot.speeches?.rows ?? [])
.filter((row) => Boolean(row.officialTextUrl || row.youtubeUrl || row.vodPlaybackUrl))
.map((row) => ({
id: `fala-${row.id}`,
era: 'C',
sphere: 'instituicao',
area: 'Acervo de falas',
headline:
row.summary ??
row.mentionExcerpt ??
`Pronunciamento na Câmara (${String(row.speechAt ?? '').slice(0, 4)})`,
detail: row.mentionExcerpt ?? null,
value: null,
numberLabel: null,
year: row.speechAt ? String(row.speechAt).slice(0, 4) : null,
phase: null,
sourceUrl: row.officialTextUrl ?? row.youtubeUrl ?? row.vodPlaybackUrl,
sourceDate: row.speechAt ?? null,
}))
const buildInstitutionReport = ({ snapshot, research, generatedAt = new Date() }) => {
const identity = snapshot.institution ?? {}
const subjectName = identity.name ?? identity.slug ?? 'Instituição'
const unit = INSTITUTION_UNIT
const speechFacts = institutionSpeechFacts(snapshot)
const items = research.items ?? []
const inScope = (sphere) => items.filter((item) => item.sphere === sphere)
const directItems = inScope('instituicao')
const sectorItems = inScope('setor')
const networkItems = inScope('rede')
const researchActionsByEra = (era) =>
items
.filter((item) => item.era === era)
.map((item) => ({
sphere: item.sphere,
year: item.numbers?.[0]?.year ?? null,
title: item.answer,
detail: item.details,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}))
const eraSections = DOSSIER_ERAS.map((era) => {
const numbers = limit(
itemNumberRows(items.filter((item) => item.era === era.id)),
MAX_ERA_NUMBERS,
)
const actions = limit(researchActionsByEra(era.id), MAX_ERA_CAMARA_ACTIONS)
const honors = items
.filter((item) => item.era === era.id && item.id === 'era_c_titulos')
.map((item) => ({
text: item.answer,
detail: item.details ?? null,
brief: item.brief ?? null,
date: item.sourceDate ?? null,
year: item.numbers?.[0]?.year ?? null,
sourceUrl: item.sourceUrl,
}))
return {
...era,
method: institutionEraMethod[era.id],
recovery: institutionEraRecovery[era.id],
numbers,
actions,
honors,
empty: numbers.length === 0 && actions.length === 0,
}
})
const deliveryList = [...directItems]
.sort((left, right) => {
const leftEra = left.era === 'C' ? 0 : 1
const rightEra = right.era === 'C' ? 0 : 1
if (leftEra !== rightEra) return leftEra - rightEra
const leftNumber = left.numbers?.length ? 0 : 1
const rightNumber = right.numbers?.length ? 0 : 1
return leftNumber - rightNumber
})
.map((item) => ({
sphere: item.sphere,
era: item.era,
year: item.numbers?.[0]?.year ?? null,
title: item.answer,
detail: item.details,
brief: item.brief ?? null,
value: item.numbers?.[0]?.value ?? null,
phase: item.numbers?.[0]?.phase ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}))
const deliveries = capped(deliveryList, MAX_INSTITUTION_DELIVERIES)
const hookList = items.map((item) => ({
topic: item.area,
angle: item.answer,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
}))
const hooks = capped(hookList, MAX_INSTITUTION_HOOKS)
const acervo = capped(
speechFacts.map((fact) => ({
id: fact.id,
period: fact.year ?? '—',
text: fact.headline,
excerpt: fact.detail,
sourceUrl: fact.sourceUrl,
})),
MAX_INSTITUTION_ACERVO,
)
const timelineList = items
.filter((item) => item.sourceUrl)
.map((item) => ({
period: item.numbers?.[0]?.year ?? (item.sourceDate ?? '').slice(0, 4) ?? '—',
role: item.answer,
brief: item.brief ?? null,
source: item.label,
url: item.sourceUrl,
}))
.sort((left, right) => String(left.period).localeCompare(String(right.period)))
const timeline = capped(timelineList, MAX_INSTITUTION_TIMELINE)
const gaps = (research.gaps ?? []).map((gap) => ({
label: gap.label ?? gap.id,
reason: gap.reason,
nextStep: 'Apurar em fonte primária.',
}))
const pendingList = (research.gaps ?? []).map((gap) => gap.label ?? gap.id)
const pending = capped(pendingList, MAX_INSTITUTION_PENDING)
const scopeItem = (sphere, key, label) => {
const sphereItems = items.filter((item) => item.sphere === sphere)
return { key, label, sphere, ...capped(sphereItems, MAX_INSTITUTION_SCOPE_LIST) }
}
const scopeLists = [
scopeItem('instituicao', 'institution', unit.sphereLabels.instituicao),
scopeItem('setor', 'sector', unit.sphereLabels.setor),
scopeItem('rede', 'network', unit.sphereLabels.rede),
]
const scopeEvidence = capped(
[...sectorItems, ...networkItems].map((item) => ({
item: item.answer,
sphere: item.sphere,
evidence: item.details ?? item.label,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
})),
MAX_INSTITUTION_EVIDENCE,
)
const bulletinFacts = items
.filter((item) => Boolean(item.sourceUrl))
.map((item) => ({
id: item.id,
era: item.era,
sphere: item.sphere,
area: item.area,
headline: stripInlineSources(item.answer),
detail: item.details ? stripInlineSources(item.details) : null,
brief: item.brief
? {
title: stripInlineSources(item.brief.title),
note: item.brief.note ? stripInlineSources(item.brief.note) : null,
}
: null,
value: item.numbers?.[0]?.value ?? null,
numberLabel: item.numbers?.[0]?.label ?? null,
year: item.numbers?.[0]?.year ?? null,
phase: item.numbers?.[0]?.phase ?? null,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}))
.concat(speechFacts.map((fact) => ({ ...fact, headline: stripInlineSources(fact.headline) })))
const generatedAtLabel = formatDateTimeBr(generatedAt)
const readAtLabel = snapshot.meta?.readAt ? formatDateTimeBr(snapshot.meta.readAt) : '—'
return {
unit,
meta: {
title: unit.title,
subjectName,
identity,
identityBadges: identityBadges(identity),
generatedAt,
generatedAtLabel,
readAtLabel,
},
cover: {
kicker: 'Comunicação · pesquisa documental',
series: unit.series,
internalNote: 'INSUMO INTERNO — defeso 2026',
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,
version: 'Documento de trabalho · versão 01',
},
page1: { timeline, deliveries, hooks, pending },
trajectory: CAREER_TIMELINE,
eras: eraSections,
acervo,
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.',
lists: scopeLists,
evidence: scopeEvidence,
hook: hooks.items[0] ?? null,
priorityGap: pending.items[0] ?? null,
},
gaps,
news: (research.news ?? []).map((row) => ({
date: row.publishedAt,
outlet: row.outlet ?? '—',
title: row.title,
url: row.url,
})),
limits: {
coverage: [
'O acervo interno de falas cobre 2011+; períodos anteriores dependem de fontes externas.',
'Resultado de busca não prova ausência histórica.',
'Emenda de bancada ou relator só recebe autoria quando a fonte a confirma.',
'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.',
],
},
bulletinFacts,
}
}
export const dossierPhaseLabel = (phase) => phaseLabels[phase] ?? phaseLabels.nao_informado

View file

@ -1,22 +1,23 @@
/**
* Boletim model (C186): one A4 page, voter-facing. It reads ONLY the
* `bulletinFacts` ledger produced by `buildDossierReport` — every fact there
* already carries a source, so the boletim cannot introduce a new fact. No
* sources are printed (they live in the dossiê).
* Boletim model (owner: C186; unit seam C187): one A4 page, voter-facing. It
* reads ONLY the `bulletinFacts` ledger produced by `buildDossierReport` — every
* fact there already carries a source, so the boletim cannot introduce a new
* fact. No sources are printed (they live in the dossiê).
*/
import { formatDateTimeBr } from './cityReportFormat.mjs'
import { dossierSphereLabel } from './dossieBlocks.mjs'
import { dossierPhaseLabel, dossierSphereLabel } from './dossieBlocks.mjs'
import { BULLETIN_TIMELINE_STEPS } from './dossieCareer.mjs'
import { resolveDossierUnit } from './dossieUnit.mjs'
export const BULLETIN_HIGHLIGHT_LIMIT = 6
export const BULLETIN_MORE_LIMIT = 14
const sphereRank = { municipio: 0, regiao: 1, polo: 2 }
const eraRank = { C: 0, B: 1, A: 2 }
const rankFacts = (facts) =>
[...facts].sort((left, right) => {
const rankFacts = (facts, unit) => {
const sphereRank = resolveDossierUnit(unit).breadthRank ?? {}
return [...facts].sort((left, right) => {
const sphere = (sphereRank[left.sphere] ?? 9) - (sphereRank[right.sphere] ?? 9)
if (sphere !== 0) return sphere
const hasValue = (left.value ? 0 : 1) - (right.value ? 0 : 1)
@ -25,27 +26,36 @@ const rankFacts = (facts) =>
if (era !== 0) return era
return String(left.id).localeCompare(String(right.id))
})
}
/**
* @param {{
* facts?: Array<{ id: string, era: string, sphere: string, area: string, headline: string, detail: string|null, value: string|null, year: string|null, phase: string|null, sourceUrl: string, sourceDate: string }>,
* municipality: string,
* municipality?: string,
* region?: string | null,
* identity?: { name: string, badges?: string[] } | null,
* unit?: any,
* generatedAt?: Date,
* }} params
*/
export const buildBulletin = ({
facts = [],
municipality,
municipality = null,
region = null,
identity = null,
unit,
generatedAt = new Date(),
}) => {
const ordered = rankFacts(facts)
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)}`,
eyebrow: `${fact.area ?? 'Atuação'} · ${dossierSphereLabel(fact.sphere, resolvedUnit)}`,
number: fact.value,
title: fact.brief?.title ?? fact.headline,
note: fact.brief?.note ?? fact.detail,
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, BULLETIN_HIGHLIGHT_LIMIT + BULLETIN_MORE_LIMIT)
@ -55,11 +65,16 @@ export const buildBulletin = ({
}))
return {
unit: resolvedUnit,
sparse: ordered.length < 3,
meta: {
title: 'Boletim informativo modelo',
title: resolvedUnit.bulletinTitle,
kicker: 'Boletim informativo · atuação pública',
municipality,
subjectName,
municipality: subjectName,
region,
identity,
identityBadges: identity?.badges ?? [],
generatedAt,
generatedAtLabel: formatDateTimeBr(generatedAt),
modelLabel: 'Modelo — insumo interno',

View file

@ -5,6 +5,7 @@
* as an internal model.
*/
import { isInstitutionUnit } from './dossieUnit.mjs'
import { htmlEscape } from './reportText.mjs'
const ASSET = (title, lines) =>
@ -12,10 +13,22 @@ const ASSET = (title, lines) =>
.map((line) => htmlEscape(line))
.join('<br />')}</p></div>`
const renderHighlight = (highlight) => `
const phaseModifier = (phase) =>
phase === 'pago' || phase === 'liquidado'
? 'phase-paid'
: phase === 'empenhado' || phase === 'autorizado'
? 'phase-pending'
: ''
const renderHighlight = (highlight, { showPhase = false } = {}) => `
<article class="highlight-card">
<p class="highlight-eyebrow">${htmlEscape(highlight.eyebrow)}</p>
${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>`
: ''
}
<h3 class="highlight-title">${htmlEscape(highlight.title)}</h3>
${highlight.note ? `<p class="highlight-note">${htmlEscape(highlight.note)}</p>` : ''}
</article>`
@ -101,7 +114,7 @@ const PRINT_CSS = `
.bulletin-foot .stamp { margin: 0; text-align: right; font-size: 7.2pt; font-weight: 600; line-height: 1.3; color: #435264; }
`
export const renderBulletinHtml = (bulletin) => `
const renderMunicipalityBulletinHtml = (bulletin) => `
<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.municipality)}</title><style>${PRINT_CSS}</style></head>
@ -192,3 +205,165 @@ export const renderBulletinHtml = (bulletin) => `
</article>
</body>
</html>`
/* Institution boletim (C187) — ports the institutional boletim hi-fi. */
const INSTITUTION_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; }
.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; }
.lacuna-panel .lacuna-rule { margin-top: 2mm; border-top: .25mm solid #d8bb8d; padding-top: 2mm; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; line-height: 1.35; letter-spacing: .055em; }
.production-note { border: .25mm solid #cbd5dc; background: #f3f6f8; border-radius: .125rem; padding: 3mm; }
.opening { margin-top: 2.5mm; }
.highlights { margin-top: 3mm; }
.bulletin-timeline { margin-top: 2.5mm; }
.more { margin-top: 2.5mm; }
.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; }
.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; }
`
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 sparse = Boolean(bulletin.sparse)
const pills = (bulletin.meta.identityBadges ?? [])
.map((badge) => `<span class="identity-pill">${htmlEscape(badge)}</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>
<body>
<article class="sheet" data-page="boletim" aria-label="Modelo de boletim informativo institucional de uma página">
<header class="bulletin-head">
<div>
<p class="kicker">${htmlEscape(bulletin.meta.kicker)}</p>
<p class="subject">${htmlEscape(bulletin.meta.subjectName)}</p>
${pills ? `<div class="pills">${pills}</div>` : ''}
</div>
<span class="model-label">${htmlEscape(bulletin.meta.modelLabel)}</span>
</header>
<div class="defeso-band" role="note" aria-label="Nota de defeso eleitoral">
<span>DEFESO 2026 · material em preparação · não publicar sem revisão editorial e jurídica</span>
<span style="text-transform: uppercase; letter-spacing: .07em">sem CTA · sem propaganda</span>
</div>
<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>
</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',
'origem, autoria e licença',
])}
</section>
<section class="highlights" aria-label="Destaques">
<div class="highlights-head">
<div>
<p class="section-label">Destaques</p>
<h2>${sparse ? 'Poucos fatos, sem enchimento' : 'Resultados em leitura rápida'}</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>
${
highlights.length
? `<div class="highlight-grid" style="${sparse ? 'grid-template-columns: repeat(2, 1fr)' : ''}">${highlights.map((highlight) => renderHighlight(highlight, { showPhase: true })).join('')}</div>`
: '<p class="empty">Sem fato com fonte suficiente para destacar — ver lacunas no dossiê.</p>'
}
</section>
<section class="bulletin-timeline" aria-label="Trajetória">
<div class="timeline-head">
<h2>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>
</div>
<div class="timeline-grid">
${bulletin.timeline
.map(
(step) => `<div class="timeline-step">
<p class="timeline-period">${htmlEscape(step.period)}</p>
<p class="timeline-text">${htmlEscape(step.text)}</p>
</div>`,
)
.join('')}
</div>
</section>
<section class="more" aria-label="Outras ações">
<div>
<div class="more-head">
<div>
<p class="section-label">E mais</p>
<h2>${sparse ? 'Outros registros confirmados' : 'Outras ações confirmadas'}</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>
${
sparse
? `<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 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'])}
</aside>`
}
</section>
${
sparse
? `<section style="margin-top: 3mm; display: grid; grid-template-columns: 1fr 48mm; gap: 4mm">
<div class="production-note">
<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'])}
</section>`
: ''
}
<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>
</div>
<p class="stamp">MODELO · INSUMO INTERNO<br />A4 · página 1/1<br />${htmlEscape(bulletin.meta.generatedAtLabel)}</p>
</div>
</footer>
</article>
</body>
</html>`
}
/** Dispatch: the institution unit renders the institutional composition. */
export const renderBulletinHtml = (bulletin) =>
isInstitutionUnit(bulletin?.unit)
? renderInstitutionBulletinHtml(bulletin)
: renderMunicipalityBulletinHtml(bulletin)

View file

@ -6,7 +6,8 @@
*/
import { formatDateBr, formatDateTimeBr } from './cityReportFormat.mjs'
import { dossierPhaseLabel, dossierSphereLabel } from './dossieBlocks.mjs'
import { dossierPhaseLabel, dossierSphereBadgeClass, dossierSphereLabel } from './dossieBlocks.mjs'
import { MUNICIPALITY_UNIT, isInstitutionUnit } from './dossieUnit.mjs'
import { htmlEscape, stripInlineSources } from './reportText.mjs'
const SOURCE_ICON =
@ -20,8 +21,10 @@ const sourceLink = (url, label = '(fonte)') =>
? `<a class="source-link" href="${htmlEscape(url)}" aria-label="Abrir fonte">${SOURCE_ICON}${htmlEscape(label)}</a>`
: ''
const scopeBadge = (sphere, label = null) =>
`<span class="scope${sphere === 'municipio' ? '' : ' scope-region'}">${htmlEscape(label ?? dossierSphereLabel(sphere))}</span>`
const scopeBadge = (sphere, label = null, unit = MUNICIPALITY_UNIT) => {
const badgeClass = dossierSphereBadgeClass(sphere, unit)
return `<span class="scope${badgeClass ? ` ${badgeClass}` : ''}">${htmlEscape(label ?? dossierSphereLabel(sphere, unit))}</span>`
}
/** `dd/mm/aa` — the news table column is 9% wide (no room for a 4-digit year). */
const formatShortDateBr = (value) => {
@ -40,7 +43,16 @@ const copyHtml = (value) => htmlEscape(stripInlineSources(value))
const briefOr = (brief, fallback) => brief?.title ?? fallback
const noteOr = (brief, fallback) => brief?.note ?? fallback
const phaseBadge = (phase) => `<span class="phase">${htmlEscape(dossierPhaseLabel(phase))}</span>`
const PHASE_MODIFIER = {
autorizado: 'phase-pending',
empenhado: 'phase-pending',
liquidado: 'phase-paid',
pago: 'phase-paid',
}
const phaseBadge = (phase) => {
const modifier = PHASE_MODIFIER[phase]
return `<span class="phase${modifier ? ` ${modifier}` : ''}">${htmlEscape(dossierPhaseLabel(phase))}</span>`
}
const placeholder = (label) =>
`<span class="placeholder-bar" aria-label="${htmlEscape(label)}"></span>`
@ -636,10 +648,11 @@ const PRINT_CSS = `
.cover-foot-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 8mm; }
.cover-scope { margin: 0; max-width: 118mm; font-size: 10.5pt; line-height: 1.4; color: #435264; }
.cover-version { margin: 0; text-align: right; font-size: 8pt; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: #315c75; }
.cover-accent { color: #315c75; }
.defeso { margin-top: auto; }
`
export const renderDossierHtml = (report) => {
const renderMunicipalityDossierHtml = (report) => {
const gapChunks = chunkGaps(report.gaps)
const newsChunks = chunkNews(report.news)
const eraSheets = report.eras.map((era) => ({ era, chunks: chunkEraActions(era) }))
@ -701,7 +714,7 @@ const mdEra = (era) => {
return lines.join('\n')
}
export const renderDossierMd = (report) => {
const renderMunicipalityDossierMd = (report) => {
const lines = [
`# ${report.meta.title} — ${report.meta.municipalityName}`,
'',
@ -789,3 +802,791 @@ export const renderDossierMd = (report) => {
)
return `${lines.join('\n')}\n`
}
/* ------------------------------------------------------------------ *
* Institution renderer (C187) — ports the approved institutional *
* hi-fi (`dossie-solla-instituicao-ui-design.html`) class-by-class; *
* shares the print shell, source tokens and table CSS of the city *
* renderer (one owner). *
* ------------------------------------------------------------------ */
const INSTITUTION_PRINT_CSS = `
${PRINT_CSS}
.identity-badge { display: inline-flex; align-items: center; min-height: 5mm; padding: .45mm 1.6mm; border: .25mm solid #cbd5dc; border-radius: 99px; color: #435264; background: #f3f6f8; font-size: 7.4pt; line-height: 1; font-weight: 700; letter-spacing: .035em; text-transform: uppercase; }
.scope-sector { border-color: #9d8b64; color: #65501f; background: #fffaf0; }
.scope-network { border-color: #8f819c; color: #554362; background: #faf7fc; }
.phase-paid { color: #285338; background: #e6f2e9; }
.phase-pending { color: #6b4918; background: #fff2d9; }
.empty-panel { border: .45mm dashed #b18549; color: #684819; background: #fff7e8; border-radius: .25rem; padding: 5mm 8mm; text-align: center; }
.empty-panel .rule-title { margin-top: 1.5mm; }
.empty-panel .warn-icon { width: 8mm; height: 8mm; margin: 0 auto; color: #8a5a18; }
.timeline-item { position: relative; padding-left: 5mm; }
.timeline-item::before { content: ''; position: absolute; top: 1.5mm; left: 0; width: 2.6mm; height: 2.6mm; border: .5mm solid #fff; border-radius: 99px; background: #315c75; box-shadow: 0 0 0 .35mm #315c75; }
.id-card { border: .25mm solid #cbd5dc; border-radius: .25rem; background: #f3f6f8; padding: 3mm; }
.id-card dl { display: grid; grid-template-columns: 22mm 1fr; gap: 1mm 2mm; font-size: 9pt; line-height: 1.3; }
.id-card dt { font-weight: 600; color: #435264; }
.id-card dd { margin: 0; }
.summary-top { display: grid; grid-template-columns: 58mm 1fr; gap: 4mm; margin-top: 4mm; }
.vinc-timeline { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2mm; border-top: .25mm solid #cbd5dc; padding-top: 3mm; margin-top: 3mm; }
.vinc-period { margin: 0; font-size: 8pt; font-weight: 700; color: #315c75; }
.vinc-text { margin: 1mm 0 0; font-size: 9pt; font-weight: 600; line-height: 1.25; }
.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; }
.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; }
.scope-cards .tight-list { margin-top: 2mm; }
.more-note { margin: 1.5mm 0 0; font-size: 8pt; font-weight: 600; color: #8a5a18; }
`
const instHeader = (report, title, subtitle, pageNo) => `
<header class="report-header">
<div>
<p class="report-kicker">Dossiê institucional · ${htmlEscape(report.meta.subjectName)}</p>
<h2 class="sheet-title">${htmlEscape(title)}</h2>
${subtitle ? `<p class="sheet-sub">${htmlEscape(subtitle)}</p>` : ''}
</div>
<div class="report-meta">
<p><strong class="warning-text">INSUMO INTERNO — defeso 2026</strong></p>
<p>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</p>
<p>pág. ${pageNo}/${report.meta.pageTotal}</p>
</div>
</header>`
const instFooter = (report, left, pageNo) => `
<footer class="report-footer">
<span>Dossiê institucional · ${htmlEscape(report.meta.subjectName)} · ${htmlEscape(left)}</span>
<span>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))} · pág. ${pageNo}/${report.meta.pageTotal}</span>
</footer>`
/** Singular of the capped-list nouns (never derive it by chopping the "s"). */
const CAPPED_NOUNS = {
itens: 'item',
entregas: 'entrega',
vínculos: 'vínculo',
}
const moreNote = (cappedList, noun = 'itens') => {
if (cappedList.omitted <= 0) return ''
const label = cappedList.omitted === 1 ? (CAPPED_NOUNS[noun] ?? noun) : noun
return `<p class="more-note">e mais ${cappedList.omitted} ${label} com fonte fora desta página — nada foi descartado.</p>`
}
const identityBadgeList = (report) =>
report.meta.identityBadges
.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">
<div class="accent-bar"></div>
<header class="cover-head">
<div>
<p class="report-kicker">${htmlEscape(report.cover.kicker)}</p>
<p class="cover-series">${htmlEscape(report.cover.series)}</p>
</div>
<div class="internal-note"><strong>${htmlEscape(report.cover.internalNote)}</strong><br />${htmlEscape(report.cover.internalNoteSub)}</div>
</header>
<div class="cover-block">
<p class="eyebrow">${htmlEscape(report.cover.eyebrow)}</p>
<h1 class="cover-title">Dossiê Solla —<br /><span class="cover-accent">${htmlEscape(report.cover.title)}</span></h1>
<p class="cover-sub">${htmlEscape(report.cover.subtitle)}</p>
</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>
</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'])}
</div>
<div class="cover-foot">
<div class="ink-rule"></div>
<div class="cover-foot-row">
<p class="cover-scope">${htmlEscape(report.cover.scope)}</p>
<p class="cover-version">Documento de trabalho<br />versão 01</p>
</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))
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 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 `
<div class="vinc-timeline">
${timeline.items
.map(
(row) => `<div class="timeline-item">
<p class="vinc-period">${htmlEscape(row.period || '—')}</p>
<p class="vinc-text">${copyHtml(briefOr(row.brief, row.role))}</p>
<p class="vinc-source">${htmlEscape(row.source ?? '')} ${sourceLink(row.url)}</p>
</div>`,
)
.join('')}
</div>
${moreNote(timeline, 'vínculos')}`
}
const renderInstitutionDeliveries = (report) => {
const deliveries = report.page1.deliveries
if (deliveries.items.length === 0) return ''
return `
<section class="block">
<div class="block-head">
<div><p class="eyebrow">02 · destaques com lastro</p><h3 class="section-title">Principais entregas localizadas</h3></div>
<p class="warning-text meta strong">Cada valor mantém a fase e a abrangência</p>
</div>
<div class="deliveries">
${deliveries.items
.map(
(row) => `<div class="delivery-row">
<div class="delivery-scope">${scopeBadge(row.sphere, null, report.unit)}<p class="meta">Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}</p></div>
<div>
<p class="delivery-title">${copyHtml(briefOr(row.brief, row.title))}</p>
${noteOr(row.brief, row.detail) ? `<p class="muted small">${copyHtml(noteOr(row.brief, row.detail))}</p>` : ''}
</div>
<div class="delivery-value">
<p class="value-big tabular">${valueCell(row.value, 'Valor não informado')}</p>
${row.phase ? phaseBadge(row.phase) : ''}
${sourceLink(row.sourceUrl)}
</div>
</div>`,
)
.join('')}
</div>
${moreNote(deliveries, 'entregas')}
<p class="muted small strong">Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor.</p>
</section>`
}
const renderInstitutionHooksAndPending = (report) => {
const hooks = report.page1.hooks
const pending = report.page1.pending
if (hooks.items.length === 0 && pending.items.length === 0) return ''
return `
<section class="page1-grid">
${
hooks.items.length
? `<div class="panel">
<p class="eyebrow">Gancho para a agenda</p>
<ul class="tight-list">${hooks.items
.map(
(hook) =>
`<li><strong>${htmlEscape(hook.topic)}:</strong> ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)}</li>`,
)
.join('')}</ul>
</div>`
: '<div></div>'
}
${
pending.items.length
? `<div class="panel panel-warning">
<p class="eyebrow warning-text">O que falta</p>
<ul class="tight-list">${pending.items.map((item) => `<li>${htmlEscape(item)}</li>`).join('')}</ul>
<p class="warning-text small strong">Ausência é resultado: não completar por inferência.</p>
</div>`
: '<div></div>'
}
</section>`
}
const renderInstitutionSummary = (report, pageNo) => `
<article class="sheet" data-page="resumo" aria-label="Resumo de uma olhada do dossiê institucional">
${instHeader(report, 'Resumo de uma olhada', 'Informação datada e pronta para conferência', pageNo)}
<section class="summary-top">
${renderInstitutionIdentity(report)}
<div>
<p class="eyebrow">01 · vínculo com a instituição</p>
<h3 class="section-title">Linha do tempo documentada</h3>
${renderInstitutionVincTimeline(report)}
</div>
</section>
${renderInstitutionDeliveries(report)}
${renderInstitutionHooksAndPending(report)}
${instFooter(report, 'Resumo · INSUMO INTERNO', pageNo)}
</article>`
const renderInstitutionEraEmpty = (report, era, pageNo) => `
<article class="sheet" data-page="era-${era.id.toLowerCase()}" aria-label="${htmlEscape(era.label)}">
${instHeader(report, `${era.label} · Pesquisa sem evidência suficiente`, era.period, pageNo)}
<div class="era-method">
<div>
<p class="eyebrow">Recorte previsto</p>
<p class="muted">${htmlEscape(era.method)}</p>
</div>
<aside class="aside-accent"><strong>Trilha tentada</strong><br />${htmlEscape(era.recovery)}</aside>
</div>
<section class="empty-panel" style="margin-top: 5mm">
${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>
</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>
</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>
</ul>
</div>
</section>
<aside class="rule-warning">
${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>
</div>
</aside>
${instFooter(report, `${era.label} · lacuna explícita`, pageNo)}
</article>`
const renderInstitutionEra = (report, era, pageNo) => {
if (era.empty) return renderInstitutionEraEmpty(report, era, pageNo)
return `
<article class="sheet" data-page="era-${era.id.toLowerCase()}" aria-label="${htmlEscape(era.label)}">
${instHeader(report, era.label, `${era.period} · ${era.subtitle}`, pageNo)}
<div class="era-method">
<div>
<p class="eyebrow">Recorte e método</p>
<p class="muted">${htmlEscape(era.method)}</p>
</div>
<aside class="aside-accent"><strong>Trilha de recuperação</strong><br />${htmlEscape(era.recovery)}</aside>
</div>
${
era.numbers.length
? `<section class="block">
<div class="block-head">
<div><p class="eyebrow">Entregas localizadas</p><h3 class="section-title">Objeto, valor, fase e alcance</h3></div>
</div>
<table class="document-table">
<caption class="sr-only">Valores por ano, fase, abrangência e fonte</caption>
<colgroup><col style="width:26%" /><col style="width:14%" /><col style="width:9%" /><col style="width:16%" /><col style="width:15%" /><col style="width:20%" /></colgroup>
<thead><tr><th>Objeto</th><th>Valor</th><th>Ano</th><th>Fase</th><th>Abrangência</th><th>Fonte</th></tr></thead>
<tbody>${era.numbers
.map(
(row) => `<tr>
<td>${htmlEscape(row.object)}</td>
<td class="numeric">${valueCell(row.value, 'Valor a recuperar')}</td>
<td class="numeric">${htmlEscape(row.year ?? '—')}</td>
<td>${phaseBadge(row.phase)}</td>
<td>${scopeBadge(row.sphere, null, report.unit)}</td>
<td>${sourceLink(row.sourceUrl)}${row.sphere !== 'instituicao' ? '<br /><span class="meta">não somar à instituição</span>' : ''}</td>
</tr>`,
)
.join('')}</tbody>
</table>
<p class="warning-text small strong">Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.</p>
</section>`
: ''
}
${
era.actions.length
? `<section class="block">
<p class="eyebrow">Atuação / vínculo registrado</p>
<h3 class="section-title">Papéis com evidência visível</h3>
<div class="action-grid">
${era.actions
.map(
(action) => `<article class="panel">
<div class="action-head">${scopeBadge(action.sphere, null, report.unit)}<span class="meta">${htmlEscape(action.year ?? '—')}</span></div>
<h4 class="action-title">${copyHtml(briefOr(action.brief, action.title))}</h4>
${noteOr(action.brief, action.detail) ? `<p class="muted small">${copyHtml(noteOr(action.brief, action.detail))}</p>` : ''}
${sourceLink(action.sourceUrl)}
</article>`,
)
.join('')}
</div>
</section>`
: ''
}
${
era.honors.length
? `<section class="block honors">
<div class="panel">
<p class="eyebrow">Títulos, honrarias e vínculos institucionais</p>
<ul class="tight-list">${era.honors
.map(
(honor) =>
`<li>${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}</li>`,
)
.join('')}</ul>
</div>
${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])}
</section>`
: ''
}
${instFooter(report, era.label, pageNo)}
</article>`
}
const renderInstitutionScope = (report, pageNo) => {
const reach = report.reach
const cardClass = { institution: '', sector: 'card-sector', network: 'card-network' }
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)}
<section class="rule-warning">
${WARNING_ICON}
<div>
<p class="rule-title">${htmlEscape(reach.ruleTitle)}</p>
<p class="muted">${htmlEscape(reach.ruleBody)}</p>
</div>
</section>
<section class="block">
<p class="eyebrow">Leitura lado a lado</p>
<h3 class="section-title">O alcance acompanha cada evidência</h3>
<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>
<p class="scope-count tabular">${list.total} ${list.total === 1 ? 'item' : 'itens'}</p>
<p class="meta">com fonte</p>
<ul class="tight-list">${list.items
.map(
(item) =>
`<li>${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}</li>`,
)
.join(
'',
)}${list.items.length === 0 ? '<li class="muted">Nenhum item com fonte.</li>' : ''}</ul>
${moreNote(list, 'itens')}
</article>`,
)
.join('')}
</div>
</section>
${
reach.evidence.items.length
? `<section class="block">
<div class="block-head"><div><p class="eyebrow">Evidência de alcance</p><h3 class="section-title">Por que cada item está neste recorte</h3></div><p class="warning-text meta strong">Sem evidência, vira lacuna</p></div>
<table class="document-table">
<caption class="sr-only">Itens de setor e rede com evidência de alcance</caption>
<colgroup><col style="width:26%" /><col style="width:16%" /><col style="width:38%" /><col style="width:20%" /></colgroup>
<thead><tr><th>Item</th><th>Abrangência</th><th>Evidência de alcance</th><th>Fonte</th></tr></thead>
<tbody>${reach.evidence.items
.map(
(row) => `<tr>
<td>${copyHtml(briefOr(row.brief, row.item))}</td>
<td>${scopeBadge(row.sphere, null, report.unit)}</td>
<td>${copyHtml(row.evidence)}</td>
<td>${sourceLink(row.sourceUrl)}</td>
</tr>`,
)
.join('')}</tbody>
</table>
${moreNote(reach.evidence, 'itens')}
</section>`
: ''
}
${
reach.hook || reach.priorityGap
? `<section class="page1-grid">
${
reach.hook
? `<div class="aside-accent"><p class="eyebrow">Gancho possível</p><p><strong>${htmlEscape(reach.hook.topic)}:</strong> ${copyHtml(briefOr(reach.hook.brief, reach.hook.angle))} ${sourceLink(reach.hook.sourceUrl)}</p></div>`
: '<div></div>'
}
${
reach.priorityGap
? `<div class="panel panel-warning"><p class="eyebrow warning-text">Lacuna prioritária</p><p>${htmlEscape(reach.priorityGap)}</p></div>`
: '<div></div>'
}
</section>`
: ''
}
${instFooter(report, 'Abrangência · recortes não somáveis', pageNo)}
</article>`
}
const honorDate = (honor) => {
if (honor.date) return formatDateBr(honor.date)
if (honor.year) return String(honor.year)
return '—'
}
const institutionHonors = (report) => report.eras.find((era) => era.id === 'C')?.honors ?? []
const hasInstitutionHonors = (report) => institutionHonors(report).length > 0
const renderInstitutionHonors = (report, pageNo) => {
const honors = institutionHonors(report)
if (honors.length === 0) return ''
const gap = report.gaps?.[0] ?? null
const hook = report.reach.hook ?? report.page1.hooks.items[0] ?? null
return `
<article class="sheet" data-page="titulos" aria-label="Títulos, honrarias e vínculos institucionais">
${instHeader(report, 'Títulos, honrarias e vínculos', 'Reconhecimento não substitui entrega; vínculo não prova resultado', pageNo)}
<section class="block">
<p class="eyebrow">Registros confirmados</p>
<h3 class="section-title">Data, natureza e fonte primária</h3>
<table class="document-table">
<caption class="sr-only">Títulos, honrarias e vínculos por data e fonte</caption>
<colgroup><col style="width:14%" /><col style="width:20%" /><col style="width:35%" /><col style="width:31%" /></colgroup>
<thead><tr><th>Data</th><th>Natureza</th><th>Registro</th><th>Fonte</th></tr></thead>
<tbody>${honors
.map(
(honor) => `<tr>
<td class="numeric">${htmlEscape(honorDate(honor))}</td>
<td><span class="identity-badge">reconhecimento</span></td>
<td><strong>${copyHtml(briefOr(honor.brief, honor.text))}</strong>${noteOr(honor.brief, honor.detail) ? `<br /><span class="meta">${copyHtml(noteOr(honor.brief, honor.detail))}</span>` : ''}</td>
<td>${sourceLink(honor.sourceUrl)}</td>
</tr>`,
)
.join('')}</tbody>
</table>
</section>
<section class="block honors">
<div>
<p class="eyebrow">Como ler</p>
<div class="action-grid">
<div class="panel">
<h3 class="action-title">Vínculo documentado</h3>
<p class="muted small">Informa papel, unidade e período. Não autoriza atribuir toda ação posterior da instituição.</p>
</div>
<div class="panel">
<h3 class="action-title">Honraria documentada</h3>
<p class="muted small">Registra reconhecimento e órgão concedente. Não vira prova de recurso ou entrega.</p>
</div>
</div>
</div>
${assetBox('NEEDS ASSET', ['reprodução do ato, diploma ou clipping', 'origem, autoria e licença'])}
</section>
<section class="page1-grid">
<div class="aside-accent">
<p class="eyebrow">Gancho para a agenda</p>
<p>${hook ? `<strong>${htmlEscape(hook.topic)}:</strong> ${copyHtml(briefOr(hook.brief, hook.angle))}` : 'Mencionar um vínculo só na formulação que a fonte sustenta e conectá-lo a uma entrega comprovada, se houver.'}</p>
</div>
<div class="panel panel-warning">
<p class="eyebrow warning-text">Pendência documental</p>
<p>${gap ? `${htmlEscape(gap.label)}: ${htmlEscape(gap.reason)}` : 'Nenhuma pendência declarada para os registros desta página.'}</p>
</div>
</section>
${instFooter(report, 'Títulos e vínculos · INSUMO INTERNO', pageNo)}
</article>`
}
const renderInstitutionGapsPage = (report, chunk, index, pageNo) => {
const rows = chunk.length
? chunk
.map(
(gap) => `<tr>
<td><strong>${htmlEscape(gap.label)}</strong></td>
<td>${htmlEscape(gap.reason)}</td>
<td>Não completar por inferência.</td>
<td>${htmlEscape(gap.nextStep)}</td>
</tr>`,
)
.join('')
: '<tr><td colspan="4" class="muted">Nenhuma lacuna declarada — confira a cobertura das eras.</td></tr>'
return `
<article class="sheet" data-page="lacunas${index > 0 ? `-${index + 1}` : ''}" aria-label="Lacunas explícitas">
${instHeader(report, 'Lacunas explícitas', `O que não foi encontrado${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)}
<section class="block">
<div class="block-head"><div><p class="eyebrow">O que não foi encontrado</p><h3 class="section-title">Lacunas explícitas</h3></div><p class="warning-text meta strong">Não completar por memória ou inferência</p></div>
<table class="document-table">
<caption class="sr-only">Tabela de lacunas explícitas e próximos passos de apuração</caption>
<colgroup><col style="width:26%" /><col style="width:30%" /><col style="width:24%" /><col style="width:20%" /></colgroup>
<thead><tr><th>Lacuna</th><th>Situação da busca</th><th>Limite editorial</th><th>Próximo passo</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</section>
${instFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)}
</article>`
}
const renderInstitutionSourcesPage = (report, pageNo) => `
<article class="sheet" data-page="fontes" aria-label="Fontes e limites do dossiê institucional">
${instHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)}
${
report.news.length
? `<section class="block">
<p class="eyebrow">Lista clicável</p>
<h3 class="section-title">Notícias e documentos consultados</h3>
<table class="document-table">
<caption class="sr-only">Fontes consultadas: data 12%, veículo 13%, título 47% e link 28%</caption>
<colgroup><col style="width:12%" /><col style="width:13%" /><col style="width:47%" /><col style="width:28%" /></colgroup>
<thead><tr><th>Data</th><th>Veículo</th><th>Título</th><th>Link</th></tr></thead>
<tbody>${report.news
.map(
(row) => `<tr>
<td class="numeric nowrap">${htmlEscape(formatShortDateBr(row.date))}</td>
<td>${htmlEscape(row.outlet)}</td>
<td>${htmlEscape(row.title)}</td>
<td>${sourceLink(row.url, '(fonte)')}</td>
</tr>`,
)
.join('')}</tbody>
</table>
</section>`
: ''
}
${
report.acervo?.items?.length
? `<section class="block">
<div class="block-head"><div><p class="eyebrow">Acervo interno (read-only)</p><h3 class="section-title">Falas do mandato sobre o recorte</h3></div><p class="meta">Base Teqo, 2011+</p></div>
<ul class="tight-list">${report.acervo.items
.map(
(row) =>
`<li><strong>${htmlEscape(row.period)}</strong> — ${copyHtml(row.text)}${row.excerpt ? ` <span class="muted small">(${copyHtml(row.excerpt)})</span>` : ''} ${sourceLink(row.sourceUrl)}</li>`,
)
.join('')}</ul>
${moreNote(report.acervo, 'itens')}
</section>`
: ''
}
<section class="page1-grid">
<div class="panel"><p class="eyebrow">Limites de cobertura</p><ul class="tight-list">${report.limits.coverage
.map((item) => `<li>${htmlEscape(item)}</li>`)
.join('')}</ul></div>
<div class="panel"><p class="eyebrow">Regras para uso editorial</p><ul class="tight-list">${report.limits.editorial
.map((item) => `<li>${htmlEscape(item)}</li>`)
.join('')}</ul></div>
</section>
<aside class="rule-warning defeso">
${WARNING_ICON}
<div>
<p class="eyebrow warning-text">Nota de defeso eleitoral 2026</p>
<h3 class="rule-title">Insumo interno não é autorização para publicar.</h3>
<p class="muted">Publicidade institucional e propaganda eleitoral obedecem a regimes diferentes. Antes de adaptar qualquer trecho para peça externa, submeta texto, imagem, autoria, período e canal à revisão responsável. Este dossiê organiza evidências; não substitui análise jurídica nem editorial.</p>
</div>
</aside>
${instFooter(report, 'Fontes e limites · INSUMO INTERNO', pageNo)}
</article>`
const renderInstitutionDossierHtml = (report) => {
const gapChunks = chunkGaps(report.gaps)
report.meta.pageTotal =
1 + // capa
1 + // resumo
report.eras.length +
1 + // abrangência
(hasInstitutionHonors(report) ? 1 : 0) +
gapChunks.length +
1 // fontes
let page = 0
const parts = []
parts.push(renderInstitutionCover(report))
page += 1
parts.push(renderInstitutionSummary(report, ++page))
for (const era of report.eras) parts.push(renderInstitutionEra(report, era, ++page))
parts.push(renderInstitutionScope(report, ++page))
if (hasInstitutionHonors(report)) parts.push(renderInstitutionHonors(report, ++page))
gapChunks.forEach((chunk, index) =>
parts.push(renderInstitutionGapsPage(report, chunk, index, ++page)),
)
parts.push(renderInstitutionSourcesPage(report, ++page))
return `<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.subjectName)}</title><style>${INSTITUTION_PRINT_CSS}</style></head>
<body>
${parts.join('\n')}
</body>
</html>`
}
const institutionMdEra = (era) => {
const lines = [
`## ${era.label} (${era.period})`,
'',
`_${era.method}_`,
'',
`Trilha: ${era.recovery}`,
]
if (era.empty) {
lines.push(
'',
`> Lacuna explícita: nenhuma fonte suficiente foi localizada para a ${era.label}. Isso não prova ausência — prova que o dossiê ainda não pode afirmar.`,
)
return lines.join('\n')
}
if (era.numbers.length) {
lines.push(
'',
'| Objeto | Valor | Ano | Fase | Abrangência | Fonte |',
'| --- | --- | --- | --- | --- | --- |',
)
for (const row of era.numbers) {
lines.push(
`| ${row.object} | ${row.value ?? '—'} | ${row.year ?? '—'} | ${dossierPhaseLabel(row.phase)} | ${row.sphere} | ${row.sourceUrl ?? '—'} |`,
)
}
}
for (const action of era.actions) {
lines.push(
'',
`- **${stripInlineSources(action.title)}** (${action.sphere}${action.year ? ` · ${action.year}` : ''})${action.detail ? ` — ${stripInlineSources(action.detail)}` : ''} [fonte](${action.sourceUrl})`,
)
}
for (const honor of era.honors)
lines.push('', `- Honraria: ${stripInlineSources(honor.text)} [fonte](${honor.sourceUrl})`)
return lines.join('\n')
}
const renderInstitutionDossierMd = (report) => {
const lines = [
`# ${report.meta.title} — ${report.meta.subjectName}`,
'',
`**Identificação:** ${report.meta.identityBadges.join(' · ')}`,
`**Gerado em:** ${formatDateTimeBr(report.meta.generatedAt)} · **Base Teqo (read-only) lida em:** ${report.meta.readAtLabel}`,
'',
'> INSUMO INTERNO — não circular, não publicar. Documento de trabalho.',
'',
`## 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(' · ') || '—'}`,
'',
'## Linha do tempo documentada',
'',
]
if (report.page1.timeline.items.length === 0) lines.push('_Nenhum vínculo datado com fonte._')
for (const row of report.page1.timeline.items) {
lines.push(
`- **${row.period || '—'}** — ${stripInlineSources(row.role)} (${row.source ?? ''}) [fonte](${row.url})`,
)
}
if (report.page1.timeline.omitted > 0)
lines.push(`- _e mais ${report.page1.timeline.omitted} vínculo(s) com fonte._`)
if (report.page1.deliveries.items.length) {
lines.push('', '## Principais entregas localizadas', '')
for (const row of report.page1.deliveries.items) {
lines.push(
`- **${stripInlineSources(row.title)}** · _${row.sphere}_${row.value ? ` · ${row.value}` : ''}${row.phase ? ` (${dossierPhaseLabel(row.phase)})` : ''}${row.detail ? ` — ${stripInlineSources(row.detail)}` : ''} [fonte](${row.sourceUrl})`,
)
}
if (report.page1.deliveries.omitted > 0)
lines.push(`- _e mais ${report.page1.deliveries.omitted} entrega(s) com fonte._`)
lines.push('', '_Autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor._')
}
for (const era of report.eras) lines.push('', institutionMdEra(era))
lines.push('', '## Abrangência: instituição × setor × rede', '')
lines.push(`> ${report.reach.ruleTitle}`, `> ${report.reach.ruleBody}`, '')
for (const list of report.reach.lists) {
lines.push(`### ${list.label}`, '')
if (list.items.length === 0) lines.push('- Nenhum item com fonte.')
for (const item of list.items) {
lines.push(`- ${stripInlineSources(item.answer)} — [fonte](${item.sourceUrl})`)
}
if (list.omitted > 0) lines.push(`- _e mais ${list.omitted} item(ns) com fonte._`)
lines.push('')
}
if (report.reach.evidence.items.length) {
lines.push('| Item | Abrangência | Evidência | Fonte |', '| --- | --- | --- | --- |')
for (const item of report.reach.evidence.items) {
lines.push(
`| ${stripInlineSources(item.item)} | ${item.sphere} | ${stripInlineSources(item.evidence)} | ${item.sourceUrl} |`,
)
}
}
if (report.acervo?.items?.length) {
lines.push('', '## Acervo interno (read-only)', '')
for (const row of report.acervo.items) {
lines.push(
`- **${row.period}** — ${stripInlineSources(row.text)}${row.excerpt ? ` _(${stripInlineSources(row.excerpt)})_` : ''} [fonte](${row.sourceUrl})`,
)
}
if (report.acervo.omitted > 0)
lines.push(`- _e mais ${report.acervo.omitted} fala(s) com fonte._`)
}
lines.push('', '## Lacunas explícitas', '')
if (report.gaps.length === 0) lines.push('_Nenhuma lacuna declarada._')
for (const gap of report.gaps) {
lines.push(`- **${gap.label}:** ${gap.reason} → ${gap.nextStep}`)
}
if (report.news.length) {
lines.push('', '## Notícias e documentos consultados', '')
for (const row of report.news) {
lines.push(`- ${formatDateBr(row.date)} · ${row.outlet} — ${row.title} — ${row.url}`)
}
}
lines.push(
'',
'## Limites de cobertura',
'',
...report.limits.coverage.map((item) => `- ${item}`),
'',
'## Regras para uso editorial',
'',
...report.limits.editorial.map((item) => `- ${item}`),
'',
'## Nota de defeso eleitoral 2026',
'',
'Insumo interno não é autorização para publicar. Publicidade institucional e propaganda eleitoral obedecem a regimes diferentes. Antes de adaptar qualquer trecho para peça externa, submeta texto, imagem, autoria, período e canal à revisão responsável.',
'',
`_Documento interno de campanha, datado (${formatDateTimeBr(report.meta.generatedAt)}). O dado envelhece: confira a data de leitura da base._`,
)
return `${lines.join('\n')}\n`
}
/** Dispatch: institution reports render with the institutional sections. */
export const renderDossierHtml = (report) =>
isInstitutionUnit(report.unit)
? renderInstitutionDossierHtml(report)
: renderMunicipalityDossierHtml(report)
export const renderDossierMd = (report) =>
isInstitutionUnit(report.unit)
? renderInstitutionDossierMd(report)
: renderMunicipalityDossierMd(report)

View file

@ -1,15 +1,17 @@
/**
* Research contract for the C186 dossiê (owner). The dossiê is researched one
* file per era (`<slug>.<era>.research.json`); each claim either carries a date
* + URL or becomes an explicit gap. Reuses the C163 validation primitives
* (exported additively from `cityReportResearch.mjs`) instead of twinning them.
* Research contract for the dossiê (owner: C186; unit seam added by C187). The
* dossiê is researched one file per era (`<slug>.<era>.research.json`); each
* claim either carries a date + URL or becomes an explicit gap. Reuses the C163
* validation primitives (exported additively from `cityReportResearch.mjs`)
* instead of twinning them.
*
* The `unit` descriptor selects the slug field, the sphere vocabulary and the
* per-era checklist; without it the behaviour is the C186 municipality one.
*/
import { collectExtraSources, isNonEmptyString, isValidDate } from './cityReportResearch.mjs'
import { DOSSIER_ERA_IDS } from './dossieCareer.mjs'
/** Explicit spheres: a regional/pole item is never summed into the município. */
const DOSSIER_SPHERES = ['municipio', 'regiao', 'polo']
import { MUNICIPALITY_UNIT, resolveDossierUnit } from './dossieUnit.mjs'
/** Execution phases (empenho ≠ pagamento). Unknown/absent degrades to "não informada". */
const DOSSIER_PHASES = ['autorizado', 'empenhado', 'liquidado', 'pago', 'restos', 'nao_informado']
@ -24,8 +26,8 @@ const DOSSIER_PHASES = ['autorizado', 'empenhado', 'liquidado', 'pago', 'restos'
export const DOSSIER_BRIEF_TITLE_MAX = 80
export const DOSSIER_BRIEF_NOTE_MAX = 120
/** Checklist per era — the dossiê's research checklist. `area` labels the boletim card. */
const DOSSIER_RESEARCH_CHECKLIST = [
/** Municipality checklist (C186). `area` labels the boletim card. */
const MUNICIPALITY_RESEARCH_CHECKLIST = [
{
id: 'era_a_formacao',
era: 'A',
@ -107,17 +109,135 @@ const DOSSIER_RESEARCH_CHECKLIST = [
},
]
export const DOSSIER_RESEARCH_CHECKLIST_IDS = DOSSIER_RESEARCH_CHECKLIST.map((item) => item.id)
/** Institution checklist (C187) — same eras, institution-oriented. */
const INSTITUTION_RESEARCH_CHECKLIST = [
{
id: 'era_a_formacao',
era: 'A',
area: 'Formação',
label: 'Formação, residência e pesquisa com vínculo nominal à instituição',
},
{
id: 'era_a_sesab',
era: 'A',
area: 'Gestão pública',
label: 'Atuação na SESAB com efeito ou menção à instituição',
},
{
id: 'era_a_consultor_ms',
era: 'A',
area: 'Ministério da Saúde',
label: 'Consultoria no Ministério da Saúde com interface institucional',
},
{
id: 'era_a_conquista',
era: 'A',
area: 'Gestão municipal',
label: 'Gestão municipal de saúde com parceria institucional',
},
{
id: 'era_a_sas_ms',
era: 'A',
area: 'Ministério da Saúde',
label: 'Secretaria de Atenção à Saúde do MS com interface institucional',
},
{
id: 'era_a_vinculo',
era: 'A',
area: 'Vínculo',
label: 'Vínculo institucional documentado (professor, pesquisador, conselheiro)',
},
{
id: 'era_b_sesab',
era: 'B',
area: 'Gestão estadual',
label: 'Gestão da SESAB (2007–2014) com a instituição',
},
{
id: 'era_b_equipamentos',
era: 'B',
area: 'Equipamentos',
label: 'Equipamentos e serviços de saúde ligados à instituição',
},
{
id: 'era_b_programas',
era: 'B',
area: 'Programas',
label: 'Programas e políticas estaduais com a instituição',
},
{
id: 'era_b_obras',
era: 'B',
area: 'Obras',
label: 'Obras e investimentos na instituição',
},
{
id: 'era_b_convenios',
era: 'B',
area: 'Convênios',
label: 'Convênios, termos e parcerias formais com a instituição',
},
{
id: 'era_c_discursos',
era: 'C',
area: 'Mandato',
label: 'Pronunciamentos e falas com menção à instituição',
},
{
id: 'era_c_proposicoes',
era: 'C',
area: 'Mandato',
label: 'Proposições e relatorias de interesse da instituição',
},
{
id: 'era_c_emendas',
era: 'C',
area: 'Emendas',
label: 'Emendas e recursos destinados à instituição',
},
{
id: 'era_c_titulos',
era: 'C',
area: 'Reconhecimento',
label: 'Títulos, honrarias e homenagens da instituição',
},
{
id: 'era_c_atuacao',
era: 'C',
area: 'Atuação',
label: 'Atuação e articulação institucional',
},
{
id: 'era_c_parcerias',
era: 'C',
area: 'Parcerias',
label: 'Parcerias, audiências e articulação formal com a instituição',
},
]
const checklistById = new Map(DOSSIER_RESEARCH_CHECKLIST.map((item) => [item.id, item]))
/** @type {Record<string, Array<{ id: string, era: string, area: string, label: string }>>} */
const CHECKLIST_BY_UNIT = {
municipality: MUNICIPALITY_RESEARCH_CHECKLIST,
institution: INSTITUTION_RESEARCH_CHECKLIST,
}
export const dossierChecklistForEra = (era) =>
DOSSIER_RESEARCH_CHECKLIST.filter((item) => item.era === era)
const checklistFor = (unit) => CHECKLIST_BY_UNIT[unit.id] ?? MUNICIPALITY_RESEARCH_CHECKLIST
const normalizeSphere = (value) => {
if (!isNonEmptyString(value)) return 'municipio'
/** Municipality checklist ids (C186 surface kept stable). */
export const DOSSIER_RESEARCH_CHECKLIST_IDS = MUNICIPALITY_RESEARCH_CHECKLIST.map((item) => item.id)
/**
* @param {string} era
* @param {any} [unit]
* @returns {Array<{ id: string, era: string, area: string, label: string }>}
*/
export const dossierChecklistForEra = (era, unit = MUNICIPALITY_UNIT) =>
checklistFor(resolveDossierUnit(unit)).filter((item) => item.era === era)
const normalizeSphere = (value, unit) => {
if (!isNonEmptyString(value)) return unit.defaultSphere
const sphere = value.trim()
return DOSSIER_SPHERES.includes(sphere) ? sphere : null
return unit.spheres.includes(sphere) ? sphere : null
}
const normalizePhase = (value) => {
@ -166,19 +286,39 @@ const normalizeNumbers = (entry) => {
return numbers
}
/**
* @typedef {Object} NormalizedDossierResearch
* @property {string} era
* @property {string} researchedAt
* @property {any[]} items
* @property {any[]} news
* @property {any[]} gaps
*/
/**
* Validates/normalizes one era's agent-written research JSON.
*
* Structural failures (no slug, no research date, no/unknown era) throw — the
* operator fixes the file. Content failures (missing source, unknown item, item
* from another era) degrade to explicit gaps.
*
* @param {any} raw
* @param {{ unit?: any }} [options]
* @returns {NormalizedDossierResearch & Record<string, any>}
*/
export const normalizeDossierResearchInput = (raw) => {
export const normalizeDossierResearchInput = (raw, { unit } = {}) => {
const resolvedUnit = resolveDossierUnit(unit)
const checklist = checklistFor(resolvedUnit)
const checklistById = new Map(checklist.map((item) => [item.id, item]))
const slug = isNonEmptyString(raw?.[resolvedUnit.slugField])
? raw[resolvedUnit.slugField].trim()
: null
if (!raw || typeof raw !== 'object') {
throw new Error('Pesquisa do dossiê inválida: o arquivo precisa ser um objeto JSON.')
}
if (!isNonEmptyString(raw.municipalitySlug)) {
throw new Error('Pesquisa do dossiê inválida: falta "municipalitySlug".')
if (!slug) {
throw new Error(`Pesquisa do dossiê inválida: falta "${resolvedUnit.slugField}".`)
}
if (!isValidDate(raw.researchedAt)) {
throw new Error(
@ -230,12 +370,12 @@ export const normalizeDossierResearchInput = (raw) => {
})
continue
}
const sphere = normalizeSphere(entry.sphere)
const sphere = normalizeSphere(entry.sphere, resolvedUnit)
if (!sphere) {
gaps.push({
id,
label: checklistItem.label,
reason: `Esfera inválida (esperado ${DOSSIER_SPHERES.join('/')}).`,
reason: `Esfera inválida (esperado ${resolvedUnit.spheres.join('/')}).`,
})
continue
}
@ -270,7 +410,7 @@ export const normalizeDossierResearchInput = (raw) => {
}
}
for (const checklistItem of dossierChecklistForEra(era)) {
for (const checklistItem of dossierChecklistForEra(era, resolvedUnit)) {
if (!seen.has(checklistItem.id)) {
gaps.push({ id: checklistItem.id, label: checklistItem.label, reason: 'Não pesquisado.' })
}
@ -299,7 +439,7 @@ export const normalizeDossierResearchInput = (raw) => {
}
return {
municipalitySlug: raw.municipalitySlug.trim(),
[resolvedUnit.slugField]: slug,
era,
researchedAt: researchedAt.toISOString(),
items,
@ -308,31 +448,48 @@ export const normalizeDossierResearchInput = (raw) => {
}
}
/** Short receipt the researcher subagent returns (never the body). */
export const dossierResearchReceipt = (research) => ({
slug: research.municipalitySlug,
era: research.era,
status: 'ok',
researchPath: `data/dossie-solla-cidade/${research.municipalitySlug}.${research.era.toLowerCase()}.research.json`,
researchedAt: research.researchedAt,
itemCount: research.items.length,
gapCount: research.gaps.length,
newsCount: research.news.length,
gaps: research.gaps.map((gap) => gap.id),
})
/**
* Short receipt the researcher subagent returns (never the body).
*
* @param {any} research
* @param {{ unit?: any }} [options]
* @returns {any}
*/
export const dossierResearchReceipt = (research, { unit } = {}) => {
const resolvedUnit = resolveDossierUnit(unit)
const slug = research[resolvedUnit.slugField]
return {
slug,
era: research.era,
status: 'ok',
researchPath: `${resolvedUnit.researchDir}/${slug}.${research.era.toLowerCase()}.research.json`,
researchedAt: research.researchedAt,
itemCount: research.items.length,
gapCount: research.gaps.length,
newsCount: research.news.length,
gaps: research.gaps.map((gap) => gap.id),
}
}
/**
* Merges the per-era research files into one view. Fails closed when the slugs
* disagree (a mixed bundle would print another city's facts).
* disagree (a mixed bundle would print another unit's facts).
*
* @param {any} researches
* @param {{ unit?: any }} [options]
* @returns {{ eras: string[], items: any[], news: any[], gaps: any[], researchedAt: string } & Record<string, any>}
*/
export const mergeDossierResearch = (researches) => {
export const mergeDossierResearch = (researches, { unit } = {}) => {
const resolvedUnit = resolveDossierUnit(unit)
const slugField = resolvedUnit.slugField
const list = Array.isArray(researches) ? researches.filter(Boolean) : []
if (list.length === 0) throw new Error('Dossiê sem pesquisa: nenhum arquivo por era foi lido.')
const slug = list[0].municipalitySlug
const slug = list[0][slugField]
for (const research of list) {
if (research.municipalitySlug !== slug) {
if (research[slugField] !== slug) {
const noun = resolvedUnit.id === 'institution' ? 'instituições' : 'municípios'
throw new Error(
`Pesquisas de municípios diferentes ("${slug}" e "${research.municipalitySlug}") — pare e regenere.`,
`Pesquisas de ${noun} diferentes ("${slug}" e "${research[slugField]}") — pare e regenere.`,
)
}
}
@ -349,7 +506,7 @@ export const mergeDossierResearch = (researches) => {
.sort()
.at(-1)
return {
municipalitySlug: slug,
[slugField]: slug,
eras: list.map((research) => research.era),
items,
news,

View file

@ -0,0 +1,67 @@
/**
* 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.
*
* 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.
*/
export const MUNICIPALITY_UNIT = {
id: 'municipality',
/** Key that carries the resolved unit slug in research/snapshot files. */
slugField: 'municipalitySlug',
spheres: ['municipio', 'regiao', 'polo'],
defaultSphere: 'municipio',
sphereLabels: { municipio: 'município', regiao: 'região', polo: 'polo' },
sphereBadgeClass: { municipio: '', regiao: 'scope-region', polo: 'scope-region' },
/** Bulletin ranking: a direct item outranks a shared-scope one. */
breadthRank: { municipio: 0, regiao: 1, polo: 2 },
researchDir: 'data/dossie-solla-cidade',
series: 'Série municipal · BA',
title: 'Dossiê Solla por cidade',
bulletinTitle: 'Boletim informativo modelo',
}
export const INSTITUTION_UNIT = {
id: 'institution',
slugField: 'institutionSlug',
spheres: ['instituicao', 'setor', 'rede'],
defaultSphere: 'instituicao',
sphereLabels: { instituicao: 'instituição', setor: 'setor', rede: 'rede' },
sphereBadgeClass: { instituicao: '', setor: 'scope-sector', rede: 'scope-network' },
breadthRank: { instituicao: 0, setor: 1, rede: 2 },
researchDir: 'data/dossie-solla-instituicao',
series: 'Série institucional · agenda com lastro',
title: 'Dossiê Solla por instituição',
bulletinTitle: 'Boletim informativo modelo',
}
const DOSSIER_UNITS = {
municipality: MUNICIPALITY_UNIT,
institution: INSTITUTION_UNIT,
}
/**
* Accepts an id string or a descriptor; absent → municipality (C186 default).
*
* @param {any} [unit]
* @returns {any}
*/
export const resolveDossierUnit = (unit) => {
if (!unit) return MUNICIPALITY_UNIT
if (typeof unit === 'string') {
const resolved = DOSSIER_UNITS[unit]
if (!resolved) throw new Error(`Unidade de dossiê desconhecida: "${unit}".`)
return resolved
}
return unit
}
/** Central predicate for the dispatch points (dossiê, boletim, blocks). */
export const isInstitutionUnit = (unit) => resolveDossierUnit(unit).id === 'institution'

View file

@ -0,0 +1,67 @@
/**
* Read-only extraction contract (owner: the C163 report pipeline; reused by the
* C187 institutional dossiê). Both extractors run where production lives, only
* ever read, and refuse to guess the target: an explicit confirmation env plus
* a connection forced read-only.
*
* Extracted so the actor resolution and the read-only session have one owner —
* the institution extractor must not twin the subtle fail-closed recipe.
*/
import {
databaseLabel,
READ_ONLY_PG_OPTIONS,
withReadOnlyDatabaseUrl,
} from './cityReportDatabase.mjs'
import { databaseHostname, isTruthyEnv } from './cli.mjs'
/**
* Resolves a real campaign actor that can see everything (candidate, then
* coordinator). `die` is injected so each CLI keeps its own label/exit.
*
* @param {any} payload
* @param {{ die: (message: string) => void, roles?: string[] }} options
* @returns {Promise<any>}
*/
export const resolveCampaignActor = async (
payload,
{ die, roles = ['candidate', 'coordinator'] },
) => {
for (const role of roles) {
const result = await payload.find({
collection: 'campaignUser',
where: { role: { equals: role } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
const doc = result.docs[0]
if (doc) return { ...doc, collection: 'campaignUser' }
}
die(
`Nenhum campaignUser com papel ${roles.join('/')} encontrado — o relatório exige um ator real que enxergue tudo.`,
)
}
/**
* Enforces the explicit opt-in and rewrites `DATABASE_URL` to force
* `default_transaction_read_only=on`. Returns the raw URL (for labels).
*
* @param {{ confirmEnv: string, label: string, die: (message: string) => void, context?: string | null }} params
* @returns {string | undefined}
*/
export const enterReadOnlySession = ({ confirmEnv, label, die, context = null }) => {
if (!isTruthyEnv(process.env[confirmEnv])) {
die(
`Opt-in explícito ausente: rode com ${confirmEnv}=1.\n` +
` Alvo: ${databaseHostname(process.env.DATABASE_URL) ?? 'DATABASE_URL ausente'} — a leitura é read-only, mas o comando não adivinha o alvo.`,
)
}
const rawDatabaseUrl = process.env.DATABASE_URL
process.env.DATABASE_URL = withReadOnlyDatabaseUrl(rawDatabaseUrl, READ_ONLY_PG_OPTIONS)
console.log(
`[${label}] alvo ${databaseLabel(rawDatabaseUrl)} · sessão read-only${context ? ` · ${context}` : ''}`,
)
return rawDatabaseUrl
}

View file

@ -31,6 +31,7 @@ import {
export const SCRIPTS_SPEC_PINNED = [
'scripts/check-test-locations.mjs',
'scripts/cityReportSnapshot.mjs',
'scripts/institutionSnapshot.mjs',
'scripts/lib/agent-forgejo.mjs',
'scripts/lib/agent-plan-lifecycle.mjs',
'scripts/lib/agent-pool-cursor.mjs',
@ -41,6 +42,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/agent-session.mjs',
'scripts/lib/ansi.mjs',
'scripts/lib/auto-unblock.mjs',
'scripts/lib/buildPdf.mjs',
'scripts/lib/camaraFetch.mjs',
'scripts/lib/camaraSpeeches.mjs',
'scripts/lib/changelog.mjs',
@ -62,6 +64,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/dossieHealthData.mjs',
'scripts/lib/dossieRender.mjs',
'scripts/lib/dossieResearch.mjs',
'scripts/lib/dossieUnit.mjs',
'scripts/lib/e2e-affected-manifest.mjs',
'scripts/lib/github-api.mjs',
'scripts/lib/github-branch-protection.mjs',
@ -73,6 +76,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/plansOnlyClosesGuard.mjs',
'scripts/lib/playwright-e2e-args.mjs',
'scripts/lib/portalTransparenciaEmendas.mjs',
'scripts/lib/readOnlyExtract.mjs',
'scripts/lib/reportText.mjs',
'scripts/lib/seed-minimal-manifest.mjs',
'scripts/lib/sollaCeuciSalvadorMetrics.mjs',

View file

@ -0,0 +1,126 @@
import type { SpeechTopic } from '@/lib/speechFacets'
/**
* Institution catalog for the `dossie-solla-instituicao` skill (C187). The
* dossiê's institutional recorte is resolved here, fail-closed: a token that is
* neither a known slug nor a known name/alias never becomes a slug. Mirrors the
* `municipalityCatalog` precedent (static TS source of truth, no DB).
*
* The `topics` field is the bridge to the internal acervo: `speech` documents
* have no institution relationship, so the institutional recorte is
* theme→institution. Keep the list conservative — a loose theme would drag
* unrelated speeches into the dossiê.
*/
export const INSTITUTION_KINDS = [
'universidade',
'instituto_federal',
'empresa_publica',
'autarquia',
'orgao_publico',
'conselho_classe',
'entidade_classe',
'categoria_profissional',
'movimento',
'rede',
'outro',
] as const
export type InstitutionKind = (typeof INSTITUTION_KINDS)[number]
export const INSTITUTION_KIND_LABELS: Record<InstitutionKind, string> = {
universidade: 'Universidade',
instituto_federal: 'Instituto Federal',
empresa_publica: 'Empresa pública',
autarquia: 'Autarquia',
orgao_publico: 'Órgão público',
conselho_classe: 'Conselho de classe',
entidade_classe: 'Entidade de classe',
categoria_profissional: 'Categoria profissional',
movimento: 'Movimento',
rede: 'Rede',
outro: 'Outro',
}
export const INSTITUTION_SPHERES = [
'federal',
'estadual',
'municipal',
'nao_governamental',
] as const
export type InstitutionSphere = (typeof INSTITUTION_SPHERES)[number]
export const INSTITUTION_SPHERE_LABELS: Record<InstitutionSphere, string> = {
federal: 'Federal',
estadual: 'Estadual',
municipal: 'Municipal',
nao_governamental: 'Não governamental',
}
export const INSTITUTION_SCOPES = ['nacional', 'BA'] as const
export type InstitutionScope = (typeof INSTITUTION_SCOPES)[number]
export const INSTITUTION_SCOPE_LABELS: Record<InstitutionScope, string> = {
nacional: 'Abrangência nacional',
BA: 'Abrangência BA',
}
export interface InstitutionCatalogEntry {
slug: string
name: string
aliases: readonly string[]
kind: InstitutionKind
sphere: InstitutionSphere
scope: InstitutionScope
topics: readonly SpeechTopic[]
}
/**
* Minimal v1 seed (intention plan §Dados da decisão). Adding an institution is
* a data edit here — the escape hatch `--slug=<x> --name="<Nome>"` covers a
* one-off without inventing a catalog entry.
*/
export const institutionCatalog: readonly InstitutionCatalogEntry[] = [
{
slug: 'ufba',
name: 'UFBA',
aliases: ['Universidade Federal da Bahia'],
kind: 'universidade',
sphere: 'federal',
scope: 'BA',
topics: ['educacao', 'ciencia-tecnologia', 'saude'],
},
{
slug: 'correios',
name: 'Correios',
aliases: ['ECT', 'Empresa Brasileira de Correios e Telégrafos'],
kind: 'empresa_publica',
sphere: 'federal',
scope: 'nacional',
topics: ['economia-trabalho', 'infraestrutura'],
},
{
slug: 'enfermagem',
name: 'Enfermagem',
aliases: ['ABEn', 'ABEn-BA', 'COFEN', 'Coren-BA'],
kind: 'categoria_profissional',
sphere: 'federal',
scope: 'nacional',
topics: ['saude'],
},
]
const catalogBySlug = new Map(institutionCatalog.map((entry) => [entry.slug, entry]))
export const getInstitutionCatalogEntry = (slug: string): InstitutionCatalogEntry | undefined =>
catalogBySlug.get(slug)
export const isInstitutionSlug = (value: string): boolean => catalogBySlug.has(value)
export const institutionSlugs = (): readonly string[] =>
institutionCatalog.map((entry) => entry.slug)
/** Every spelling declared for an entry (canonical name + aliases). */
export const institutionSpellings = (entry: InstitutionCatalogEntry): readonly string[] => [
entry.name,
...entry.aliases,
]

View file

@ -0,0 +1,49 @@
import {
type InstitutionCatalogEntry,
institutionCatalog,
institutionSpellings,
} from '@/lib/institutionCatalog'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
/**
* Institution-name resolution for the institutional dossiê (C187). Mirrors the
* `resolveMunicipalityName` fold: accent/case/punctuation-insensitive lookup
* over the catalog's canonical name + aliases. Returns null when unknown — the
* caller keeps the fail-closed policy (skill resolves aliases, never invents a
* slug). A fold shared by two different entries is treated as ambiguous (null),
* so the operator must pass the canonical slug.
*/
const entriesBySearchValue = new Map<string, Set<InstitutionCatalogEntry>>()
const register = (spelling: string, entry: InstitutionCatalogEntry) => {
const key = normalizeSearchPhrase(spelling)
if (!key) return
const bucket = entriesBySearchValue.get(key)
if (bucket) bucket.add(entry)
else entriesBySearchValue.set(key, new Set([entry]))
}
for (const entry of institutionCatalog) {
register(entry.slug, entry)
for (const spelling of institutionSpellings(entry)) register(spelling, entry)
}
/**
* Resolve a token (slug, canonical name or alias) to its catalog entry. Returns
* null when unknown or when the fold is ambiguous.
*/
export const resolveInstitutionEntry = (
value: string | undefined | null,
): InstitutionCatalogEntry | null => {
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (!trimmed) return null
const bucket = entriesBySearchValue.get(normalizeSearchPhrase(trimmed))
if (!bucket || bucket.size !== 1) return null
return [...bucket][0] ?? null
}
/** Canonical name for a token, or null when unknown/ambiguous. */
export const resolveInstitutionName = (value: string | undefined | null): string | null =>
resolveInstitutionEntry(value)?.name ?? null

View file

@ -0,0 +1,119 @@
// @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 { composeInstitutionSnapshot } from '../../scripts/institutionSnapshot.mjs'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
// C187: the institutional snapshot recorte is theme→institution (the acervo has
// no institution relationship). 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: 'ufba',
name: 'UFBA',
kind: 'universidade',
kindLabel: 'Universidade',
sphere: 'federal',
sphereLabel: 'Federal',
scope: 'BA',
scopeLabel: 'Abrangência BA',
aliases: ['Universidade Federal da Bahia'],
topics: ['ciencia-tecnologia'],
}
describe('institution snapshot (C187)', () => {
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-inst-${randomUUID()}`,
speechAt: '2024-05-10T17:28',
type: 'DISCURSO',
phase: 'Discurso',
summary: 'Defesa do financiamento das universidades federais.',
officialTranscript: 'O SR. JORGE SOLLA (Bloco/PT - BA) - Sr. Presidente...',
keywords: ['UFBA', 'universidade'],
topics: ['ciencia-tecnologia'],
classifiedBy: 'gazetteer',
},
depth: 0,
overrideAccess: true,
})
createdSpeechIds.push(speech.id)
const actor = { ...candidate, collection: 'campaignUser' as const }
const snapshot = await composeInstitutionSnapshot({
payload,
actor,
identity,
readAt: '2026-09-17T11:00:00.000Z',
codeSha: 'test-sha',
database: 'localhost:5432/teqo_test',
})
expect(snapshot.meta.kind).toBe('institution')
expect(snapshot.institution).toMatchObject({ slug: 'ufba', name: 'UFBA' })
expect(snapshot.institution.topics).toEqual(['ciencia-tecnologia'])
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(JSON.stringify(snapshot)).not.toContain('@')
},
)
it('declares an explicit gap when the institution has no topic', async () => {
const fixtures = campaignFixtures()
const candidate = await fixtures.createCampaignUser('candidate')
const actor = { ...candidate, collection: 'campaignUser' as const }
const snapshot = await composeInstitutionSnapshot({
payload,
actor,
identity: { ...identity, slug: 'sem-tema', name: 'Sem Tema', topics: [] },
readAt: '2026-09-17T11:00:00.000Z',
})
expect(snapshot.speeches.rows).toEqual([])
expect(snapshot.gaps).toContainEqual(expect.objectContaining({ id: 'acervo_sem_tema' }))
})
})

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
A4_HEIGHT_PX,
A4_PAGE_BUDGET_PX,
A4_WIDTH_PX,
assertPageFits,
PAGE_FIT_TOLERANCE_PX,
} from '../../scripts/lib/buildPdf.mjs'
// C187 extracted the A4 print plumbing so the institution builder (third
// consumer) shares one owner with the C186 dossiê. Pins the mm→px budget and
// the overflow guard that turns a cut page into a hard failure.
describe('A4 print budget', () => {
it('derives height/width from 297×210mm at 96dpi with rounding slack', () => {
expect(A4_HEIGHT_PX).toBe(Math.round(297 * (96 / 25.4)))
expect(A4_WIDTH_PX).toBe(Math.round(210 * (96 / 25.4)))
expect(A4_PAGE_BUDGET_PX).toBe(A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX)
})
})
describe('assertPageFits', () => {
it('passes when no page overflows', () => {
expect(() => assertPageFits([], 'O dossiê', 'hint')).not.toThrow()
})
it('throws listing the overflowing page and its height', () => {
expect(() =>
assertPageFits(
[{ page: 'resumo', height: A4_PAGE_BUDGET_PX + 50 }],
'O dossiê',
'corte copy',
),
).toThrow(/resumo: \d+px.*corte copy/s)
})
})
import { emitHtmlPairPdf } from '../../scripts/lib/buildPdf.mjs'
describe('emitHtmlPairPdf', () => {
it('is exported as the single owner of the two-document emit contract', () => {
expect(typeof emitHtmlPairPdf).toBe('function')
})
})

View file

@ -0,0 +1,312 @@
import { describe, expect, it } from 'vitest'
import { buildDossierReport } from '../../scripts/lib/dossieBlocks.mjs'
import { buildBulletin } from '../../scripts/lib/dossieBulletin.mjs'
import { renderBulletinHtml } from '../../scripts/lib/dossieBulletinRender.mjs'
import { renderDossierHtml, renderDossierMd } from '../../scripts/lib/dossieRender.mjs'
import {
mergeDossierResearch,
normalizeDossierResearchInput,
} from '../../scripts/lib/dossieResearch.mjs'
import { INSTITUTION_UNIT } from '../../scripts/lib/dossieUnit.mjs'
// C187: institution content model + render. Grounds the institutional recorte:
// identity badges, `instituicao|setor|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-17T12:00:00.000Z')
const snapshot = {
meta: { readAt: '2026-09-17T11:00:00.000Z' },
institution: {
slug: 'ufba',
name: 'UFBA',
kind: 'universidade',
kindLabel: 'Universidade',
sphere: 'federal',
sphereLabel: 'Federal',
scope: 'BA',
scopeLabel: 'Abrangência BA',
aliases: ['Universidade Federal da Bahia'],
topics: ['educacao', 'ciencia-tecnologia'],
},
speeches: {
topics: ['ciencia-tecnologia'],
totalCount: 1,
rows: [
{
id: 11,
speechAt: '2024-05-10T00:00:00.000Z',
summary: 'Defesa do financiamento das universidades federais.',
officialTextUrl: 'https://camara.test/discurso-ufba',
mentionExcerpt: 'a UFBA e as demais universidades federais',
},
],
},
}
const item = (id: string, _era: 'A' | 'B' | 'C', overrides: Record<string, unknown> = {}) => ({
id,
answer: `Ação ${id} documentada`,
sphere: 'instituicao',
sourceUrl: `https://ufba.test/${id}`,
sourceDate: '2024-06-01',
...overrides,
})
const eraFile = (era: 'A' | 'B' | 'C', items: unknown[]) => ({
institutionSlug: 'ufba',
era,
researchedAt: '2026-09-17T10:00:00.000Z',
items,
news: [
{
title: 'Notícia institucional',
outlet: 'UFBA',
publishedAt: '2024-05-01',
url: 'https://ufba.test/noticia',
},
],
})
const research = mergeDossierResearch(
[
normalizeDossierResearchInput(
eraFile('A', [
item('era_a_formacao', 'A'),
item('era_a_vinculo', 'A', { sphere: 'setor' }),
item('era_a_sesab', 'A', { sphere: 'rede' }),
]),
{ unit: INSTITUTION_UNIT },
),
normalizeDossierResearchInput(
eraFile('B', [
item('era_b_sesab', 'B', {
numbers: [{ label: 'Investimento', value: 'R$ 2,0 mi', year: '2012', phase: 'pago' }],
}),
]),
{ unit: INSTITUTION_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: 'Título de cidadão honorário' }),
]),
{ unit: INSTITUTION_UNIT },
),
],
{ unit: INSTITUTION_UNIT },
)
const report = buildDossierReport({
snapshot,
research,
generatedAt,
unit: INSTITUTION_UNIT,
})
describe('institution era without evidence', () => {
const emptyReport = buildDossierReport({
snapshot,
research: mergeDossierResearch(
(['A', 'B', 'C'] as const).map((era) =>
normalizeDossierResearchInput(eraFile(era, []), { unit: INSTITUTION_UNIT }),
),
{ unit: INSTITUTION_UNIT },
),
generatedAt,
unit: INSTITUTION_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('empty-panel')
})
})
describe('buildDossierReport (institution unit)', () => {
it('carries the institution identity and badges', () => {
expect(report.unit.id).toBe('institution')
expect(report.meta.subjectName).toBe('UFBA')
expect(report.meta.identityBadges).toEqual(['Universidade', 'Federal', 'Abrangência BA'])
})
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 setor/rede into the instituicao list', () => {
const listFor = (key: string) => report.reach.lists.find((list) => list.key === key)
expect(listFor('institution')?.items.every((row) => row.sphere === 'instituicao')).toBe(true)
expect(listFor('institution')?.items.some((row) => row.id === 'era_a_vinculo')).toBe(false)
expect(listFor('sector')?.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('prints "e mais N" with the right singular when a list is capped', () => {
const manyItems = [
item('era_c_emendas', 'C', { sphere: 'setor' }),
item('era_c_atuacao', 'C', { sphere: 'setor' }),
item('era_c_discursos', 'C', { sphere: 'setor' }),
item('era_c_proposicoes', 'C', { sphere: 'setor' }),
item('era_c_titulos', 'C', { sphere: 'setor' }),
item('era_c_parcerias', 'C', { sphere: 'setor' }),
]
const cappedReport = buildDossierReport({
snapshot,
research: mergeDossierResearch(
[normalizeDossierResearchInput(eraFile('C', manyItems), { unit: INSTITUTION_UNIT })],
{ unit: INSTITUTION_UNIT },
),
generatedAt,
unit: INSTITUTION_UNIT,
})
const list = cappedReport.reach.lists.find((row) => row.key === 'sector')
expect(list?.omitted).toBeGreaterThan(0)
const html = renderDossierHtml(cappedReport)
expect(html).toContain(`${list?.omitted} item`)
expect(html, 'the plural noun must not be chopped into "iten"').not.toContain('iten ')
})
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-11')).toBe(true)
expect(renderDossierMd(report)).toContain('## Acervo interno (read-only)')
})
})
describe('renderDossierHtml/Md (institution unit)', () => {
const html = renderDossierHtml(report)
const md = renderDossierMd(report)
it('anchors every institution page', () => {
for (const anchor of [
'capa',
'resumo',
'era-a',
'era-b',
'era-c',
'abrangencia',
'lacunas',
'fontes',
]) {
expect(html).toContain(`data-page="${anchor}"`)
}
})
it('ports the institutional classes and identity badges', () => {
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')
})
it('prints the non-summable rule and the defeso note', () => {
expect(html).toContain('Setor/rede não é a instituição')
expect(html).toContain('Nota de defeso eleitoral 2026')
})
it('renders the honors scene with date, nature and the reading panels', () => {
expect(html).toContain('Data, natureza e fonte primária')
expect(html).toContain('Como ler')
expect(html).toContain('Vínculo documentado')
expect(html).toContain('Honraria documentada')
})
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: INSTITUTION_UNIT },
),
],
{ unit: INSTITUTION_UNIT },
),
generatedAt,
unit: INSTITUTION_UNIT,
})
const hostileHtml = renderDossierHtml(hostile)
expect(hostileHtml).not.toContain('<script>alert(1)</script>')
expect(hostileHtml).toContain('&lt;script&gt;')
})
it('mirrors the institution sections in markdown', () => {
expect(md).toContain('# Dossiê Solla por instituição — UFBA')
expect(md).toContain('## Abrangência: instituição × setor × rede')
expect(md).toContain('## Lacunas explícitas')
expect(md).toContain('[fonte](https://ufba.test/era_a_formacao)')
})
})
describe('buildBulletin (institution unit)', () => {
const bulletin = buildBulletin({
facts: report.bulletinFacts,
identity: {
name: report.meta.subjectName,
badges: report.meta.identityBadges,
},
unit: INSTITUTION_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('Modelo — insumo interno')
expect(html).toContain('identity-pill')
expect(html).toContain('UFBA')
})
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')
})
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: { name: 'UFBA', badges: ['Universidade'] },
unit: INSTITUTION_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')
})
})

View file

@ -0,0 +1,95 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
// C187: pins the batch contract of the dossie-solla-instituicao 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-instituicao/SKILL.md')
const researcher = read('.opencode/agent/dossie-solla-instituicao.md')
describe('skill /dossie-solla-instituicao documents the batch contract', () => {
it('keeps the comma-separated batch entry and the single-institution case', () => {
expect(skill).toContain('## Lote (várias instituições)')
expect(skill, 'batch example must be literal').toContain(
'/dossie-solla-instituicao UFBA, Correios',
)
expect(skill, 'single-institution must remain valid').toContain(
'/dossie-solla-instituicao UFBA',
)
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 catalog resolution and the one-off escape', () => {
expect(skill).toContain('isInstitutionSlug')
expect(skill).toContain('resolveInstitutionName')
expect(skill, 'unknown token must fail closed').toContain('falha fechado')
expect(skill, 'must never invent a slug').toContain('nunca')
expect(skill, 'the one-off escape must be documented').toContain('--slug=<x> --name="<Nome>"')
})
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 (C187)', () => {
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 institutionSlug').toContain('"institutionSlug": "ufba"')
for (const id of ['era_a_vinculo', 'era_b_convenios', 'era_c_parcerias']) {
expect(skill, `checklist must list ${id}`).toContain(id)
}
expect(skill, 'setor/rede is never summed').toMatch(/nunca\s+(\*\*)?são?\s+somados/)
})
})
describe('subagent .opencode/agent/dossie-solla-instituicao.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('--institution=')
expect(researcher, 'researcher must not run the builder CLI').not.toContain('--snapshot=')
})
})

View file

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

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
getInstitutionCatalogEntry,
institutionCatalog,
isInstitutionSlug,
} from '../../src/lib/institutionCatalog'
import {
resolveInstitutionEntry,
resolveInstitutionName,
} from '../../src/lib/institutionNameAliases'
// C187: the institutional dossiê resolves its recorte in a catalog and fails
// closed. This pins the v1 seed, the slug/name/alias folds and the ambiguity
// policy (a fold shared by two entries resolves to null, never to a guess).
describe('institutionCatalog', () => {
it('seeds UFBA, Correios and Enfermagem with kind/sphere/scope/topics', () => {
expect(institutionCatalog.map((entry) => entry.slug).sort()).toEqual([
'correios',
'enfermagem',
'ufba',
])
for (const entry of institutionCatalog) {
expect(entry.name.length).toBeGreaterThan(0)
expect(entry.topics.length).toBeGreaterThan(0)
}
expect(getInstitutionCatalogEntry('ufba')).toMatchObject({
kind: 'universidade',
sphere: 'federal',
scope: 'BA',
})
expect(getInstitutionCatalogEntry('enfermagem')).toMatchObject({
kind: 'categoria_profissional',
scope: 'nacional',
})
})
it('accepts only catalog slugs', () => {
expect(isInstitutionSlug('ufba')).toBe(true)
expect(isInstitutionSlug('correios')).toBe(true)
expect(isInstitutionSlug('universidade-federal-da-bahia')).toBe(false)
expect(isInstitutionSlug('ufba-fake')).toBe(false)
})
})
describe('resolveInstitutionName', () => {
it('resolves canonical name and aliases accent/case-insensitively', () => {
expect(resolveInstitutionName('UFBA')).toBe('UFBA')
expect(resolveInstitutionName('ufba')).toBe('UFBA')
expect(resolveInstitutionName('Universidade Federal da Bahia')).toBe('UFBA')
expect(resolveInstitutionName('correios')).toBe('Correios')
expect(resolveInstitutionName('ECT')).toBe('Correios')
expect(resolveInstitutionEntry('ABEn-BA')?.slug).toBe('enfermagem')
})
it('fails closed on unknown tokens', () => {
expect(resolveInstitutionName('UFRB')).toBeNull()
expect(resolveInstitutionName('')).toBeNull()
expect(resolveInstitutionEntry(null)).toBeNull()
})
})

View file

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

View file

@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { enterReadOnlySession, resolveCampaignActor } from '../../scripts/lib/readOnlyExtract.mjs'
// C187 extracted the read-only extraction contract so the institution extractor
// does not twin it. Pins the fail-closed opt-in, the forced read-only URL and
// the actor resolution order.
const originalDatabaseUrl = process.env.DATABASE_URL
afterEach(() => {
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL
else process.env.DATABASE_URL = originalDatabaseUrl
delete process.env.READONLY_TEST_CONFIRM
})
describe('enterReadOnlySession', () => {
it('refuses to run without the explicit confirmation env', () => {
const die = vi.fn((message: string) => {
throw new Error(message)
})
expect(() =>
enterReadOnlySession({ confirmEnv: 'READONLY_TEST_CONFIRM', label: 'test', die }),
).toThrow(/READONLY_TEST_CONFIRM=1/)
expect(die).toHaveBeenCalled()
})
it('forces default_transaction_read_only on and returns the raw url', () => {
process.env.READONLY_TEST_CONFIRM = '1'
process.env.DATABASE_URL = 'postgresql://user:pass@127.0.0.1:5432/teqo_test'
const die = vi.fn()
const raw = enterReadOnlySession({
confirmEnv: 'READONLY_TEST_CONFIRM',
label: 'test',
die,
context: 'instituição ufba',
})
expect(raw).toBe('postgresql://user:pass@127.0.0.1:5432/teqo_test')
expect(decodeURIComponent(process.env.DATABASE_URL ?? '')).toContain(
'default_transaction_read_only=on',
)
expect(die).not.toHaveBeenCalled()
})
})
describe('resolveCampaignActor', () => {
it('prefers candidate, then coordinator', async () => {
const find = vi
.fn()
.mockResolvedValueOnce({ docs: [] })
.mockResolvedValueOnce({ docs: [{ id: 7, role: 'coordinator', name: 'Coord' }] })
const actor = await resolveCampaignActor({ find }, { die: vi.fn() })
expect(actor).toMatchObject({ id: 7, role: 'coordinator', collection: 'campaignUser' })
expect(find).toHaveBeenCalledTimes(2)
})
it('fails closed when no privileged actor exists', async () => {
const find = vi.fn().mockResolvedValue({ docs: [] })
const die = vi.fn((message: string) => {
throw new Error(message)
})
await expect(resolveCampaignActor({ find }, { die })).rejects.toThrow(/candidate\/coordinator/)
})
})