From cb5854d17e7c09eb7951e388d27da66ef1aac6f4 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 17 Sep 2026 18:54:49 -0300 Subject: [PATCH] =?UTF-8?q?feat(C187):=20dossi=C3=AA=20Solla=20por=20insti?= =?UTF-8?q?tui=C3=A7=C3=A3o=20+=20boletim=20modelo=20(skill=20+=20builder)?= =?UTF-8?q?=20(Closes=20#1135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../skills/dossie-solla-instituicao/SKILL.md | 271 ++++++ .gitignore | 4 + .opencode/agent/dossie-solla-instituicao.md | 42 + .../commands/dossie-solla-instituicao.md | 9 + docs/changelog/2026-09-17-c187.md | 1 + docs/plans/dossie-solla-instituicao-impl.md | 178 ++++ scripts/build-dossie-solla-cidade.mjs | 75 +- scripts/build-dossie-solla-instituicao.mjs | 171 ++++ scripts/extract-city-report-snapshot.mjs | 53 +- scripts/extract-institution-snapshot.mjs | 157 ++++ scripts/institutionSnapshot.mjs | 145 ++++ scripts/lib/buildPdf.mjs | 95 ++ scripts/lib/dossieBlocks.mjs | 354 +++++++- scripts/lib/dossieBulletin.mjs | 45 +- scripts/lib/dossieBulletinRender.mjs | 179 +++- scripts/lib/dossieRender.mjs | 813 +++++++++++++++++- scripts/lib/dossieResearch.mjs | 239 ++++- scripts/lib/dossieUnit.mjs | 67 ++ scripts/lib/readOnlyExtract.mjs | 67 ++ scripts/lib/test-affected-core.mjs | 4 + src/lib/institutionCatalog.ts | 126 +++ src/lib/institutionNameAliases.ts | 49 ++ tests/int/institutionSnapshot.int.spec.ts | 119 +++ tests/unit/buildPdf.unit.spec.ts | 45 + tests/unit/dossieInstitution.unit.spec.ts | 312 +++++++ .../dossieInstitutionBatchSkill.unit.spec.ts | 95 ++ .../dossieInstitutionResearch.unit.spec.ts | 128 +++ tests/unit/institutionCatalog.unit.spec.ts | 62 ++ tests/unit/opencodeCommands.unit.spec.ts | 1 + tests/unit/readOnlyExtract.unit.spec.ts | 64 ++ 30 files changed, 3791 insertions(+), 179 deletions(-) create mode 100644 .agents/skills/dossie-solla-instituicao/SKILL.md create mode 100644 .opencode/agent/dossie-solla-instituicao.md create mode 100644 .opencode/commands/dossie-solla-instituicao.md create mode 100644 docs/changelog/2026-09-17-c187.md create mode 100644 docs/plans/dossie-solla-instituicao-impl.md create mode 100644 scripts/build-dossie-solla-instituicao.mjs create mode 100644 scripts/extract-institution-snapshot.mjs create mode 100644 scripts/institutionSnapshot.mjs create mode 100644 scripts/lib/buildPdf.mjs create mode 100644 scripts/lib/dossieUnit.mjs create mode 100644 scripts/lib/readOnlyExtract.mjs create mode 100644 src/lib/institutionCatalog.ts create mode 100644 src/lib/institutionNameAliases.ts create mode 100644 tests/int/institutionSnapshot.int.spec.ts create mode 100644 tests/unit/buildPdf.unit.spec.ts create mode 100644 tests/unit/dossieInstitution.unit.spec.ts create mode 100644 tests/unit/dossieInstitutionBatchSkill.unit.spec.ts create mode 100644 tests/unit/dossieInstitutionResearch.unit.spec.ts create mode 100644 tests/unit/institutionCatalog.unit.spec.ts create mode 100644 tests/unit/readOnlyExtract.unit.spec.ts diff --git a/.agents/skills/dossie-solla-instituicao/SKILL.md b/.agents/skills/dossie-solla-instituicao/SKILL.md new file mode 100644 index 00000000..77b2cc31 --- /dev/null +++ b/.agents/skills/dossie-solla-instituicao/SKILL.md @@ -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 " e/ou o "boletim modelo da + " — 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= --name=""`), **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/..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/.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/.institution.snapshot.json \ + --research-dir=data/dossie-solla-instituicao \ + --out-dir=docs/research/dossie-solla-instituicao + ``` + Saídas: `docs/research/dossie-solla-instituicao/--dossie.pdf` + + `-dossie.md` + `-boletim.pdf`. Intermediários gitignored em + `data/dossie-solla-instituicao/` (HTML, JSONs, logs). Flags úteis: + `--generated-at=`, `DOSSIER_STRICT=1` (falha o run se houver lacuna — + conferência, não entrega). +6. **Summary final.** Uma linha por entrada, na ordem da lista: `entrada · status + (ok|failed) · dossiê/boletim (quando ok) · motivo (quando falha)`. + +## Briefing por era (A/B/C) + +O researcher recebe **uma era** e usa o checklist da era (ver "Contrato dos +JSONs"). 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" ` + ``; 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 + +`..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 "; **≤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= --name=""` 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. diff --git a/.gitignore b/.gitignore index aa0e3ae3..639b17b0 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.opencode/agent/dossie-solla-instituicao.md b/.opencode/agent/dossie-solla-instituicao.md new file mode 100644 index 00000000..9788706d --- /dev/null +++ b/.opencode/agent/dossie-solla-instituicao.md @@ -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/..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". diff --git a/.opencode/commands/dossie-solla-instituicao.md b/.opencode/commands/dossie-solla-instituicao.md new file mode 100644 index 00000000..faceea1c --- /dev/null +++ b/.opencode/commands/dossie-solla-instituicao.md @@ -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 [, ...]` — ex.: `/dossie-solla-instituicao UFBA, Correios`. Uma instituição = dossiê (`--dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). Token fora do catálogo falha fechado; use o escape one-off `--slug= --name=""`. + +$ARGUMENTS diff --git a/docs/changelog/2026-09-17-c187.md b/docs/changelog/2026-09-17-c187.md new file mode 100644 index 00000000..f8ee7687 --- /dev/null +++ b/docs/changelog/2026-09-17-c187.md @@ -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. diff --git a/docs/plans/dossie-solla-instituicao-impl.md b/docs/plans/dossie-solla-instituicao-impl.md new file mode 100644 index 00000000..861715b1 --- /dev/null +++ b/docs/plans/dossie-solla-instituicao-impl.md @@ -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 ` 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= --name=""`, 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
isInstitutionSlug / resolveInstitutionName
fail-closed + escape --slug/--name + dedup"] + B --> C["N researchers por era (A|B|C × instituição)
paralelo, MAX_RESEARCHERS_IN_FLIGHT=6"] + C --> R["..research.json
+ recibo curto (status/paths/gaps)"] + B --> E["extração read-only, 1 instituição por vez
extract-institution-snapshot.mjs
composeInstitutionSnapshot (por tema)"] + E --> S[".institution.snapshot.json"] + R --> G["build-dossie-solla-instituicao.mjs"] + S --> G + G --> I["dossieBlocks(unit=INSTITUTION):
capa+resumo+eras+abrangência+fontes
→ bulletinFacts ledger (só fato com fonte)"] + I --> J["dossieRender → PDF A4 multi-página + .md
(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/--*.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/..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`; `
` 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= --out=`; `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ê `.{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. diff --git a/scripts/build-dossie-solla-cidade.mjs b/scripts/build-dossie-solla-cidade.mjs index 3bfe3917..8b8cb349 100644 --- a/scripts/build-dossie-solla-cidade.mjs +++ b/scripts/build-dossie-solla-cidade.mjs @@ -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() } diff --git a/scripts/build-dossie-solla-instituicao.mjs b/scripts/build-dossie-solla-instituicao.mjs new file mode 100644 index 00000000..8b92b781 --- /dev/null +++ b/scripts/build-dossie-solla-instituicao.mjs @@ -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/.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= [--research-dir=] [--out-dir=] [--generated-at=]') +} +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.`, + ) +} diff --git a/scripts/extract-city-report-snapshot.mjs b/scripts/extract-city-report-snapshot.mjs index 7924f14b..f445c90a 100644 --- a/scripts/extract-city-report-snapshot.mjs +++ b/scripts/extract-city-report-snapshot.mjs @@ -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({ diff --git a/scripts/extract-institution-snapshot.mjs b/scripts/extract-institution-snapshot.mjs new file mode 100644 index 00000000..bd8211e2 --- /dev/null +++ b/scripts/extract-institution-snapshot.mjs @@ -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= --name=""`. + * + * 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= --name="".', + ) + } + 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= --out=\n' + + ' Ou escape one-off: --slug= --name="" [--kind= --sphere= --scope= --topics=a,b] --out=\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= --out= (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) diff --git a/scripts/institutionSnapshot.mjs b/scripts/institutionSnapshot.mjs new file mode 100644 index 00000000..4250bb22 --- /dev/null +++ b/scripts/institutionSnapshot.mjs @@ -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} + */ +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, + } +} diff --git a/scripts/lib/buildPdf.mjs b/scripts/lib/buildPdf.mjs new file mode 100644 index 00000000..c2b81719 --- /dev/null +++ b/scripts/lib/buildPdf.mjs @@ -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() +} diff --git a/scripts/lib/dossieBlocks.mjs b/scripts/lib/dossieBlocks.mjs index 4bc51303..4acf0994 100644 --- a/scripts/lib/dossieBlocks.mjs +++ b/scripts/lib/dossieBlocks.mjs @@ -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 diff --git a/scripts/lib/dossieBulletin.mjs b/scripts/lib/dossieBulletin.mjs index c1ee9588..49540c38 100644 --- a/scripts/lib/dossieBulletin.mjs +++ b/scripts/lib/dossieBulletin.mjs @@ -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', diff --git a/scripts/lib/dossieBulletinRender.mjs b/scripts/lib/dossieBulletinRender.mjs index 2c3aa1ef..78fbab51 100644 --- a/scripts/lib/dossieBulletinRender.mjs +++ b/scripts/lib/dossieBulletinRender.mjs @@ -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('
')}

` -const renderHighlight = (highlight) => ` +const phaseModifier = (phase) => + phase === 'pago' || phase === 'liquidado' + ? 'phase-paid' + : phase === 'empenhado' || phase === 'autorizado' + ? 'phase-pending' + : '' + +const renderHighlight = (highlight, { showPhase = false } = {}) => `

${htmlEscape(highlight.eyebrow)}

${highlight.number ? `

${htmlEscape(highlight.number)}

` : ''} + ${ + showPhase && highlight.number && highlight.phaseLabel + ? `

${htmlEscape(highlight.phaseLabel)}

` + : '' + }

${htmlEscape(highlight.title)}

${highlight.note ? `

${htmlEscape(highlight.note)}

` : ''}
` @@ -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) => ` ${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.municipality)} @@ -192,3 +205,165 @@ export const renderBulletinHtml = (bulletin) => ` ` + +/* 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 = + '' + +const renderInstitutionBulletinHtml = (bulletin) => { + const sparse = Boolean(bulletin.sparse) + const pills = (bulletin.meta.identityBadges ?? []) + .map((badge) => `${htmlEscape(badge)}`) + .join('') + const highlights = bulletin.highlights + const more = bulletin.moreItems + return ` + +${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.subjectName)} + +
+
+
+

${htmlEscape(bulletin.meta.kicker)}

+

${htmlEscape(bulletin.meta.subjectName)}

+ ${pills ? `
${pills}
` : ''} +
+ ${htmlEscape(bulletin.meta.modelLabel)} +
+ +
+ DEFESO 2026 · material em preparação · não publicar sem revisão editorial e jurídica + sem CTA · sem propaganda +
+ +
+
+ +

O que Jorge Solla fez pela e na ${htmlEscape(bulletin.meta.subjectName)}

+

${ + 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.' + }

+
+ ${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', + ])} +
+ +
+
+
+ +

${sparse ? 'Poucos fatos, sem enchimento' : 'Resultados em leitura rápida'}

+
+

${sparse ? 'não criar cards vazios para chegar a seis' : 'cada número mantém sua fase e seu alcance'}

+
+ ${ + highlights.length + ? `
${highlights.map((highlight) => renderHighlight(highlight, { showPhase: true })).join('')}
` + : '

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

' + } +
+ +
+
+

Uma trajetória de compromisso com o serviço público

+

${sparse ? 'contexto de carreira · não substitui fato institucional' : 'quatro momentos · linguagem direta'}

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

${htmlEscape(step.period)}

+

${htmlEscape(step.text)}

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

${sparse ? 'Outros registros confirmados' : 'Outras ações confirmadas'}

+
+

${sparse ? 'sem completar por inferência' : 'até 14 · sem completar por inferência'}

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

Sem itens adicionais com fonte além dos destaques.

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

' + } +
+ ${ + sparse + ? `` + : `` + } +
+ + ${ + sparse + ? `
+
+ +

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.

+
+ ${ASSET('NEEDS ASSET · selo', ['marca autorizada da instituição', 'arquivo vetorial'])} +
` + : '' + } + +
+
+
+

Conteúdo selecionado do dossiê institucional.

+

Controle editorial: fase acompanha cada valor · instituição, setor e rede não são somados · sem percentual estadual absoluto.

+
+

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

+
+
+
+ +` +} + +/** Dispatch: the institution unit renders the institutional composition. */ +export const renderBulletinHtml = (bulletin) => + isInstitutionUnit(bulletin?.unit) + ? renderInstitutionBulletinHtml(bulletin) + : renderMunicipalityBulletinHtml(bulletin) diff --git a/scripts/lib/dossieRender.mjs b/scripts/lib/dossieRender.mjs index 1540a7b4..42f9688f 100644 --- a/scripts/lib/dossieRender.mjs +++ b/scripts/lib/dossieRender.mjs @@ -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)') => ? `${SOURCE_ICON}${htmlEscape(label)}` : '' -const scopeBadge = (sphere, label = null) => - `${htmlEscape(label ?? dossierSphereLabel(sphere))}` +const scopeBadge = (sphere, label = null, unit = MUNICIPALITY_UNIT) => { + const badgeClass = dossierSphereBadgeClass(sphere, unit) + return `${htmlEscape(label ?? dossierSphereLabel(sphere, unit))}` +} /** `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) => `${htmlEscape(dossierPhaseLabel(phase))}` +const PHASE_MODIFIER = { + autorizado: 'phase-pending', + empenhado: 'phase-pending', + liquidado: 'phase-paid', + pago: 'phase-paid', +} +const phaseBadge = (phase) => { + const modifier = PHASE_MODIFIER[phase] + return `${htmlEscape(dossierPhaseLabel(phase))}` +} const placeholder = (label) => `` @@ -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) => ` +
+
+

Dossiê institucional · ${htmlEscape(report.meta.subjectName)}

+

${htmlEscape(title)}

+ ${subtitle ? `

${htmlEscape(subtitle)}

` : ''} +
+
+

INSUMO INTERNO — defeso 2026

+

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

+

pág. ${pageNo}/${report.meta.pageTotal}

+
+
` + +const instFooter = (report, left, pageNo) => ` +
+ Dossiê institucional · ${htmlEscape(report.meta.subjectName)} · ${htmlEscape(left)} + Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))} · pág. ${pageNo}/${report.meta.pageTotal} +
` + +/** 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 `

e mais ${cappedList.omitted} ${label} com fonte fora desta página — nada foi descartado.

` +} + +const identityBadgeList = (report) => + report.meta.identityBadges + .map((badge) => `${htmlEscape(badge)}`) + .join(' ') + +const renderInstitutionCover = (report) => ` +
+
+
+
+

${htmlEscape(report.cover.kicker)}

+

${htmlEscape(report.cover.series)}

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

${htmlEscape(report.cover.eyebrow)}

+

Dossiê Solla —
${htmlEscape(report.cover.title)}

+

${htmlEscape(report.cover.subtitle)}

+
+ +
+
Identificação
${identityBadgeList(report)}
+
Data de geração
${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}
+
Escopo e versão
Eras A / B / C · instituição, setor e rede separados · versão 01
+
+ +
+

Como usar

${htmlEscape(report.cover.howToUse)}

+ ${assetBox('NEEDS ASSET', ['selo da instituição', 'uso autorizado e origem'])} +
+ +
+
+
+

${htmlEscape(report.cover.scope)}

+

Documento de trabalho
versão 01

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

Identificação

+
${rows.map(([key, value]) => `
${htmlEscape(key)}
${htmlEscape(value)}
`).join('')}
+
` +} + +const renderInstitutionVincTimeline = (report) => { + const timeline = report.page1.timeline + if (timeline.items.length === 0) { + return '

Nenhum vínculo datado com fonte — ver lacunas explícitas.

' + } + return ` +
+ ${timeline.items + .map( + (row) => `
+

${htmlEscape(row.period || '—')}

+

${copyHtml(briefOr(row.brief, row.role))}

+

${htmlEscape(row.source ?? '')} ${sourceLink(row.url)}

+
`, + ) + .join('')} +
+ ${moreNote(timeline, 'vínculos')}` +} + +const renderInstitutionDeliveries = (report) => { + const deliveries = report.page1.deliveries + if (deliveries.items.length === 0) return '' + return ` +
+
+

02 · destaques com lastro

Principais entregas localizadas

+

Cada valor mantém a fase e a abrangência

+
+
+ ${deliveries.items + .map( + (row) => `
+
${scopeBadge(row.sphere, null, report.unit)}

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

+
+

${copyHtml(briefOr(row.brief, row.title))}

+ ${noteOr(row.brief, row.detail) ? `

${copyHtml(noteOr(row.brief, row.detail))}

` : ''} +
+
+

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

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

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

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

Gancho para a agenda

+
    ${hooks.items + .map( + (hook) => + `
  • ${htmlEscape(hook.topic)}: ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)}
  • `, + ) + .join('')}
+
` + : '
' + } + ${ + pending.items.length + ? `
+

O que falta

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

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

+
` + : '
' + } +
` +} + +const renderInstitutionSummary = (report, pageNo) => ` +
+ ${instHeader(report, 'Resumo de uma olhada', 'Informação datada e pronta para conferência', pageNo)} +
+ ${renderInstitutionIdentity(report)} +
+

01 · vínculo com a instituição

+

Linha do tempo documentada

+ ${renderInstitutionVincTimeline(report)} +
+
+ ${renderInstitutionDeliveries(report)} + ${renderInstitutionHooksAndPending(report)} + ${instFooter(report, 'Resumo · INSUMO INTERNO', pageNo)} +
` + +const renderInstitutionEraEmpty = (report, era, pageNo) => ` +
+ ${instHeader(report, `${era.label} · Pesquisa sem evidência suficiente`, era.period, pageNo)} +
+
+

Recorte previsto

+

${htmlEscape(era.method)}

+
+ +
+
+ ${WARNING_ICON} +

Lacuna explícita · não é “zero”

+

Nenhuma fonte suficiente foi localizada para esta era.

+

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.

+
+
+
+

O que foi feito

+
    +
  • Busca nominal por instituição e aliases.
  • +
  • Consulta a fontes oficiais e acervos datados.
  • +
  • Triagem de resultados por vínculo e abrangência.
  • +
  • Descarte de menções sem lastro suficiente.
  • +
+
+
+

O que falta fazer

+
    +
  • Solicitar consulta ao arquivo físico.
  • +
  • Validar nome histórico e unidade da instituição.
  • +
  • Recuperar ato, ata ou documento contemporâneo.
  • +
  • Repetir busca com alias confirmado.
  • +
+
+
+ + ${instFooter(report, `${era.label} · lacuna explícita`, pageNo)} +
` + +const renderInstitutionEra = (report, era, pageNo) => { + if (era.empty) return renderInstitutionEraEmpty(report, era, pageNo) + return ` +
+ ${instHeader(report, era.label, `${era.period} · ${era.subtitle}`, pageNo)} + +
+
+

Recorte e método

+

${htmlEscape(era.method)}

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

Entregas localizadas

Objeto, valor, fase e alcance

+
+ + + + + ${era.numbers + .map( + (row) => ` + + + + + + + `, + ) + .join('')} +
Valores por ano, fase, abrangência e fonte
ObjetoValorAnoFaseAbrangênciaFonte
${htmlEscape(row.object)}${valueCell(row.value, 'Valor a recuperar')}${htmlEscape(row.year ?? '—')}${phaseBadge(row.phase)}${scopeBadge(row.sphere, null, report.unit)}${sourceLink(row.sourceUrl)}${row.sphere !== 'instituicao' ? '
não somar à instituição' : ''}
+

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

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

Atuação / vínculo registrado

+

Papéis com evidência visível

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

${copyHtml(briefOr(action.brief, action.title))}

+ ${noteOr(action.brief, action.detail) ? `

${copyHtml(noteOr(action.brief, action.detail))}

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

Títulos, honrarias e vínculos institucionais

+
    ${era.honors + .map( + (honor) => + `
  • ${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}
  • `, + ) + .join('')}
+
+ ${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])} +
` + : '' + } + + ${instFooter(report, era.label, pageNo)} +
` +} + +const renderInstitutionScope = (report, pageNo) => { + const reach = report.reach + const cardClass = { institution: '', sector: 'card-sector', network: 'card-network' } + return ` +
+ ${instHeader(report, 'Abrangência: instituição × setor × rede', 'Três recortes, três leituras, nenhuma soma combinada', pageNo)} + +
+ ${WARNING_ICON} +
+

${htmlEscape(reach.ruleTitle)}

+

${htmlEscape(reach.ruleBody)}

+
+
+ +
+

Leitura lado a lado

+

O alcance acompanha cada evidência

+
+ ${reach.lists + .map( + (list) => `
+
${scopeBadge(list.sphere, null, report.unit)}${list.sphere === 'instituicao' ? 'DIRETO' : 'NÃO SOMAR'}
+

${list.total} ${list.total === 1 ? 'item' : 'itens'}

+

com fonte

+
    ${list.items + .map( + (item) => + `
  • ${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}
  • `, + ) + .join( + '', + )}${list.items.length === 0 ? '
  • Nenhum item com fonte.
  • ' : ''}
+ ${moreNote(list, 'itens')} +
`, + ) + .join('')} +
+
+ + ${ + reach.evidence.items.length + ? `
+

Evidência de alcance

Por que cada item está neste recorte

Sem evidência, vira lacuna

+ + + + + ${reach.evidence.items + .map( + (row) => ` + + + + + `, + ) + .join('')} +
Itens de setor e rede com evidência de alcance
ItemAbrangênciaEvidência de alcanceFonte
${copyHtml(briefOr(row.brief, row.item))}${scopeBadge(row.sphere, null, report.unit)}${copyHtml(row.evidence)}${sourceLink(row.sourceUrl)}
+ ${moreNote(reach.evidence, 'itens')} +
` + : '' + } + + ${ + reach.hook || reach.priorityGap + ? `
+ ${ + reach.hook + ? `

Gancho possível

${htmlEscape(reach.hook.topic)}: ${copyHtml(briefOr(reach.hook.brief, reach.hook.angle))} ${sourceLink(reach.hook.sourceUrl)}

` + : '
' + } + ${ + reach.priorityGap + ? `

Lacuna prioritária

${htmlEscape(reach.priorityGap)}

` + : '
' + } +
` + : '' + } + + ${instFooter(report, 'Abrangência · recortes não somáveis', pageNo)} +
` +} + +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 ` +
+ ${instHeader(report, 'Títulos, honrarias e vínculos', 'Reconhecimento não substitui entrega; vínculo não prova resultado', pageNo)} +
+

Registros confirmados

+

Data, natureza e fonte primária

+ + + + + ${honors + .map( + (honor) => ` + + + + + `, + ) + .join('')} +
Títulos, honrarias e vínculos por data e fonte
DataNaturezaRegistroFonte
${htmlEscape(honorDate(honor))}reconhecimento${copyHtml(briefOr(honor.brief, honor.text))}${noteOr(honor.brief, honor.detail) ? `
${copyHtml(noteOr(honor.brief, honor.detail))}` : ''}
${sourceLink(honor.sourceUrl)}
+
+
+
+

Como ler

+
+
+

Vínculo documentado

+

Informa papel, unidade e período. Não autoriza atribuir toda ação posterior da instituição.

+
+
+

Honraria documentada

+

Registra reconhecimento e órgão concedente. Não vira prova de recurso ou entrega.

+
+
+
+ ${assetBox('NEEDS ASSET', ['reprodução do ato, diploma ou clipping', 'origem, autoria e licença'])} +
+
+
+

Gancho para a agenda

+

${hook ? `${htmlEscape(hook.topic)}: ${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.'}

+
+
+

Pendência documental

+

${gap ? `${htmlEscape(gap.label)}: ${htmlEscape(gap.reason)}` : 'Nenhuma pendência declarada para os registros desta página.'}

+
+
+ ${instFooter(report, 'Títulos e vínculos · INSUMO INTERNO', pageNo)} +
` +} + +const renderInstitutionGapsPage = (report, chunk, index, pageNo) => { + const rows = chunk.length + ? chunk + .map( + (gap) => ` + ${htmlEscape(gap.label)} + ${htmlEscape(gap.reason)} + Não completar por inferência. + ${htmlEscape(gap.nextStep)} + `, + ) + .join('') + : 'Nenhuma lacuna declarada — confira a cobertura das eras.' + return ` +
+ ${instHeader(report, 'Lacunas explícitas', `O que não foi encontrado${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)} +
+

O que não foi encontrado

Lacunas explícitas

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

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

Lista clicável

+

Notícias e documentos consultados

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

Acervo interno (read-only)

Falas do mandato sobre o recorte

Base Teqo, 2011+

+
    ${report.acervo.items + .map( + (row) => + `
  • ${htmlEscape(row.period)} — ${copyHtml(row.text)}${row.excerpt ? ` (${copyHtml(row.excerpt)})` : ''} ${sourceLink(row.sourceUrl)}
  • `, + ) + .join('')}
+ ${moreNote(report.acervo, 'itens')} +
` + : '' + } + +
+

Limites de cobertura

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

Regras para uso editorial

    ${report.limits.editorial + .map((item) => `
  • ${htmlEscape(item)}
  • `) + .join('')}
+
+ + + + ${instFooter(report, 'Fontes e limites · INSUMO INTERNO', pageNo)} +
` + +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 ` + +${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.subjectName)} + +${parts.join('\n')} + +` +} + +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) diff --git a/scripts/lib/dossieResearch.mjs b/scripts/lib/dossieResearch.mjs index 7bb52429..2f986aaa 100644 --- a/scripts/lib/dossieResearch.mjs +++ b/scripts/lib/dossieResearch.mjs @@ -1,15 +1,17 @@ /** - * Research contract for the C186 dossiê (owner). The dossiê is researched one - * file per era (`..research.json`); each claim either carries a date - * + URL or becomes an explicit gap. Reuses the C163 validation primitives - * (exported additively from `cityReportResearch.mjs`) instead of twinning them. + * Research contract for the dossiê (owner: C186; unit seam added by C187). The + * dossiê is researched one file per era (`..research.json`); each + * claim either carries a date + URL or becomes an explicit gap. Reuses the C163 + * validation primitives (exported additively from `cityReportResearch.mjs`) + * instead of twinning them. + * + * 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>} */ +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} */ -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} */ -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, diff --git a/scripts/lib/dossieUnit.mjs b/scripts/lib/dossieUnit.mjs new file mode 100644 index 00000000..4c2e270b --- /dev/null +++ b/scripts/lib/dossieUnit.mjs @@ -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' diff --git a/scripts/lib/readOnlyExtract.mjs b/scripts/lib/readOnlyExtract.mjs new file mode 100644 index 00000000..a0a45b0a --- /dev/null +++ b/scripts/lib/readOnlyExtract.mjs @@ -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} + */ +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 +} diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index e41e2dad..b7e9b9f2 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -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', diff --git a/src/lib/institutionCatalog.ts b/src/lib/institutionCatalog.ts new file mode 100644 index 00000000..17228c0a --- /dev/null +++ b/src/lib/institutionCatalog.ts @@ -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 = { + 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 = { + 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 = { + 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= --name=""` 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, +] diff --git a/src/lib/institutionNameAliases.ts b/src/lib/institutionNameAliases.ts new file mode 100644 index 00000000..f6b9acad --- /dev/null +++ b/src/lib/institutionNameAliases.ts @@ -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>() + +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 diff --git a/tests/int/institutionSnapshot.int.spec.ts b/tests/int/institutionSnapshot.int.spec.ts new file mode 100644 index 00000000..3614ad9a --- /dev/null +++ b/tests/int/institutionSnapshot.int.spec.ts @@ -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' })) + }) +}) diff --git a/tests/unit/buildPdf.unit.spec.ts b/tests/unit/buildPdf.unit.spec.ts new file mode 100644 index 00000000..ae5f5556 --- /dev/null +++ b/tests/unit/buildPdf.unit.spec.ts @@ -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') + }) +}) diff --git a/tests/unit/dossieInstitution.unit.spec.ts b/tests/unit/dossieInstitution.unit.spec.ts new file mode 100644 index 00000000..8da16cf2 --- /dev/null +++ b/tests/unit/dossieInstitution.unit.spec.ts @@ -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 = {}) => ({ + 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: '' })]), + { unit: INSTITUTION_UNIT }, + ), + ], + { unit: INSTITUTION_UNIT }, + ), + generatedAt, + unit: INSTITUTION_UNIT, + }) + const hostileHtml = renderDossierHtml(hostile) + expect(hostileHtml).not.toContain('') + expect(hostileHtml).toContain('<script>') + }) + + 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') + }) +}) diff --git a/tests/unit/dossieInstitutionBatchSkill.unit.spec.ts b/tests/unit/dossieInstitutionBatchSkill.unit.spec.ts new file mode 100644 index 00000000..71283117 --- /dev/null +++ b/tests/unit/dossieInstitutionBatchSkill.unit.spec.ts @@ -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= --name=""') + }) + + 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=') + }) +}) diff --git a/tests/unit/dossieInstitutionResearch.unit.spec.ts b/tests/unit/dossieInstitutionResearch.unit.spec.ts new file mode 100644 index 00000000..33086b3c --- /dev/null +++ b/tests/unit/dossieInstitutionResearch.unit.spec.ts @@ -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 = {}) => ({ + id, + answer: 'Entrega documentada.', + sphere: 'instituicao', + sourceUrl: 'https://ufba.test/ato', + sourceDate: '2024-02-01', + ...overrides, +}) + +const eraResearch = (era: 'A' | 'B' | 'C', overrides: Record = {}) => ({ + 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/, + ) + }) +}) diff --git a/tests/unit/institutionCatalog.unit.spec.ts b/tests/unit/institutionCatalog.unit.spec.ts new file mode 100644 index 00000000..6daa2cd0 --- /dev/null +++ b/tests/unit/institutionCatalog.unit.spec.ts @@ -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() + }) +}) diff --git a/tests/unit/opencodeCommands.unit.spec.ts b/tests/unit/opencodeCommands.unit.spec.ts index b32454f1..023848d5 100644 --- a/tests/unit/opencodeCommands.unit.spec.ts +++ b/tests/unit/opencodeCommands.unit.spec.ts @@ -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', () => { diff --git a/tests/unit/readOnlyExtract.unit.spec.ts b/tests/unit/readOnlyExtract.unit.spec.ts new file mode 100644 index 00000000..fa47aaec --- /dev/null +++ b/tests/unit/readOnlyExtract.unit.spec.ts @@ -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/) + }) +})