mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(C187): dossiê Solla por instituição + boletim modelo (skill + builder) (Closes #1135)
Generaliza aditivamente o pipeline C186 (default município; specs verdes) via o descritor scripts/lib/dossieUnit.mjs (municipality|institution), extrai o emit A4/fit-guard para scripts/lib/buildPdf.mjs e a receita read-only para scripts/lib/readOnlyExtract.mjs. - Catálogo fail-closed src/lib/institutionCatalog.ts + institutionNameAliases.ts (seed UFBA/Correios/Enfermagem; escape one-off --slug/--name; dedup). - Skill/command/subagente /dossie-solla-instituicao; eras A/B/C reusadas. - Abrangência instituição × setor × rede nunca somada; era sem fonte vira página de lacuna explícita; lista capada declara "e mais N" (regra C188 no dono). - Boletim modelo 1×A4 com fase junto ao valor, sem fontes, sem CTA; herda apenas fatos com fonte do dossiê (incl. falas do acervo read-only). - Sem schema/migration/DB write; artefatos gitignored.
This commit is contained in:
parent
f658d5eaaa
commit
5c64547df3
30 changed files with 3791 additions and 179 deletions
271
.agents/skills/dossie-solla-instituicao/SKILL.md
Normal file
271
.agents/skills/dossie-solla-instituicao/SKILL.md
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
---
|
||||
name: dossie-solla-instituicao
|
||||
description: 'Gera o dossiê Solla por instituição (PDF A4 + .md, tudo o que Solla fez pela/na/com a instituição por era) e o Boletim Informativo modelo de 1 página, a partir da base Teqo read-only + pesquisa web datada + fontes oficiais; aceita uma instituição ou um lote separado por vírgula.'
|
||||
---
|
||||
|
||||
# Dossiê Solla por instituição + boletim modelo (C187)
|
||||
|
||||
Entrega, por instituição (universidade, instituto, empresa pública, autarquia,
|
||||
órgão, conselho/entidade de classe, categoria profissional, movimento, rede), um
|
||||
**par PDF A4 datado + companion `.md`** com tudo o que **Jorge Solla fez por, na
|
||||
e com a instituição ao longo da carreira** — toda afirmação não trivial com
|
||||
**URL + data**, tudo sem fonte vira **lacuna explícita** — **e** o **Boletim
|
||||
Informativo modelo** (1 página A4, linguagem de eleitor, sem declaração de
|
||||
fontes). Skill irmã da `dossie-solla-cidade` (C186): mesmo pipeline, recorte
|
||||
institucional. Os dois layouts vêm dos artefatos hi-fi aprovados pelo `designer`.
|
||||
|
||||
## Quando usar
|
||||
|
||||
- A comunicação pede "o dossiê da <instituição>" e/ou o "boletim modelo da
|
||||
<instituição>" — ou várias numa invocação (`/dossie-solla-instituicao UFBA,
|
||||
Correios`).
|
||||
- Quem executa são o **orquestrador** (agente principal) + um sub-agente
|
||||
**researcher por era, por instituição**, em paralelo + os scripts (`extract` no
|
||||
homeserver, `build` local). O detalhe está em "Pipeline (etapas)".
|
||||
|
||||
## Lote (várias instituições)
|
||||
|
||||
A invocação aceita **uma** instituição ou **várias**, separadas por **vírgula**:
|
||||
|
||||
```text
|
||||
/dossie-solla-instituicao UFBA # N=1
|
||||
/dossie-solla-instituicao UFBA, Correios # lote
|
||||
```
|
||||
|
||||
Parsing (orquestrador, **antes** de qualquer pesquisa):
|
||||
|
||||
- separa por `,`; aplica `trim`; descarta vazio;
|
||||
- cada token é aceito como **slug canônico** (`isInstitutionSlug`) **ou** como
|
||||
**nome** (fold acento-insensível `resolveInstitutionName`; `null` = nome
|
||||
desconhecido → token inválido) → entrada do catálogo;
|
||||
- **0 entradas** = token inválido; **>1 entrada** = ambíguo (peça o slug
|
||||
canônico explícito) — o token **falha fechado** com mensagem acionável
|
||||
(adicionar alias/entrada em `src/lib/institutionCatalog.ts` ou usar o escape
|
||||
one-off `--slug=<x> --name="<Nome>"`), **nunca** inventa slug;
|
||||
- **dedupe após a resolução**, preservando a ordem;
|
||||
- token inválido/ambíguo vira **falha isolada** com o motivo no summary final —
|
||||
**nunca** aborta o lote.
|
||||
|
||||
Uma instituição = um dossiê (PDF+MD) + **um** boletim PDF; **sem** índice/PDF
|
||||
agregado. Falha de uma instituição/era **não** cancela as demais (**sucesso
|
||||
parcial** explícito).
|
||||
|
||||
## Pipeline (etapas)
|
||||
|
||||
1. **Orquestrador (agente principal).** Parseia a lista (seção "Lote"), resolve
|
||||
as entradas no catálogo (`src/lib/institutionCatalog.ts`) e dispara **um
|
||||
researcher por era (A/B/C), por instituição** (Task), em paralelo, com guarda
|
||||
de concorrência (`MAX_RESEARCHERS_IN_FLIGHT = 6`). Não invente slug: confirme
|
||||
no catálogo ou use o escape one-off. Nunca retém pesquisa web nem o corpo de
|
||||
um `research.json` — só os recibos.
|
||||
2. **Researcher (sub-agente, ×3 por instituição, em paralelo).**
|
||||
`.opencode/agent/dossie-solla-instituicao.md` — o **único** passo pesado de
|
||||
contexto. Faz a pesquisa web datada da sua era e escreve
|
||||
`data/dossie-solla-instituicao/<slug>.<era>.research.json` (cada item com
|
||||
`sourceUrl` + `sourceDate`; sem fonte, **não escreva o item** — ele vira
|
||||
lacuna). Devolve **apenas o recibo curto** (seção "Recibo do researcher").
|
||||
Não roda ssh nem build.
|
||||
3. **Extração read-only no homeserver — serializada, 1 instituição por vez.**
|
||||
`scripts/extract-institution-snapshot.mjs` (`INSTITUTION_REPORT_CONFIRM=1`,
|
||||
sessão read-only) lê o acervo interno de falas por **tema→instituição**
|
||||
(`topics` da entrada do catálogo; o acervo cobre 2011+ e não tem campo
|
||||
instituição) e grava
|
||||
`data/dossie-solla-instituicao/<slug>.institution.snapshot.json`.
|
||||
4. **Fontes oficiais em tempo de build.** O Portal da Transparência **não filtra
|
||||
por instituição** e a Câmara tampouco; emendas/proposições institucionais
|
||||
entram como **itens de pesquisa** (com fase e fonte). Sem atribuição → lacuna,
|
||||
**nunca zero silencioso**. O builder **não** busca emendas/Câmara/IBGE.
|
||||
5. **Render local (por instituição):**
|
||||
```bash
|
||||
NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-instituicao.mjs \
|
||||
--snapshot=data/dossie-solla-instituicao/<slug>.institution.snapshot.json \
|
||||
--research-dir=data/dossie-solla-instituicao \
|
||||
--out-dir=docs/research/dossie-solla-instituicao
|
||||
```
|
||||
Saídas: `docs/research/dossie-solla-instituicao/<slug>-<YYYY-MM-DD>-dossie.pdf`
|
||||
+ `-dossie.md` + `-boletim.pdf`. Intermediários gitignored em
|
||||
`data/dossie-solla-instituicao/` (HTML, JSONs, logs). Flags úteis:
|
||||
`--generated-at=<ISO>`, `DOSSIER_STRICT=1` (falha o run se houver lacuna —
|
||||
conferência, não entrega).
|
||||
6. **Summary final.** Uma linha por entrada, na ordem da lista: `entrada · status
|
||||
(ok|failed) · dossiê/boletim (quando ok) · motivo (quando falha)`.
|
||||
|
||||
## Briefing por era (A/B/C)
|
||||
|
||||
O researcher recebe **uma era** e usa o checklist da era (ver "Contrato dos
|
||||
JSONs"). As eras são as mesmas da C186 (não criar eras novas):
|
||||
|
||||
- **Era A (até 2006)** — formação/residência/pesquisa com vínculo nominal;
|
||||
SESAB; consultoria no MS; gestão municipal; Secretaria de Atenção à Saúde do
|
||||
MS; vínculo institucional documentado. O acervo interno de falas cobre 2011+:
|
||||
**pré-2011 é lacuna explícita**, não invenção.
|
||||
- **Era B (2007–2014)** — gestão da SESAB com a instituição; equipamentos;
|
||||
programas; obras; convênios/termos. Fontes: DOE-BA (DOOL), SESAB/instituição,
|
||||
Transparência Bahia.
|
||||
- **Era C (2015–2027)** — pronunciamentos; proposições/relatorias de interesse;
|
||||
emendas; títulos/honrarias; atuação; parcerias/audiências. Fontes: API Câmara
|
||||
(deputy id `178857`; `/discursos` **default 7 dias** — sempre passe a janela;
|
||||
relatorias saem das tramitações), Portal da Transparência, Siga Brasil.
|
||||
|
||||
Templates de busca: `"Jorge Solla" <instituição>` + `<tema>`; recorte por data
|
||||
`after:YYYY-MM-DD before:YYYY-MM-DD`; `site:` por fonte institucional/oficial
|
||||
(ufba.br, correios.com.br, cofen.gov.br, camara.leg.br, ptbahia.org.br,
|
||||
jorgesolla.com.br); excluir ruído de campanha 2026 (`-eleição -candidato -voto
|
||||
-coligação -pesquisa`) e preferir verbos de entrega (emenda, recurso, convênio,
|
||||
parceria, projeto, relatoria, audiência, homenagem, repassado).
|
||||
|
||||
## Recibo do researcher
|
||||
|
||||
O sub-agente devolve **só** este recibo curto (≤ ~15 linhas) — **nunca** o corpo
|
||||
do `research.json` (`items[].answer/details`):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"slug": "ufba",
|
||||
"era": "C",
|
||||
"status": "ok", // ou "failed"
|
||||
"researchPath": "data/dossie-solla-instituicao/ufba.c.research.json",
|
||||
"researchedAt": "2026-09-17T10:00:00.000Z",
|
||||
"itemCount": 5, // itens da era com fonte
|
||||
"gapCount": 2, // == gaps.length
|
||||
"newsCount": 3,
|
||||
"gaps": ["era_c_parcerias", "…"],
|
||||
"failureReason": "…" // opcional (só quando status = failed)
|
||||
}
|
||||
```
|
||||
|
||||
`status: "failed"` carrega o motivo e **não** escreve corpo parcial; o
|
||||
orquestrador agrega os recibos no summary final — é o único dado de pesquisa que
|
||||
cruza para ele.
|
||||
|
||||
## Contrato dos JSONs
|
||||
|
||||
`<slug>.<era>.research.json` (um arquivo por era; `era` ∈ `A|B|C`):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"institutionSlug": "ufba", // obrigatório
|
||||
"era": "C", // obrigatório: A | B | C
|
||||
"researchedAt": "2026-09-17T10:00:00.000Z", // obrigatório
|
||||
"items": [
|
||||
{
|
||||
"id": "era_c_emendas", // id do checklist da era
|
||||
"answer": "R$ 5,0 milhões para estrutura de ensino e pesquisa",
|
||||
"details": "opcional; aceita {{fonte}} / {{fonte:N}}",
|
||||
"sphere": "instituicao", // instituicao | setor | rede (default instituicao)
|
||||
"numbers": [ // opcional
|
||||
{ "label": "Emenda", "value": "R$ 5,0 mi", "year": "2024", "phase": "empenhado" }
|
||||
],
|
||||
"sourceUrl": "https://…", // obrigatório no item publicado
|
||||
"sourceDate": "2026-09-15", // obrigatório
|
||||
"extraSources": [{ "label": "…", "url": "https://…", "date": "2026-09-01" }],
|
||||
"consultedAt": "2026-09-17T09:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"news": [
|
||||
{ "title": "…", "outlet": "…", "publishedAt": "2024-06-01", "url": "https://…", "summary": "…" }
|
||||
],
|
||||
"gaps": [{ "id": "…", "label": "…", "reason": "…" }]
|
||||
}
|
||||
```
|
||||
|
||||
Checklist por era (ids):
|
||||
|
||||
- **A:** `era_a_formacao`, `era_a_sesab`, `era_a_consultor_ms`, `era_a_conquista`,
|
||||
`era_a_sas_ms`, `era_a_vinculo`.
|
||||
- **B:** `era_b_sesab`, `era_b_equipamentos`, `era_b_programas`, `era_b_obras`,
|
||||
`era_b_convenios`.
|
||||
- **C:** `era_c_discursos`, `era_c_proposicoes`, `era_c_emendas`,
|
||||
`era_c_titulos`, `era_c_atuacao`, `era_c_parcerias`.
|
||||
|
||||
A **abrangência** (`sphere`) é `instituicao` | `setor` | `rede`; `setor` e `rede`
|
||||
não são a instituição e **nunca são somados** a ela (cada linha informa sua
|
||||
abrangência). Item sem `sourceUrl`/`sourceDate`, item ausente ou item de outra
|
||||
era vira **lacuna** (`Não pesquisado.` / `Sem fonte: …`); abrangência inválida
|
||||
vira lacuna. `phase` ∈ `autorizado|empenhado|liquidado|pago|restos` (default
|
||||
`nao_informado`; empenho **não** é pagamento). O `bulletinFacts` (ledger do
|
||||
boletim) só é populado por item **com fonte** — o boletim não introduz fato novo.
|
||||
|
||||
## Conteúdo do dossiê
|
||||
|
||||
- **Capa** — série institucional, "INSUMO INTERNO — defeso 2026", identificação
|
||||
(tipo/esfera/alcance em badges), data, "como usar", escopo/versão.
|
||||
- **Resumo de uma olhada** — identificação + linha do tempo documentada do
|
||||
vínculo; principais entregas localizadas (badge de abrangência + valor + badge
|
||||
de fase + fonte); gancho para a agenda; o que falta. Guarda: **autorizado ≠
|
||||
empenhado ≠ liquidado ≠ pago**.
|
||||
- **Seção por era (A/B/C)** — recorte e método + trilha de recuperação; tabela
|
||||
**Objeto/Valor/Ano/Fase/Abrangência/Fonte**; cards "Papéis com evidência";
|
||||
títulos/honrarias. Uma era **sem evidência** vira **página de lacuna
|
||||
explícita** (nunca seção em branco nem "zero").
|
||||
- **Abrangência: instituição × setor × rede** — painel "Setor/rede não é a
|
||||
instituição. Não some os recortes."; três listas com contagem por recorte
|
||||
(**nenhum total combinado**); tabela de evidência de alcance; gancho e lacuna
|
||||
prioritária.
|
||||
- **Títulos, honrarias e vínculos** — tabela reconhecimento/natureza/fonte.
|
||||
- **Fontes e limites** — tabela de lacunas; notícias/documentos consultados
|
||||
(larguras fixas 12/13/47/28%); limites de cobertura; regras para uso editorial;
|
||||
nota de defeso eleitoral 2026.
|
||||
- **Nada some por não caber:** listas capadas declaram o resto ("e mais N") e a
|
||||
reformulação fica no `summary` do researcher — nunca "…" silencioso (regra
|
||||
C188 aplicada no dono).
|
||||
|
||||
## Conteúdo do Boletim modelo (1 página A4)
|
||||
|
||||
- Cabeçalho com o nome da instituição + identity pills + rótulo **"Modelo —
|
||||
insumo interno"**; faixa de **defeso** ("sem CTA · sem propaganda"); abertura
|
||||
"O que Jorge Solla fez pela e na <instituição>"; **≤6 destaques** (eyebrow
|
||||
"Área · abrangência", número em destaque, título curto, nota); timeline de 4
|
||||
passos da trajetória; **"E mais" com ≤14 itens** em duas colunas; rodapé com
|
||||
controle editorial e defeso. Com **poucos fatos**, usa a variação de lacuna
|
||||
(não cria cards vazios para chegar a seis).
|
||||
- **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem
|
||||
CTA de campanha; herda **apenas** fatos com fonte do dossiê.
|
||||
|
||||
## Guardrails de produto (não negociáveis)
|
||||
|
||||
- **Sem fonte, não publica**: afirmação não trivial sem URL+data vira lacuna.
|
||||
- **Empenho ≠ pagamento**: cada valor acompanha sua fase; nunca consolidar.
|
||||
- **Abrangência explícita** (`instituição`/`setor`/`rede`): setor e rede
|
||||
**nunca** são somados como se fossem a instituição.
|
||||
- **Leitura relativa**: nunca % estadual absoluto.
|
||||
- **Atribuição de emenda** de bancada/relator só com autoria checada; sem
|
||||
atribuição → lacuna (o Portal não filtra por instituição).
|
||||
- **Lacuna explícita** em vez de inferência; o acervo interno cobre 2011+.
|
||||
- **PII mínima**: telefone/e-mail nunca entram.
|
||||
- **Falha isolada** por era/instituição não cancela o lote (**sucesso parcial**).
|
||||
- **Artefato gitignored** (repo público): PDF/MD/JSON nunca são commitados.
|
||||
- **Defeso**: dossiê e boletim saem como insumo interno/modelo, sem CTA; a peça
|
||||
final publicável é da comunicação/campanha. **Sem schema/migration/DB write.**
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Página do dossiê / boletim estourou**: o builder aborta com a altura medida.
|
||||
**Aperte o teto de conteúdo/corte copy — nunca mexa no layout para espremer.**
|
||||
- **Era sem pesquisa**: o builder sintetiza o arquivo da era com lacunas
|
||||
explícitas — e a era sai como página de lacuna, não inventada.
|
||||
- **Câmara 429/500**: `getJsonWithBackoff` + paginação com teto; falha degrada
|
||||
para lacuna datada. Os discursos sempre passam janela (`dataInicio`/`dataFim`).
|
||||
- **Sem chave do Portal da Transparência**: emendas institucionais viram lacuna
|
||||
(o dossiê sai).
|
||||
- **Slug de pesquisa ≠ snapshot**: o builder recusa o par — regenere, nunca
|
||||
edite o JSON à mão para casar.
|
||||
- **Token desconhecido no lote**: vira linha `failed` no summary com o motivo.
|
||||
Use `--slug=<x> --name="<Nome>"` para uma instituição fora do catálogo.
|
||||
- **Chromium**: vem do `@playwright/test`; se faltar binário,
|
||||
`pnpm exec playwright install chromium`.
|
||||
|
||||
## Referências
|
||||
|
||||
- Intenção: `docs/plans/dossie-solla-instituicao.md`; impl:
|
||||
`docs/plans/dossie-solla-instituicao-impl.md`.
|
||||
- Designs hi-fi (fonte de verdade do port):
|
||||
`docs/plans/dossie-solla-instituicao-ui-design.html` e
|
||||
`docs/plans/dossie-solla-instituicao-boletim-ui-design.html`.
|
||||
- Skill irmã (dona do pipeline): `.agents/skills/dossie-solla-cidade/SKILL.md`.
|
||||
- Scripts: `scripts/build-dossie-solla-instituicao.mjs`,
|
||||
`scripts/extract-institution-snapshot.mjs`, `scripts/institutionSnapshot.mjs`,
|
||||
`scripts/lib/dossie*.mjs`, `scripts/lib/buildPdf.mjs`,
|
||||
`scripts/lib/readOnlyExtract.mjs`.
|
||||
- Catálogo: `src/lib/institutionCatalog.ts`, `src/lib/institutionNameAliases.ts`.
|
||||
- Runbook de extração read-only: `docs/ops/teqo-1313-deploy.md` §C163.
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
42
.opencode/agent/dossie-solla-instituicao.md
Normal file
42
.opencode/agent/dossie-solla-instituicao.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
description: Researcher por era — pesquisa web datada e escreve o research.json de uma era (A/B/C) do dossiê Solla por instituição; devolve só o recibo curto (etapa do lote /dossie-solla-instituicao)
|
||||
mode: subagent
|
||||
---
|
||||
|
||||
# Dossiê Solla por instituição — researcher (por era)
|
||||
|
||||
Você é a etapa **researcher** da skill
|
||||
`.agents/skills/dossie-solla-instituicao/SKILL.md` (fonte canônica: contrato dos
|
||||
JSONs, checklist por era, guardrails, recibo). Leia-a antes de rodar qualquer
|
||||
comando. Você é invocado **uma vez por era** (o orquestrador diz qual: `A`, `B`
|
||||
ou `C`) pelo agente principal, que já resolveu o slug e coordena as demais
|
||||
eras/instituições em paralelo.
|
||||
|
||||
## Papel
|
||||
|
||||
1. Confirmar o slug canônico da instituição (catálogo
|
||||
`src/lib/institutionCatalog.ts`) e a era atribuída.
|
||||
2. Fazer a **pesquisa web datada** da era e escrever
|
||||
`data/dossie-solla-instituicao/<slug>.<era>.research.json` no contrato da
|
||||
skill (ex.: `ufba.c.research.json`) — item sem `sourceUrl`/`sourceDate` não
|
||||
vai para o arquivo (vira lacuna explícita). Use o checklist da era e marque
|
||||
`sphere` (`instituicao`/`setor`/`rede`); setor e rede **nunca** são somados à
|
||||
instituição.
|
||||
3. Devolver **apenas o recibo curto** (seção "Recibo do researcher" da skill):
|
||||
`slug`, `era`, `status`, `researchPath`, `researchedAt`, `itemCount`,
|
||||
`gapCount`, `newsCount`, `gaps`, `failureReason?`. **Nunca** devolva o corpo
|
||||
do `research.json` (`items[].answer/details`, …).
|
||||
|
||||
## Limites
|
||||
|
||||
- **Não** conduza a extração nem o build — são etapas determinísticas do
|
||||
orquestrador (`scripts/extract-institution-snapshot.mjs` no homeserver,
|
||||
`scripts/build-dossie-solla-instituicao.mjs` local). Não faça `ssh`, `scp` nem
|
||||
render.
|
||||
- **Nunca** escreva na base de produção: não use `db:pull`/snapshot de banco.
|
||||
- **Nunca** commite o `research.json`/PDF/MD/snapshot (dado interno; repo público
|
||||
— artefato gitignored).
|
||||
- Não invente fato sem fonte; não trate empenho como pagamento (informe a fase);
|
||||
não inclua telefone/e-mail de liderança; não reconstrua a carreira pré-2011
|
||||
fora do acervo (é lacuna).
|
||||
- Não edite os scripts nem o layout para "caber".
|
||||
9
.opencode/commands/dossie-solla-instituicao.md
Normal file
9
.opencode/commands/dossie-solla-instituicao.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
description: Gera o dossiê Solla por instituição (PDF A4 + .md) e o Boletim Informativo modelo de 1 página para uma instituição ou um lote separado por vírgula
|
||||
---
|
||||
|
||||
Carregue a skill `dossie-solla-instituicao` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os slugs canônicos no catálogo de instituições, dispara um researcher por era (A/B/C) por instituição, roda a extração read-only do acervo (por tema→instituição) e o build local (dossiê + boletim) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-instituicao/SKILL.md` — não a transcreva nem recrie o fluxo.
|
||||
|
||||
Uso: `/dossie-solla-instituicao <Instituição> [<Instituição>, ...]` — ex.: `/dossie-solla-instituicao UFBA, Correios`. Uma instituição = dossiê (`<slug>-<YYYY-MM-DD>-dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). Token fora do catálogo falha fechado; use o escape one-off `--slug=<x> --name="<Nome>"`.
|
||||
|
||||
$ARGUMENTS
|
||||
1
docs/changelog/2026-09-17-c187.md
Normal file
1
docs/changelog/2026-09-17-c187.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C187 — Dossiê Solla por instituição + Boletim modelo (2026-09-17):** nova skill `/dossie-solla-instituicao` (command + subagente researcher por era A/B/C), irmã da C186, que reúne com fonte e data tudo o que Jorge Solla fez por/na/com uma **instituição** (universidade, empresa pública, autarquia, órgão, conselho/entidade de classe, categoria profissional, movimento, rede) e entrega o **dossiê** (PDF A4 + companion `.md`: capa com identificação por tipo/esfera/alcance, resumo com linha do tempo do vínculo, seções por era com tabela Objeto/Valor/Ano/Fase/**Abrangência**/Fonte, painel `instituição × setor × rede` sem total combinado, títulos/honrarias, lacunas, fontes, defeso — era sem evidência vira **página de lacuna explícita**, nunca "zero") e o **Boletim modelo** (1 página A4, linguagem de eleitor, ≤6 destaques + ≤14 "E mais", fase junto ao valor, sem fontes, sem CTA). Arquitetura: **edita o dono, não gema um irmão** — o descritor `scripts/lib/dossieUnit.mjs` (`municipality|institution`) generaliza **aditivamente** o pipeline C186 (`dossieResearch`/`dossieBlocks`/`dossieRender`/`dossieBulletin`/`dossieBulletinRender` seguem com default município; specs C186 verdes) e o emit/fit-guard virou dono único em `scripts/lib/buildPdf.mjs` (C186 migrado). Catálogo novo e **fail-closed** (`src/lib/institutionCatalog.ts` + `institutionNameAliases.ts`; seed UFBA/Correios/Enfermagem; token desconhecido → mensagem acionável + escape one-off `--slug/--name`; dedup; falha isolada no lote). Extração read-only nova (`scripts/institutionSnapshot.mjs` + `extract-institution-snapshot.mjs`, recorte **tema→instituição** — o acervo cobre 2011+ e não tem campo instituição), reusando `scripts/lib/readOnlyExtract.mjs` (dono da receita fail-closed, compartilhado com o extrator C163). O builder **não** busca emendas/Câmara/IBGE (o Portal não filtra por instituição; emenda institucional sem atribuição vira lacuna). O contrato sem-truncamento do C188 vale no dono (lista capada declara "e mais N"). Sem schema, migration, escrita no DB, Consent ou URL pública — artefatos gitignored (repo público). Novos specs: `institutionCatalog`, `dossieInstitutionResearch`, `dossieInstitution`, `dossieInstitutionBatchSkill`, `readOnlyExtract`, `buildPdf` (+ int `institutionSnapshot`; `opencodeCommands` e `test-affected-core` atualizados). `pnpm gate:fast` verde (353 arquivos / 3658 testes) + int 92/824, `knip`/`cycles` limpos, smoke local dos PDFs (UFBA 9 págs + boletim 1 pág; Enfermagem esparsa com era vazia). Issue: #1135.
|
||||
178
docs/plans/dossie-solla-instituicao-impl.md
Normal file
178
docs/plans/dossie-solla-instituicao-impl.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# Impl: Dossiê Solla por instituição + Boletim modelo (skill → dossiê + boletim)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-17
|
||||
Issue: #1135
|
||||
Intenção: docs/plans/dossie-solla-instituicao.md
|
||||
Appetite restante: ~3–4 dias eng (herdado)
|
||||
|
||||
> Aprovado no modo `--auto` (`/work-issue --issue 1135 --auto`): a pausa do GATE foi convertida em apresentação-no-chat. Literais e cortes de escopo do plano de intenção foram assumidos; nada de schema/Consent/URL público entra aqui.
|
||||
|
||||
## Leitura da intenção (Outcome / o que NÃO negociar / o que reavaliar)
|
||||
|
||||
- **Outcome:** `/dossie-solla-instituicao <instituição>` entrega, numa invocação, um par **PDF A4 datado + companion `.md`** (identificação por `kind`/`sphere`/`scope`; seções por era A/B/C; tabelas **Objeto/Valor/Ano/Fase/Abrangência/Fonte**; painel de abrangência `instituição × setor × rede`; lacunas; fontes; defeso) **e** o **Boletim modelo** (1 página A4, linguagem de eleitor, ≤6 destaques + ≤14 "E mais", sem fontes, sem CTA, rótulo "Modelo — insumo interno" + defeso, `NEEDS ASSET`). Lote de 1+N; falha isolada não cancela o lote.
|
||||
- **O que NÃO negociar:** catálogo **fail-closed** (token desconhecido → mensagem acionável + escape `--slug=<x> --name="<Nome>"`, nunca slug inventado) e dedup após resolução; eras A/B/C **reusadas** do C186 (não criar eras novas); abrangência `instituicao | setor | rede` **nunca somada**; fases `autorizado|empenhado|liquidado|pago|restos` (default `nao_informado`, empenho ≠ pago); "sem fonte, não publica" + lacuna explícita; PII mínima; leitura relativa (nunca % estadual absoluto); artefato **gitignored** (repo público); **sem schema/migration/DB write/segundo cadastro**; **não alterar** `/dossie-solla-cidade` nem `/relatorio-cidade` (só generalização aditiva do dono, com as specs C186/C163 verdes); **sem truncamento mecânico/descarte silencioso** (reformular no `summary` ou declarar "e mais N" — regra C188, aplicada no dono).
|
||||
- **O que reavaliar (hipóteses da "Direção no codebase"):**
|
||||
- "reusar o extrator C186/C163 sem mudanças" — falso: `--municipality=`, `resolveAccessibleMunicipalityContext` e `loadSpeeches(municipalities:[id])` prendem o snapshot a município. O recorte institucional é **tema→instituição**; exige um composer novo que reusa a plumbing read-only, não o composer.
|
||||
- "reusar emendas/Câmara/saúde do C186 em tempo de build" — falso: o Portal **não filtra por instituição** e `fetchAuthorEmendas` exige `municipalityCode/Name`; a Câmara não filtra por instituição. Em `build`, emenda/proposição institucional sem autoria/fonte → **lacuna**; os números vêm dos itens de pesquisa (com fase).
|
||||
- "build reusa `build-dossie-solla-cidade.mjs`" — só a **técnica** (Chromium/`page.pdf`/fit-guard); a plumbing vira dono próprio (`buildPdf.mjs`) porque este é o 3º builder CLI (gatilho já registrado na review do C186).
|
||||
- **Design (fonte de verdade do port — portar classe a classe, sem redesenhar):** dossiê `docs/plans/dossie-solla-instituicao-ui-design.html`, **9 cenas**: 01 Capa; 02 Resumo; 03/04/05 Eras A/B/C; 06 Abrangência `instituição × setor × rede`; 07 Títulos/honrarias/vínculos; 08 Fontes/lacunas/defeso; 09 **empty-state** "era sem fonte". Boletim `docs/plans/dossie-solla-instituicao-boletim-ui-design.html`, **2 cenas** (composição completa + variação de poucos fatos). A estrutura visual é do `designer`; o implementer **porta classe a classe**.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["/dossie-solla-instituicao UFBA, Correios"] --> B["orquestrador: resolve token<br/>isInstitutionSlug / resolveInstitutionName<br/>fail-closed + escape --slug/--name + dedup"]
|
||||
B --> C["N researchers por era (A|B|C × instituição)<br/>paralelo, MAX_RESEARCHERS_IN_FLIGHT=6"]
|
||||
C --> R["<slug>.<era>.research.json<br/>+ recibo curto (status/paths/gaps)"]
|
||||
B --> E["extração read-only, 1 instituição por vez<br/>extract-institution-snapshot.mjs<br/>composeInstitutionSnapshot (por tema)"]
|
||||
E --> S["<slug>.institution.snapshot.json"]
|
||||
R --> G["build-dossie-solla-instituicao.mjs"]
|
||||
S --> G
|
||||
G --> I["dossieBlocks(unit=INSTITUTION):<br/>capa+resumo+eras+abrangência+fontes<br/>→ bulletinFacts ledger (só fato com fonte)"]
|
||||
I --> J["dossieRender → PDF A4 multi-página + .md<br/>(buildPdf.mjs: fit-guard + emit)"]
|
||||
I --> K["dossieBulletin(unit=INSTITUTION) → dossieBulletinRender"]
|
||||
K --> L["PDF 1×A4 boletim modelo"]
|
||||
J --> M["docs/research/dossie-solla-instituicao/<slug>-<YYYY-MM-DD>-*.pdf/.md"]
|
||||
L --> M
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) generalizar os libs do C186 in-place com `if` de unidade espalhado; B) novo conjunto de libs institucionais reusando só helpers puros; C) **hibrido**: seam explícito de unidade (`dossieUnit.mjs`) + generalização **aditiva** do dono (default município) + módulo fino de conteúdo institucional.
|
||||
**Recomendação:** **C** — mantém **um dono** do pipeline (research/render/ledger/PDF) e uma só cópia das mecânicas compartilhadas, ao mesmo tempo em que dá à instituição seu vocabulário (checklist, abrangência 3-listas, copy) sem forçar a seção `região/polo` do C186 a virar uma união estruturalmente estranha.
|
||||
**Rejeitadas:** **B** — twin do normalizador de pesquisa, do shell de render e do ledger do boletim (exatamente a drift que o repo proíbe); **A** — a seção de abrangência (3 listas) e a de região/polo (2 listas + "não some") são **estruturalmente** diferentes; um `if` por seção em ~5 libs deixa o C186 mais frágil e o diff maior.
|
||||
|
||||
## Componentes / mudanças
|
||||
|
||||
**Skill / command / subagente**
|
||||
|
||||
- **`.agents/skills/dossie-solla-instituicao/SKILL.md`** (novo) — fonte canônica; seções `Lote`, `Catálogo de instituições` (fail-closed/escape/dedup), `Pipeline (etapas)`, `Briefing por era (A/B/C)`, `Recibo do researcher`, `Contrato dos JSONs` (`institutionSlug`, `sphere: instituicao|setor|rede`), `Conteúdo` (dossiê + boletim), `Guardrails`, `Troubleshooting`, `Referências` (aponta os dois HTMLs hi-fi).
|
||||
- **`.opencode/commands/dossie-solla-instituicao.md`** (novo) — frontmatter só `description:`; corpo carga a skill pelo **nome exato** e termina em `$ARGUMENTS`.
|
||||
- **`.opencode/agent/dossie-solla-instituicao.md`** (novo) — `description:` + `mode: subagent`; researcher **por era**; escreve `data/dossie-solla-instituicao/<slug>.<era>.research.json`; devolve só o recibo; proibido `ssh`/`scp`/build/`--snapshot=`/commitar.
|
||||
|
||||
**Catálogo (novo, TS em `src/lib` pelo precedente `municipalityCatalog`)**
|
||||
|
||||
- **`src/lib/institutionCatalog.ts`** — `InstitutionKind` (`universidade|instituto_federal|empresa_publica|autarquia|orgao_publico|conselho_classe|entidade_classe|categoria_profissional|movimento|rede|outro`), `InstitutionSphere` (`federal|estadual|municipal|nao_governamental`), `InstitutionScope` (`nacional|BA`), `InstitutionCatalogEntry { slug, name, aliases, kind, sphere, scope, topics }`, `institutionCatalog` (seed **ufba** universidade/federal/BA; **correios** empresa_publica/federal/nacional; **enfermagem** categoria_profissional/federal/nacional com âncoras ABEn-BA/COFEN/Coren-BA), `getInstitutionCatalogEntry`, `isInstitutionSlug`, `institutionCatalogEntriesForName`.
|
||||
- **`src/lib/institutionNameAliases.ts`** — `INSTITUTION_NAME_ALIASES`, `resolveInstitutionName` (fold `normalizeSearchPhrase`, espelhando `municipalityNameAliases.ts`).
|
||||
|
||||
**Seam + libs generalizados (aditivos; C186 verde)**
|
||||
|
||||
- **`scripts/lib/dossieUnit.mjs`** (novo) — `MUNICIPALITY_UNIT`, `INSTITUTION_UNIT`, `DOSSIER_UNITS`; campos `id`, `slugField` (`municipalitySlug`/`institutionSlug`), `sphereValues`+`defaultSphere`+`sphereLabels`+`sphereBadgeClass`, `breadthRank`, `researchDir`, `outDir`, `title`, `coverCopy`, `limits`, `checklist`, `identity(snapshot)`. Checklist institucional: A `era_a_formacao|sesab|consultor_ms|conquista|sas_ms|vinculo_institucional`; B `era_b_sesab|equipamentos|programas|obras|convenios`; C `era_c_discursos|proposicoes|emendas|titulos|atuacao|parcerias`.
|
||||
- **`scripts/lib/dossieResearch.mjs`** (editar) — `normalizeDossierResearchInput(raw, { unit = MUNICIPALITY_UNIT })`, `mergeDossierResearch(researches, { unit })`, `dossierResearchReceipt(research, { unit })`, `dossierChecklistForEra(era, unit)`. Sem opções → comportamento C186 idêntico (mensagens `/municipalitySlug/`, `/researchedAt/`, `/era/` preservadas).
|
||||
- **`scripts/lib/dossieBlocks.mjs`** (editar) — `buildDossierReport({ snapshot, research, emendas=null, camara=null, health=null, generatedAt, unit = MUNICIPALITY_UNIT })`; mecânicas compartilhadas (number rows, `bulletinFacts`, limites, `phaseLabel`) ficam; o shape de seção ramifica em `regiao` (município) vs `abrangencia` (3 listas `instituicao/setor/rede`, sem total combinado); adiciona contagem `omitted` por lista capada.
|
||||
- **`scripts/lib/dossieRender.mjs`** (editar) — `renderDossierHtml/Md(report)` lê `report.unit`; shell/`document-table`/`asset-box` compartilhados; classes novas `identity-badge`, `scope-sector`, `scope-network`, `phase-paid`, `phase-pending`, `timeline-item`, `empty-panel`; `<dl>` da capa e `data-page` dirigidos pelo descritor.
|
||||
- **`scripts/lib/dossieBulletin.mjs`** (editar) — `buildBulletin({ facts, municipality, region, identity = null, unit = MUNICIPALITY_UNIT, generatedAt })`; ranking por `unit.breadthRank` (`instituicao` antes de `setor`/`rede`); tetos 6/14 preservados.
|
||||
- **`scripts/lib/dossieBulletinRender.mjs`** (editar) — `renderBulletinHtml(bulletin)` + `identity-pill`, `defeso-band`, `lacuna-panel`; reusa `highlight-card/-number/-title/-note/-eyebrow`, `more-item`, `timeline-step`, `model-label`, `asset-box`; copy de `unit.bulletinCopy`.
|
||||
- **`scripts/lib/buildPdf.mjs`** (novo) — `A4_PAGE_BUDGET_PX`, `launchPdfBrowser`, `assertPageFits(page, { selector, budgetPx, label })`, `printPdf(page, outPath)`. Consumido por `build-dossie-solla-cidade.mjs` (editado p/ importar) e pelo builder novo. C163 intocado.
|
||||
|
||||
**Extração read-only + builder**
|
||||
|
||||
- **`scripts/lib/readOnlyExtract.mjs`** (novo) — `resolveCampaignActor(payload)` + `enterReadOnlySession()` (receita fail-closed do ator real + sessão read-only); consumido pelo extrator C163 (editar, sem mudança de comportamento) e pelo novo.
|
||||
- **`scripts/institutionSnapshot.mjs`** (novo) — `composeInstitutionSnapshot({ payload, actor, slug, readAt, codeSha, database })` → `{ meta, institution:{ slug,name,kind,sphere,scope,aliases,topics }, speeches:{ topics, totalCount, rows }, gaps }`; reusa `buildSpeechListWhere` + `loadSegmentsForSpeeches` + `overrideAccess:false`, recorte por `topics` do catálogo (mapa tema→instituição).
|
||||
- **`scripts/extract-institution-snapshot.mjs`** (novo) — CLI `--institution=<slug> --out=<json>`; `INSTITUTION_REPORT_CONFIRM=1`; sessão read-only; serializado 1 instituição por vez.
|
||||
- **`scripts/build-dossie-solla-instituicao.mjs`** (novo) — CLI `--snapshot= --research-dir= --out-dir= [--generated-at=]`; exige `snapshot.institution.slug`; lê `<slug>.{a,b,c}.research.json` (recusa slug divergente); **não** chama `fetchAuthorEmendas`/`fetchCamaraActivity`/`fetchHealthData`; `buildDossierReport({ ..., unit: INSTITUTION_UNIT })` → dossiê + `buildBulletin({ facts, identity, unit })`; HTML cache + `.md` + dois PDFs (guardas `[data-page="resumo"]` e `[data-page="boletim"]`); `DOSSIER_STRICT=1` falha em lacuna.
|
||||
|
||||
**Testes / CI / docs**
|
||||
|
||||
- Novos: `tests/unit/institutionCatalog.unit.spec.ts`, `dossieInstitutionResearch.unit.spec.ts`, `dossieInstitutionBlocks.unit.spec.ts`, `dossieInstitutionRender.unit.spec.ts`, `dossieInstitutionBulletin.unit.spec.ts`, `dossieInstitutionBatchSkill.unit.spec.ts`, `tests/int/institutionSnapshot.int.spec.ts`.
|
||||
- Editar: `tests/unit/opencodeCommands.unit.spec.ts` (incluir `'dossie-solla-instituicao'`); `scripts/lib/test-affected-core.mjs` (`SCRIPTS_SPEC_PINNED` += `dossieUnit.mjs`, `buildPdf.mjs`, `readOnlyExtract.mjs`, `institutionSnapshot.mjs`; `ciSkipInvariants` recalcula o closure e falha até casar); `.gitignore` (`/data/dossie-solla-instituicao/`, `/docs/research/dossie-solla-instituicao/`); `docs/changelog/2026-09-17-c187.md`.
|
||||
- **Manter verdes:** specs C186 (`dossie*`) e C163 (`cityReport*`), provando os defaults aditivos.
|
||||
|
||||
### Dados → forma (pergunta 3 — data-presentation)
|
||||
|
||||
- **Dossiê — forma escolhida:** **tabelas/listas com fonte por linha** + badge de abrangência + badge de fase (tabela Objeto/Valor/Ano/Fase/Abrangência/Fonte; painel de abrangência com **3 listas e nenhum total combinado**; timeline de vínculo na página 1 + timeline de carreira reusada em página própria; tabela de lacunas). É a forma mais pobre que ainda desbloqueia a decisão (escolher ângulo/entrega e saber o que falta), com a abrangência visível em cada linha. **Rejeitadas:** dashboard/KPIs agregados (perdem a fonte item a item e convidam a somar); gráfico de série (não há série comparável entre eras/entes); mapa (sem geografia no escopo); % estadual absoluto (anti-goal).
|
||||
- **Boletim — forma escolhida:** **número destacado + rótulo curto** (≤6 `.highlight-card`) + **lista densa ✓** (≤14 "E mais") + timeline de 4 passos; densidade > destaque grande. **Rejeitadas:** tabela/planilha (documento de gestor, não de eleitor); parágrafo longo (estoura 1 página); fontes no boletim (vivem só no dossiê); CTA de campanha (defeso).
|
||||
|
||||
## Decisões de engenharia (A–H)
|
||||
|
||||
**A. Seam de unidade.** Opções: A) `if` de unidade espalhado nos libs do C186; B) libs institucionais novas; C) descritor `dossieUnit.mjs` + generalização aditiva + módulo fino de conteúdo. **Recomendação: C** — um só dono por concern, C186 verde por default, vocabulário institucional isolado. **Rejeitadas:** B (twin do normalizador/render/ledger); A (união estrutural região×abrangência em 5 libs).
|
||||
|
||||
**B. Local do catálogo.** Opções: A) `src/lib/institutionCatalog.ts` + `src/lib/institutionNameAliases.ts`; B) `scripts/lib/*.mjs`; C) coleção Payload `Institution` + migration/seed. **Recomendação: A** — segue o precedente (`municipalityCatalog`/`municipalityNameAliases`), é importável pelo tsx dos scripts e testável nos specs TS. **Rejeitadas:** B (segundo lugar/idioma para o mesmo conceito); C (DB/migration/segundo cadastro para insumo interno sem URL pública — appetite e invariante).
|
||||
|
||||
**C. Extração read-only do acervo.** Opções: A) modo instituição em `composeCityReportSnapshot`/`extract-city-report-snapshot.mjs`; B) `institutionSnapshot.mjs` + `extract-institution-snapshot.mjs` reusando a plumbing read-only e os loaders de fala com recorte por tema; C) sem extração DB. **Recomendação: B** — o acervo + mapa tema→instituição é fonte declarada do produto, e o recorte não é município. **Rejeitadas:** A (reabre o contrato do snapshot C163 pinado no int spec); C (descarta fonte-chave e contraria a intenção).
|
||||
|
||||
**D. Fontes oficiais em tempo de build.** Opções: A) reusar `fetchAuthorEmendas`/`fetchCamaraActivity`/`fetchHealthData`; B) não buscar em build — emendas/proposições institucionais vêm dos itens de pesquisa com fonte; C) filtrar Câmara por nome da instituição na ementa. **Recomendação: B** — o Portal não filtra por instituição, a Câmara tampouco, e `fetchAuthorEmendas` exige município (imprimiria emenda municipal em dossiê institucional). "Sem atribuição → lacuna, nunca zero silencioso". **Rejeitadas:** A (atribuição errada); C (heurística de match nominal = falso positivo/omissão).
|
||||
|
||||
**E. Builders e plumbing de PDF.** Opções: A) builder único genérico com `--unit=`; B) `build-dossie-solla-instituicao.mjs` novo + extrair `buildPdf.mjs` (fit-guard + emit) e migrar o C186; C) copiar a plumbing do builder C186. **Recomendação: B** — este é o 3º builder CLI, exatamente o gatilho registrado na review do C186; um dono do "emitir A4 com guarda". C163 fica intocado (fora do escopo deste item). **Rejeitadas:** A (flag gêmea, duas identidades/saídas num entry só); C (twin da plumbing Chromium/orçamento/fit-guard).
|
||||
|
||||
**F. Herança de fatos pelo boletim.** Opções: A) derivar de `bulletinFacts` (ledger de fato com fonte) na mesma execução; B) segunda pesquisa. **Recomendação: A** — garantia estrutural: `buildBulletin` institucional recebe apenas o ledger; o renderer não vê research/snapshot crus. **Rejeitadas:** B (reintroduz síntese sem lastro).
|
||||
|
||||
**G. Sem truncamento/descarte silencioso (C188 no dono).** Opções: A) declarar "e mais N" no bloco compartilhado quando um cap corta, e a reformulação fica no `summary`; B) paginador de conteúdo completo além das lacunas; C) truncar. **Recomendação: A** — a lista capada é seleção de página, e o resto é **declarado** (`omitted`), nunca sumido; aplica-se ao dono (ambos os recortes), não só à instituição. **Rejeitadas:** B (fora do appetite; C188 dono da regra); C (viola o aceite).
|
||||
|
||||
**H. Campo do item para abrangência.** Opções: A) manter `sphere` genérico com enum da unidade (`municipio|regiao|polo` × `instituicao|setor|rede`); B) novo campo `breadth` só para instituição. **Recomendação: A** — um campo genérico, C186 (`item.sphere === 'municipio'`, pinado) verde; o descritor fornece enum/labels/classe. Ressalva documentada: o `sphere`/`scope` do **catálogo** (esfera/alcance do ente) é outro objeto e não deve ser confundido com `sphere` do **item** (abrangência). **Rejeitadas:** B (dois nomes para o mesmo conceito; ramificação no render/blocks).
|
||||
|
||||
## Fases verificáveis (tracer bullet first)
|
||||
|
||||
1. **Tracer (seam + contrato + conteúdo/render, sem extractor) — quota de meio dia.** `dossieUnit.mjs`; generalização aditiva de `dossieResearch`/`dossieBlocks`/`dossieRender`; módulo institucional com capa + resumo + Era C + abrangência + fontes; `buildPdf.mjs` extraído e C186 migrado; specs C186 verdes e `dossieInstitution{Research,Blocks,Render}` verdes. Prova o encadeamento mais arriscado cedo.
|
||||
2. **Catálogo + pipeline agêntico.** `institutionCatalog.ts`/`institutionNameAliases.ts` + spec; `SKILL.md` + command + subagente; `dossieInstitutionBatchSkill.unit.spec.ts` (lote/resolução/fail-closed/escape/dedup/fan-out/guarda); `opencodeCommands.unit.spec.ts` atualizado; `test-affected-core.mjs` atualizado.
|
||||
3. **Extração + builder.** `readOnlyExtract.mjs` (+ edição aditiva do extrator C163), `institutionSnapshot.mjs`, `extract-institution-snapshot.mjs`, `build-dossie-solla-instituicao.mjs`; `tests/int/institutionSnapshot.int.spec.ts`; decisão D exercida (sem emendas/Câmara/saúde em build).
|
||||
4. **Boletim + port visual + docs.** `buildBulletin`/`dossieBulletinRender` institucionais (completo + variação de poucos fatos); port classe a classe das 9 cenas do dossiê e das 2 do boletim; guardas de fit (`[data-page="resumo"]`, `[data-page="boletim"]`); `.gitignore`; changelog.
|
||||
5. **Gates.** `pnpm gate:fast` (lint 0 warnings, `tsc --noEmit`, unit) → build local de **UFBA** e de uma instituição esparsa (Enfermagem) conferindo PDF/MD/boletim (aceite manual) → `pnpm push` (PR CI roda o resto). Sem e2e (não há runtime no app); `tests/int` só o snapshot novo.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Forkar o pipeline institucional (research/render/ledger/PDF) — twin proibido; tudo passa pelo dono + descritor.
|
||||
- Estender o snapshot C163/`assertSnapshotShape` ou editar `build-city-report.mjs` (só a extração aditiva de `readOnlyExtract` no extrator, sem mudança de comportamento).
|
||||
- Puxar emendas por município / Câmara / IBGE–DATASUS para instituição (decisão D); "completar" com % estadual absoluto; somar `setor`/`rede` à instituição.
|
||||
- Coleção Payload/migration/segundo cadastro; UI in-app no `/campanha`; índice/PDF agregado de lote.
|
||||
- Reconstruir a carreira pré-2011 fora do acervo — vira **lacuna**, não invenção.
|
||||
- Reabrir o design: os hi-fi estão aprovados — só port; se faltar superfície, aciona-se o `designer` (não se redesenha na implementação).
|
||||
- `--unit` num builder único; migrar o builder C163 para `buildPdf.mjs` agora (fora do escopo).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regressão C186/C163 pela generalização:** defaults preservam assinaturas/erros; specs `dossie*`/`cityReport*` no `gate:fast`; build local do C186 conferido após migrar `buildPdf.mjs`.
|
||||
- **`SCRIPTS_SPEC_PINNED` fora de sincronia:** `ciSkipInvariants` recalcula o closure e falha fechado; atualizar o mapa na mesma fase que cria o módulo.
|
||||
- **Fit/overflow de página:** orçamentos explícitos e caps; se estourar, **aperta o teto/reformula copy — nunca espreme o layout**; "e mais N" onde cortar.
|
||||
- **Recorte por tema impreciso (acervo):** `topics` por entrada do catálogo (revisável), cap por tema + "e mais N", e o SKILL declara o limite (acervo cobre 2011+).
|
||||
- **Autoria de emenda ambígua:** sem atribuição/fonte → lacuna; nunca imprimir emenda municipal como institucional.
|
||||
- **Fan-out/gitignore/PII:** `MAX_RESEARCHERS_IN_FLIGHT = 6` + extração serializada; artefatos gitignored; subagente proibido de commitar; telefone/e-mail nunca entram.
|
||||
- **Appetite:** cortes já embutidos — sem APIs oficiais em build (D), sem health, sem agregado de lote, C163 intocado.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: dossiê PDF A4 + `.md` (identificação, eras A/B/C, abrangência 3-listas, títulos/vínculos, lacunas, fontes, defeso) **e** boletim modelo 1×A4 (≤6 destaques + ≤14 "E mais", sem fontes, sem CTA, rótulo "Modelo — insumo interno" + defeso, `NEEDS ASSET`).
|
||||
- [ ] Catálogo fail-closed acionável + escape `--slug/--name` + dedup; eras A/B/C reusadas; abrangência `instituicao|setor|rede` **nunca somada**; fases por valor (empenho ≠ pago); "sem fonte, não publica"; PII mínima; artefato gitignored.
|
||||
- [ ] Boletim herda **apenas** `bulletinFacts` (fato com fonte do dossiê); sem linha de fontes; sem CTA.
|
||||
- [ ] Sem truncamento mecânico/descarte silencioso: lista capada declara "e mais N"; reformulação no `summary`.
|
||||
- [ ] Generalização **aditiva** do dono; specs C186 e C163 verdes; `/dossie-solla-cidade` e `/relatorio-cidade` sem mudança de comportamento; `buildPdf.mjs` é dono único do emit A4 (C186 migrado).
|
||||
- [ ] `test-affected-core.mjs` sincronizado (closure exato) e `opencodeCommands.unit.spec.ts` com `dossie-solla-instituicao`; command/subagente acoplados pelo nome exato.
|
||||
- [ ] Unit novos verdes: `institutionCatalog`, `dossieInstitutionResearch`, `dossieInstitutionBlocks`, `dossieInstitutionRender`, `dossieInstitutionBulletin`, `dossieInstitutionBatchSkill`; int `institutionSnapshot`.
|
||||
- [ ] `.gitignore` + changelog `docs/changelog/2026-09-17-c187.md`; `pnpm gate:fast` verde; build local aceito em UFBA + Enfermagem; `pnpm push`.
|
||||
|
||||
---
|
||||
|
||||
## Self-score decision-quality (gate ≥4/5)
|
||||
|
||||
1. **Decisões caras têm rejeitadas?** Sim — A–H com Opções/Recomendação/Rejeitadas explícitas (seam, catálogo, extração, APIs, builders/PDF, herança, truncamento, campo de abrangência). — **5/5**
|
||||
2. **Abordagem cabe no appetite (3–4 dias)?** Sim — reuso máximo do dono C186, sem schema/DB, e cortes declarados (sem emendas/Câmara/IBGE em build, sem agregado, C163 intocado). — **4/5** (a generalização aditiva de 5 libs + o novo composer é o trecho que mais consome quota; por isso o tracer cedo e o corte de APIs).
|
||||
3. **Rabbit holes nomeados?** Sim — fork do pipeline, extensão do snapshot C163, emenda municipal em dossiê institucional, match nominal da Câmara, coleção Payload, UI in-app, agregado, pré-2011 inventado, redesign. — **5/5**
|
||||
4. **Depth check: reusa shells/helpers existentes?** Sim — `dossieCareer`, `dossieResearch/Blocks/Render/Bulletin/Render`, `cityReportFormat`, `reportText`, `cli`, `cityReportDatabase`/`buildSpeechListWhere`/`loadSegmentsForSpeeches`, `normalizeSearchPhrase`, e extrai `buildPdf.mjs`/`readOnlyExtract.mjs` onde há ≥2 consumidores. — **4/5** (a generalização toca módulos em uso; mitigada por defaults + specs).
|
||||
5. **Intenção (aceite de produto) permanece satisfeita?** Sim — os dois artefatos, o catálogo fail-closed, os literais (1 página, ≤6/≤14, sem fontes, rótulo de modelo, abrangência não somável) e a regra C188 estão no aceite; a engenharia não reescreveu o outcome. — **5/5**
|
||||
|
||||
**Total 23/25 (média 4,6/5) — passa o gate ≥4/5.**
|
||||
|
||||
## Simplificação e débitos (Passo 5–6 do `work-issue`)
|
||||
|
||||
**Já resolvido no simplify (não reabrir):** bug de string `"iten"` no `moreNote`
|
||||
(mapa de singular + testes de era vazia e `omitted > 0`); `abrangencia` → `reach`
|
||||
(valor `data-page="abrangencia"` mantido); campos mortos do descritor removidos;
|
||||
`resolveDossierUnit` fail-closed; exports órfãos desexportados e
|
||||
`emitHtmlPairPdf` como dono único do emit (C186 migrado); `speeches.rows`
|
||||
consumido como evidência com fonte (`acervo` no dossiê + ledger do boletim);
|
||||
`isInstitutionUnit` centralizando o dispatch; caps em constantes nomeadas.
|
||||
|
||||
**Adiado com gatilho (não é Issue — DRY/polish em `scripts/**`, 2 call sites):\*\*
|
||||
|
||||
- Boilerplate gêmeo dos dois builders (`readJson`, `readEraResearch`, mkdir/write,
|
||||
`DOSSIER_STRICT`) — gatilho: 4º builder CLI ou edição simultânea; a parte
|
||||
valiosa (emit/fit-guard) já saiu para `buildPdf.mjs`.
|
||||
- Mecânica copiada no branch institucional de `dossieBlocks.mjs`
|
||||
(`researchActionsByEra`, comparador, `bulletinFacts`, `gaps`/`pending`) —
|
||||
gatilho: 3º unit/recorte ou próxima edição dessas mecânicas.
|
||||
- `instHeader`/`instFooter` × `sheetHeader`/`sheetFooter`; fase→classe CSS em dois
|
||||
renderers; typedef `DossierReport` frouxo (`checkJs` off); specs institucionais
|
||||
~90% cópia do C186; guarda de PII do int spec por `not.toContain('@')`.
|
||||
|
||||
**Explicitamente fora (descartado):** renomear `sphere` do item para `breadth`
|
||||
(opção B rejeitada na decisão H; documentado no `dossieUnit.mjs`); simetria
|
||||
`unit` no report municipal; `meta.municipality`→`subjectName` no boletim.
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
171
scripts/build-dossie-solla-instituicao.mjs
Normal file
171
scripts/build-dossie-solla-instituicao.mjs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* Dossiê + boletim builder (C187, institution unit): institution snapshot +
|
||||
* per-era research → dossiê PDF A4 + `.md` and the one-page boletim PDF.
|
||||
*
|
||||
* Runs on the workstation: no database access, no persistence. Official
|
||||
* sources are NOT fetched here — the Transparency Portal does not filter by
|
||||
* institution and the Câmara does not either, so institutional emendas and
|
||||
* propositions come from the per-era research items (with phase + source);
|
||||
* anything unattributed becomes an explicit gap, never a silent zero.
|
||||
*
|
||||
* Usage:
|
||||
* NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-instituicao.mjs \
|
||||
* --snapshot=data/dossie-solla-instituicao/<slug>.institution.snapshot.json \
|
||||
* --research-dir=data/dossie-solla-instituicao \
|
||||
* --out-dir=docs/research/dossie-solla-instituicao
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { emitHtmlPairPdf, launchPdfBrowser } from './lib/buildPdf.mjs'
|
||||
import { dieWithLabel, isTruthyEnv, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
|
||||
import { buildDossierReport } from './lib/dossieBlocks.mjs'
|
||||
import { buildBulletin } from './lib/dossieBulletin.mjs'
|
||||
import { renderBulletinHtml } from './lib/dossieBulletinRender.mjs'
|
||||
import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs'
|
||||
import { renderDossierHtml, renderDossierMd } from './lib/dossieRender.mjs'
|
||||
import { mergeDossierResearch, normalizeDossierResearchInput } from './lib/dossieResearch.mjs'
|
||||
import { INSTITUTION_UNIT } from './lib/dossieUnit.mjs'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const LABEL = 'build-dossie-solla-instituicao'
|
||||
const die = dieWithLabel(LABEL)
|
||||
const DEFAULT_OUT_DIR = 'docs/research/dossie-solla-instituicao'
|
||||
const CACHE_DIR = 'data/dossie-solla-instituicao'
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const { flags } = parseEqualsFlags(process.argv.slice(2))
|
||||
const snapshotPath = typeof flags.snapshot === 'string' ? flags.snapshot : null
|
||||
const researchDir = typeof flags['research-dir'] === 'string' ? flags['research-dir'] : CACHE_DIR
|
||||
const outDir = typeof flags['out-dir'] === 'string' ? flags['out-dir'] : DEFAULT_OUT_DIR
|
||||
const generatedAt =
|
||||
typeof flags['generated-at'] === 'string' ? new Date(flags['generated-at']) : new Date()
|
||||
|
||||
if (!snapshotPath) {
|
||||
die('Uso: --snapshot=<json> [--research-dir=<dir>] [--out-dir=<dir>] [--generated-at=<ISO>]')
|
||||
}
|
||||
if (Number.isNaN(generatedAt.getTime())) die(`--generated-at inválida: ${flags['generated-at']}`)
|
||||
|
||||
const readJson = async (relativePath, label) => {
|
||||
try {
|
||||
return JSON.parse(await readFile(resolve(ROOT, relativePath), 'utf8'))
|
||||
} catch (error) {
|
||||
die(
|
||||
`Falha ao ler ${label} (${relativePath}): ${error instanceof Error ? error.message : error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = await readJson(snapshotPath, 'snapshot')
|
||||
const slug = snapshot.institution?.slug
|
||||
if (!slug) die('Snapshot sem "institution.slug" — extração inválida.')
|
||||
|
||||
const readEraResearch = async (era) => {
|
||||
const path = join(researchDir, `${slug}.${era.toLowerCase()}.research.json`)
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8'))
|
||||
if (raw.institutionSlug !== slug) {
|
||||
die(
|
||||
`Pesquisa da Era ${era} é de "${raw.institutionSlug}", não de "${slug}" (${path}) — pare e regenere.`,
|
||||
)
|
||||
}
|
||||
if (raw.era !== era) {
|
||||
die(`Pesquisa em ${path} declara Era ${raw.era}, não Era ${era} — pare e regenere.`)
|
||||
}
|
||||
return normalizeDossierResearchInput(raw, { unit: INSTITUTION_UNIT })
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
console.log(`[${LABEL}] Era ${era}: sem pesquisa (${path}) — vira lacuna explícita.`)
|
||||
return normalizeDossierResearchInput(
|
||||
{
|
||||
institutionSlug: slug,
|
||||
era,
|
||||
researchedAt: generatedAt.toISOString(),
|
||||
items: [],
|
||||
},
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
)
|
||||
}
|
||||
die(
|
||||
`Pesquisa da Era ${era} inválida (${path}): ${error instanceof Error ? error.message : error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const researches = []
|
||||
for (const era of DOSSIER_ERA_IDS) researches.push(await readEraResearch(era))
|
||||
const research = mergeDossierResearch(researches, { unit: INSTITUTION_UNIT })
|
||||
|
||||
const currentCodeSha = (() => {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (snapshot.meta?.codeSha && currentCodeSha && snapshot.meta.codeSha !== currentCodeSha) {
|
||||
console.warn(
|
||||
`[${LABEL}] AVISO: snapshot extraído no SHA ${snapshot.meta.codeSha}, builder no ${currentCodeSha} — regenere se o extrator mudou.`,
|
||||
)
|
||||
}
|
||||
|
||||
const report = buildDossierReport({
|
||||
snapshot,
|
||||
research,
|
||||
generatedAt,
|
||||
unit: INSTITUTION_UNIT,
|
||||
})
|
||||
const bulletin = buildBulletin({
|
||||
facts: report.bulletinFacts,
|
||||
identity: { name: report.meta.subjectName, badges: report.meta.identityBadges },
|
||||
unit: INSTITUTION_UNIT,
|
||||
generatedAt,
|
||||
})
|
||||
|
||||
const dossierHtml = renderDossierHtml(report)
|
||||
const dossierMd = renderDossierMd(report)
|
||||
const bulletinHtml = renderBulletinHtml(bulletin)
|
||||
|
||||
const baseName = `${slug}-${generatedAt.toISOString().slice(0, 10)}`
|
||||
|
||||
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
|
||||
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml)
|
||||
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml)
|
||||
|
||||
await mkdir(resolve(ROOT, outDir), { recursive: true })
|
||||
const dossierMdFile = join(outDir, `${baseName}-dossie.md`)
|
||||
const dossierPdfFile = join(outDir, `${baseName}-dossie.pdf`)
|
||||
const bulletinPdfFile = join(outDir, `${baseName}-boletim.pdf`)
|
||||
await writeFile(resolve(ROOT, dossierMdFile), dossierMd)
|
||||
console.log(`[${LABEL}] companion → ${dossierMdFile}`)
|
||||
|
||||
const browser = await launchPdfBrowser()
|
||||
try {
|
||||
await emitHtmlPairPdf(browser, {
|
||||
dossierHtml,
|
||||
bulletinHtml,
|
||||
dossierPdf: resolve(ROOT, dossierPdfFile),
|
||||
bulletinPdf: resolve(ROOT, bulletinPdfFile),
|
||||
})
|
||||
} catch (error) {
|
||||
die(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
console.log(`[${LABEL}] PDF → ${dossierPdfFile}`)
|
||||
console.log(`[${LABEL}] boletim PDF → ${bulletinPdfFile}`)
|
||||
console.log(
|
||||
`[${LABEL}] ${report.meta.subjectName}: eras=${report.eras.length} entregas=${report.page1.deliveries.items.length} ` +
|
||||
`highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} html_bytes=${Buffer.byteLength(dossierHtml)}`,
|
||||
)
|
||||
|
||||
if (isTruthyEnv(process.env.DOSSIER_STRICT) && research.gaps.length > 0) {
|
||||
die(
|
||||
`DOSSIER_STRICT=1 e ${research.gaps.length} lacuna(s) de pesquisa — o dossiê saiu, mas o modo estrito falha.`,
|
||||
)
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
157
scripts/extract-institution-snapshot.mjs
Normal file
157
scripts/extract-institution-snapshot.mjs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Snapshot extractor for the institutional dossiê (C187).
|
||||
*
|
||||
* Runs where production lives (homeserver), read-only by connection options,
|
||||
* and writes a JSON snapshot consumed by
|
||||
* `scripts/build-dossie-solla-instituicao.mjs`. Nothing is persisted.
|
||||
*
|
||||
* The token is resolved fail-closed against the catalog; a one-off institution
|
||||
* uses the explicit escape `--slug=<x> --name="<Nome>"`.
|
||||
*
|
||||
* Usage (from the homeserver, with `~/stack/teqo-1313.env` loaded):
|
||||
* INSTITUTION_REPORT_CONFIRM=1 NODE_OPTIONS="--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs" \
|
||||
* node scripts/extract-institution-snapshot.mjs --institution=ufba --out=data/dossie-solla-instituicao/ufba.institution.snapshot.json
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
getInstitutionCatalogEntry,
|
||||
INSTITUTION_KIND_LABELS,
|
||||
INSTITUTION_KINDS,
|
||||
INSTITUTION_SCOPE_LABELS,
|
||||
INSTITUTION_SCOPES,
|
||||
INSTITUTION_SPHERE_LABELS,
|
||||
INSTITUTION_SPHERES,
|
||||
institutionSlugs,
|
||||
} from '../src/lib/institutionCatalog.ts'
|
||||
import { databaseLabel } from './lib/cityReportDatabase.mjs'
|
||||
import { dieWithLabel, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
|
||||
import { enterReadOnlySession, resolveCampaignActor } from './lib/readOnlyExtract.mjs'
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const LABEL = 'extract-institution-snapshot'
|
||||
const die = dieWithLabel(LABEL)
|
||||
|
||||
const codeSha = () => {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const oneOf = (value, allowed, fallback) => {
|
||||
if (value === undefined || value === null || value === '') return fallback
|
||||
const normalized = String(value).trim()
|
||||
return allowed.includes(normalized) ? normalized : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the identity: catalog entry, or the explicit one-off escape. Unknown
|
||||
* token fails closed with an actionable message — never an invented slug.
|
||||
*/
|
||||
const resolveIdentity = (flags) => {
|
||||
if (typeof flags.institution === 'string' && flags.institution.trim()) {
|
||||
const entry = getInstitutionCatalogEntry(flags.institution.trim())
|
||||
if (!entry) {
|
||||
die(
|
||||
`Instituição desconhecida no catálogo: "${flags.institution}".\n` +
|
||||
` Conhecidas: ${institutionSlugs().join(', ')}.\n` +
|
||||
' Adicione alias/entrada em src/lib/institutionCatalog.ts ou use o escape one-off: --slug=<x> --name="<Nome>".',
|
||||
)
|
||||
}
|
||||
return {
|
||||
slug: entry.slug,
|
||||
name: entry.name,
|
||||
kind: entry.kind,
|
||||
sphere: entry.sphere,
|
||||
scope: entry.scope,
|
||||
aliases: [...entry.aliases],
|
||||
topics: [...entry.topics],
|
||||
}
|
||||
}
|
||||
|
||||
const slug = typeof flags.slug === 'string' ? flags.slug.trim() : ''
|
||||
const name = typeof flags.name === 'string' ? flags.name.trim() : ''
|
||||
if (!slug || !name) {
|
||||
die(
|
||||
'Uso: --institution=<slug> --out=<arquivo.json>\n' +
|
||||
' Ou escape one-off: --slug=<x> --name="<Nome>" [--kind= --sphere= --scope= --topics=a,b] --out=<arquivo.json>\n' +
|
||||
' Env: INSTITUTION_REPORT_CONFIRM=1 (obrigatório), DATABASE_URL (read-only é forçado).',
|
||||
)
|
||||
}
|
||||
const topics =
|
||||
typeof flags.topics === 'string'
|
||||
? flags.topics
|
||||
.split(',')
|
||||
.map((topic) => topic.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
return {
|
||||
slug,
|
||||
name,
|
||||
kind: oneOf(flags.kind, INSTITUTION_KINDS, 'outro'),
|
||||
sphere: oneOf(flags.sphere, INSTITUTION_SPHERES, 'nao_governamental'),
|
||||
scope: oneOf(flags.scope, INSTITUTION_SCOPES, 'nacional'),
|
||||
aliases: [],
|
||||
topics,
|
||||
}
|
||||
}
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const { flags } = parseEqualsFlags(process.argv.slice(2))
|
||||
const out = typeof flags.out === 'string' ? flags.out : null
|
||||
const identity = resolveIdentity(flags)
|
||||
|
||||
if (!out) {
|
||||
die('Uso: --institution=<slug> --out=<arquivo.json> (ou o escape --slug/--name).')
|
||||
}
|
||||
|
||||
const rawDatabaseUrl = enterReadOnlySession({
|
||||
confirmEnv: 'INSTITUTION_REPORT_CONFIRM',
|
||||
label: LABEL,
|
||||
die,
|
||||
context: `instituição ${identity.slug}`,
|
||||
})
|
||||
|
||||
const { default: configPromise } = await import('../src/payload.config.ts')
|
||||
const { getPayload } = await import('payload')
|
||||
const { composeInstitutionSnapshot } = await import('./institutionSnapshot.mjs')
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
try {
|
||||
const actor = await resolveCampaignActor(payload, { die })
|
||||
console.log(`[${LABEL}] ator: ${actor.name ?? actor.id} (${actor.role})`)
|
||||
|
||||
const snapshot = await composeInstitutionSnapshot({
|
||||
payload,
|
||||
actor,
|
||||
identity: {
|
||||
...identity,
|
||||
kindLabel: INSTITUTION_KIND_LABELS[identity.kind],
|
||||
sphereLabel: INSTITUTION_SPHERE_LABELS[identity.sphere],
|
||||
scopeLabel: INSTITUTION_SCOPE_LABELS[identity.scope],
|
||||
},
|
||||
readAt: new Date().toISOString(),
|
||||
codeSha: codeSha(),
|
||||
database: databaseLabel(rawDatabaseUrl),
|
||||
})
|
||||
|
||||
const outPath = resolve(ROOT, out)
|
||||
await mkdir(dirname(outPath), { recursive: true })
|
||||
await writeFile(outPath, `${JSON.stringify(snapshot, null, 2)}\n`)
|
||||
console.log(
|
||||
`[${LABEL}] snapshot de ${snapshot.institution.name} → ${out} ` +
|
||||
`(falas=${snapshot.speeches.totalCount} temas=${snapshot.speeches.topics.join(',') || '—'} lacunas=${snapshot.gaps.length})`,
|
||||
)
|
||||
} catch (error) {
|
||||
die(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
145
scripts/institutionSnapshot.mjs
Normal file
145
scripts/institutionSnapshot.mjs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Snapshot composer for the institutional dossiê (C187).
|
||||
*
|
||||
* Runs on the homeserver against production, read-only: the institutional
|
||||
* recorte does not exist as a relationship on `speech`, so the bridge is
|
||||
* theme→institution (`identity.topics`, from the catalog). Reuses the C163
|
||||
* speech loaders (no parallel source of truth), projects out PII and returns
|
||||
* plain JSON. The render step never touches the database again.
|
||||
*
|
||||
* Integration layer, not a pure lib: it imports Payload-coupled utilities and
|
||||
* is exercised by `tests/int/institutionSnapshot.int.spec.ts`.
|
||||
*/
|
||||
|
||||
import { normalizeForSearch } from '../src/lib/speechSearch.ts'
|
||||
import { excerptOffsetSeconds } from '../src/lib/speechVod.ts'
|
||||
import { buildSpeechListWhere } from '../src/utilities/speech/speechListFilters.ts'
|
||||
import { loadSegmentsForSpeeches } from '../src/utilities/speech/speechPageData.ts'
|
||||
|
||||
const SPEECH_LIMIT = 15
|
||||
|
||||
const SPEECH_SELECT = {
|
||||
speechAt: true,
|
||||
type: true,
|
||||
phase: true,
|
||||
summary: true,
|
||||
officialTextUrl: true,
|
||||
youtubeUrl: true,
|
||||
vodPlaybackUrl: true,
|
||||
excerptTMs: true,
|
||||
eventStartAt: true,
|
||||
topics: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects one speech: the official summary says what it is; `mentionExcerpt`
|
||||
* is the passage that literally names the institution (null when the acervo tag
|
||||
* has no literal mention in the ASR segments).
|
||||
*/
|
||||
const projectSpeechRow = (doc, segments, matchNames) => {
|
||||
const candidates = matchNames
|
||||
.map((name) => ({ name, key: normalizeForSearch(name) }))
|
||||
.filter((candidate) => candidate.key)
|
||||
const matched = candidates.find(({ key }) =>
|
||||
segments.some((segment) => normalizeForSearch(segment.text).includes(key)),
|
||||
)
|
||||
const mentionSegment = matched
|
||||
? segments.find((segment) => normalizeForSearch(segment.text).includes(matched.key))
|
||||
: undefined
|
||||
return {
|
||||
id: doc.id,
|
||||
speechAt: doc.speechAt ?? null,
|
||||
type: doc.type ?? null,
|
||||
phase: doc.phase ?? null,
|
||||
summary: doc.summary ?? null,
|
||||
officialTextUrl: doc.officialTextUrl ?? null,
|
||||
youtubeUrl: doc.youtubeUrl ?? null,
|
||||
vodPlaybackUrl: doc.vodPlaybackUrl ?? null,
|
||||
youtubeExcerptStartSeconds: excerptOffsetSeconds(doc.excerptTMs, doc.eventStartAt),
|
||||
mentionExcerpt: mentionSegment?.text ?? null,
|
||||
matchedName: matched?.name ?? null,
|
||||
topics: Array.isArray(doc.topics) ? doc.topics : [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* payload: any,
|
||||
* actor: any,
|
||||
* identity: { slug: string, name: string, kind: string, kindLabel: string, sphere: string, sphereLabel: string, scope: string, scopeLabel: string, aliases: string[], topics: string[] },
|
||||
* readAt: string,
|
||||
* codeSha?: string | null,
|
||||
* database?: string | null,
|
||||
* }} params
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export const composeInstitutionSnapshot = async ({
|
||||
payload,
|
||||
actor,
|
||||
identity,
|
||||
readAt,
|
||||
codeSha = null,
|
||||
database = null,
|
||||
}) => {
|
||||
const topics = Array.isArray(identity.topics) ? identity.topics : []
|
||||
const matchNames = [identity.name, ...(identity.aliases ?? [])]
|
||||
|
||||
let docs = []
|
||||
let totalCount = 0
|
||||
const gaps = []
|
||||
if (topics.length === 0) {
|
||||
gaps.push({
|
||||
id: 'acervo_sem_tema',
|
||||
reason: 'A instituição não declara temas no catálogo — o recorte do acervo fica vazio.',
|
||||
})
|
||||
} else {
|
||||
const result = await payload.find({
|
||||
collection: 'speech',
|
||||
where: buildSpeechListWhere({ page: 1, topics }),
|
||||
depth: 0,
|
||||
limit: SPEECH_LIMIT,
|
||||
sort: '-speechAt',
|
||||
select: SPEECH_SELECT,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
docs = result.docs
|
||||
totalCount = result.totalDocs
|
||||
if (docs.length === 0) {
|
||||
gaps.push({
|
||||
id: 'acervo_sem_falas',
|
||||
reason: `Nenhuma fala do acervo nos temas ${topics.join(', ')} — o acervo cobre 2011+.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const segmentsBySpeech = await loadSegmentsForSpeeches(
|
||||
payload,
|
||||
actor,
|
||||
docs.map((doc) => doc.id),
|
||||
)
|
||||
|
||||
return {
|
||||
meta: { readAt, codeSha, database, kind: 'institution' },
|
||||
institution: {
|
||||
slug: identity.slug,
|
||||
name: identity.name,
|
||||
kind: identity.kind,
|
||||
kindLabel: identity.kindLabel,
|
||||
sphere: identity.sphere,
|
||||
sphereLabel: identity.sphereLabel,
|
||||
scope: identity.scope,
|
||||
scopeLabel: identity.scopeLabel,
|
||||
aliases: identity.aliases ?? [],
|
||||
topics,
|
||||
},
|
||||
speeches: {
|
||||
topics,
|
||||
totalCount,
|
||||
rows: docs.map((doc) =>
|
||||
projectSpeechRow(doc, segmentsBySpeech.get(doc.id) ?? [], matchNames),
|
||||
),
|
||||
},
|
||||
gaps,
|
||||
}
|
||||
}
|
||||
95
scripts/lib/buildPdf.mjs
Normal file
95
scripts/lib/buildPdf.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* A4 print plumbing for the report builders (owner: the C163/C186/C187
|
||||
* pipeline). Chromium comes from `@playwright/test`; every document is one
|
||||
* `.sheet` per page with a `data-page` anchor, and no page may be cut by the
|
||||
* overflow — if it does not fit, the content budget is reduced, the layout is
|
||||
* never squeezed.
|
||||
*
|
||||
* Extracted from `build-dossie-solla-cidade.mjs` when the institution builder
|
||||
* (C187) became the third consumer, so there is one owner of the mm→px budget,
|
||||
* the launch and the emit.
|
||||
*/
|
||||
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
const MM_TO_PX = 96 / 25.4
|
||||
export const A4_HEIGHT_PX = Math.round(297 * MM_TO_PX)
|
||||
export const A4_WIDTH_PX = Math.round(210 * MM_TO_PX)
|
||||
/** Rounding slack of the mm→px conversion; content must still fit the page. */
|
||||
export const PAGE_FIT_TOLERANCE_PX = 10
|
||||
export const A4_PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX
|
||||
|
||||
export const launchPdfBrowser = () => chromium.launch()
|
||||
|
||||
/** Every `[data-page]` taller than the budget, in document order. */
|
||||
const measurePageOverflows = (page, budgetPx = A4_PAGE_BUDGET_PX) =>
|
||||
page.evaluate(
|
||||
(max) =>
|
||||
[...document.querySelectorAll('[data-page]')]
|
||||
.map((element) => ({
|
||||
page: element.getAttribute('data-page'),
|
||||
height: element.scrollHeight,
|
||||
}))
|
||||
.filter((row) => row.height > max),
|
||||
budgetPx,
|
||||
)
|
||||
|
||||
/** Throws with the list of overflowing pages — callers turn it into `die`. */
|
||||
export const assertPageFits = (overflows, label, hint) => {
|
||||
if (overflows.length === 0) return
|
||||
throw new Error(
|
||||
`${label} estourou o A4 (${overflows
|
||||
.map((row) => `${row.page}: ${row.height}px`)
|
||||
.join(', ')} > ${A4_PAGE_BUDGET_PX}px úteis). ${hint}`,
|
||||
)
|
||||
}
|
||||
|
||||
const printA4Pdf = (page, outPath) =>
|
||||
page.pdf({
|
||||
path: outPath,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
margin: { top: '0', bottom: '0', left: '0', right: '0' },
|
||||
})
|
||||
|
||||
/**
|
||||
* One browser run, two HTML documents: sets each document, asserts its anchor
|
||||
* exists, guards the fit and emits the PDF. Shared by the C186 (city) and C187
|
||||
* (institution) builders — the pages differ, the emit contract does not.
|
||||
*/
|
||||
export const emitHtmlPairPdf = async (
|
||||
browser,
|
||||
{ dossierHtml, bulletinHtml, dossierPdf, bulletinPdf },
|
||||
) => {
|
||||
const page = await browser.newPage()
|
||||
await page.setViewportSize({ width: A4_WIDTH_PX, height: A4_HEIGHT_PX })
|
||||
|
||||
await page.setContent(dossierHtml, { waitUntil: 'load' })
|
||||
await page.emulateMedia({ media: 'print' })
|
||||
const hasSummary = await page.evaluate(() =>
|
||||
Boolean(document.querySelector('[data-page="resumo"]')),
|
||||
)
|
||||
if (!hasSummary)
|
||||
throw new Error('Página de resumo ausente no HTML do dossiê — renderer quebrado.')
|
||||
assertPageFits(
|
||||
await measurePageOverflows(page, A4_PAGE_BUDGET_PX),
|
||||
'Página(s) do dossiê',
|
||||
'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.',
|
||||
)
|
||||
await printA4Pdf(page, dossierPdf)
|
||||
|
||||
await page.setContent(bulletinHtml, { waitUntil: 'load' })
|
||||
await page.emulateMedia({ media: 'print' })
|
||||
const hasBulletin = await page.evaluate(() =>
|
||||
Boolean(document.querySelector('[data-page="boletim"]')),
|
||||
)
|
||||
if (!hasBulletin) throw new Error('Bloco do boletim ausente no HTML — renderer quebrado.')
|
||||
assertPageFits(
|
||||
await measurePageOverflows(page, A4_PAGE_BUDGET_PX),
|
||||
'O boletim',
|
||||
'Corte itens/caps — o boletim TEM de caber em uma página.',
|
||||
)
|
||||
await printA4Pdf(page, bulletinPdf)
|
||||
|
||||
await page.close()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
* as an internal model.
|
||||
*/
|
||||
|
||||
import { isInstitutionUnit } from './dossieUnit.mjs'
|
||||
import { htmlEscape } from './reportText.mjs'
|
||||
|
||||
const ASSET = (title, lines) =>
|
||||
|
|
@ -12,10 +13,22 @@ const ASSET = (title, lines) =>
|
|||
.map((line) => htmlEscape(line))
|
||||
.join('<br />')}</p></div>`
|
||||
|
||||
const renderHighlight = (highlight) => `
|
||||
const phaseModifier = (phase) =>
|
||||
phase === 'pago' || phase === 'liquidado'
|
||||
? 'phase-paid'
|
||||
: phase === 'empenhado' || phase === 'autorizado'
|
||||
? 'phase-pending'
|
||||
: ''
|
||||
|
||||
const renderHighlight = (highlight, { showPhase = false } = {}) => `
|
||||
<article class="highlight-card">
|
||||
<p class="highlight-eyebrow">${htmlEscape(highlight.eyebrow)}</p>
|
||||
${highlight.number ? `<p class="highlight-number tabular">${htmlEscape(highlight.number)}</p>` : ''}
|
||||
${
|
||||
showPhase && highlight.number && highlight.phaseLabel
|
||||
? `<p class="highlight-phase"><span class="phase ${phaseModifier(highlight.phase)}">${htmlEscape(highlight.phaseLabel)}</span></p>`
|
||||
: ''
|
||||
}
|
||||
<h3 class="highlight-title">${htmlEscape(highlight.title)}</h3>
|
||||
${highlight.note ? `<p class="highlight-note">${htmlEscape(highlight.note)}</p>` : ''}
|
||||
</article>`
|
||||
|
|
@ -101,7 +114,7 @@ const PRINT_CSS = `
|
|||
.bulletin-foot .stamp { margin: 0; text-align: right; font-size: 7.2pt; font-weight: 600; line-height: 1.3; color: #435264; }
|
||||
`
|
||||
|
||||
export const renderBulletinHtml = (bulletin) => `
|
||||
const renderMunicipalityBulletinHtml = (bulletin) => `
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.municipality)}</title><style>${PRINT_CSS}</style></head>
|
||||
|
|
@ -192,3 +205,165 @@ export const renderBulletinHtml = (bulletin) => `
|
|||
</article>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
/* Institution boletim (C187) — ports the institutional boletim hi-fi. */
|
||||
|
||||
const INSTITUTION_BULLETIN_CSS = `
|
||||
${PRINT_CSS}
|
||||
.defeso-band { margin-inline: -10mm; padding: 1.4mm 10mm 1.35mm; display: flex; align-items: center; justify-content: space-between; gap: 5mm; color: #68420f; background: #fff7e8; border-top: .25mm solid #e7cc9d; border-bottom: .25mm solid #e7cc9d; font-size: 7.15pt; line-height: 1.2; font-weight: 700; }
|
||||
.identity-pill { display: inline-flex; align-items: center; min-height: 5mm; padding: .55mm 1.7mm; border: .25mm solid #cbd5dc; border-radius: 99px; color: #435264; background: #f3f6f8; font-size: 6.9pt; line-height: 1; font-weight: 700; letter-spacing: .045em; text-transform: uppercase; }
|
||||
.bulletin-head .subject { margin: 0; font-size: 11pt; font-weight: 800; }
|
||||
.bulletin-head .pills { margin-top: 1.3mm; display: flex; flex-wrap: wrap; gap: 1.2mm; }
|
||||
.lacuna-panel { border: .4mm dashed #b18549; color: #684819; background: #fff7e8; }
|
||||
.lacuna-panel .lacuna-rule { margin-top: 2mm; border-top: .25mm solid #d8bb8d; padding-top: 2mm; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; line-height: 1.35; letter-spacing: .055em; }
|
||||
.production-note { border: .25mm solid #cbd5dc; background: #f3f6f8; border-radius: .125rem; padding: 3mm; }
|
||||
.opening { margin-top: 2.5mm; }
|
||||
.highlights { margin-top: 3mm; }
|
||||
.bulletin-timeline { margin-top: 2.5mm; }
|
||||
.more { margin-top: 2.5mm; }
|
||||
.empty-box { min-height: 30mm; }
|
||||
.lacuna-panel .warn-icon { width: 5mm; height: 5mm; flex-shrink: 0; margin-top: .4mm; color: #8a5a18; }
|
||||
.highlight-phase { margin: 1.2mm 0 0; }
|
||||
.phase { display: inline-block; padding: .5mm 1.4mm; border-radius: 1mm; background: #e9eef1; color: #334553; font-size: 7.3pt; font-weight: 700; text-transform: uppercase; letter-spacing: .035em; }
|
||||
.phase-paid { color: #285338; background: #e6f2e9; }
|
||||
.phase-pending { color: #6b4918; background: #fff2d9; }
|
||||
`
|
||||
|
||||
const INST_WARNING_ICON =
|
||||
'<svg class="warn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10.3 2.9 1.8 17a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 2.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/></svg>'
|
||||
|
||||
const renderInstitutionBulletinHtml = (bulletin) => {
|
||||
const sparse = Boolean(bulletin.sparse)
|
||||
const pills = (bulletin.meta.identityBadges ?? [])
|
||||
.map((badge) => `<span class="identity-pill">${htmlEscape(badge)}</span>`)
|
||||
.join('')
|
||||
const highlights = bulletin.highlights
|
||||
const more = bulletin.moreItems
|
||||
return `<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.subjectName)}</title><style>${INSTITUTION_BULLETIN_CSS}</style></head>
|
||||
<body>
|
||||
<article class="sheet" data-page="boletim" aria-label="Modelo de boletim informativo institucional de uma página">
|
||||
<header class="bulletin-head">
|
||||
<div>
|
||||
<p class="kicker">${htmlEscape(bulletin.meta.kicker)}</p>
|
||||
<p class="subject">${htmlEscape(bulletin.meta.subjectName)}</p>
|
||||
${pills ? `<div class="pills">${pills}</div>` : ''}
|
||||
</div>
|
||||
<span class="model-label">${htmlEscape(bulletin.meta.modelLabel)}</span>
|
||||
</header>
|
||||
|
||||
<div class="defeso-band" role="note" aria-label="Nota de defeso eleitoral">
|
||||
<span>DEFESO 2026 · material em preparação · não publicar sem revisão editorial e jurídica</span>
|
||||
<span style="text-transform: uppercase; letter-spacing: .07em">sem CTA · sem propaganda</span>
|
||||
</div>
|
||||
|
||||
<section class="opening" aria-label="Abertura do boletim">
|
||||
<div>
|
||||
<p class="section-label">${sparse ? 'Somente o que está documentado' : 'Uma trajetória de trabalho junto à instituição'}</p>
|
||||
<h1>O que Jorge Solla fez pela e na <span class="accent">${htmlEscape(bulletin.meta.subjectName)}</span></h1>
|
||||
<p class="lead">${
|
||||
sparse
|
||||
? 'Esta versão tem poucos registros confirmados. Em vez de preencher espaço, mostra só os fatos que o dossiê sustenta.'
|
||||
: 'Ações, recursos e articulações explicados de forma direta — somente o que já foi documentado no dossiê institucional.'
|
||||
}</p>
|
||||
</div>
|
||||
${ASSET(sparse ? 'NEEDS ASSET · foto ou clipping' : 'NEEDS ASSET · foto real', [
|
||||
sparse ? 'somente registro relacionado ao fato' : 'Solla na instituição ou na ação citada',
|
||||
'origem, autoria e licença',
|
||||
])}
|
||||
</section>
|
||||
|
||||
<section class="highlights" aria-label="Destaques">
|
||||
<div class="highlights-head">
|
||||
<div>
|
||||
<p class="section-label">Destaques</p>
|
||||
<h2>${sparse ? 'Poucos fatos, sem enchimento' : 'Resultados em leitura rápida'}</h2>
|
||||
</div>
|
||||
<p class="side">${sparse ? 'não criar cards vazios para chegar a seis' : 'cada número mantém sua fase e seu alcance'}</p>
|
||||
</div>
|
||||
${
|
||||
highlights.length
|
||||
? `<div class="highlight-grid" style="${sparse ? 'grid-template-columns: repeat(2, 1fr)' : ''}">${highlights.map((highlight) => renderHighlight(highlight, { showPhase: true })).join('')}</div>`
|
||||
: '<p class="empty">Sem fato com fonte suficiente para destacar — ver lacunas no dossiê.</p>'
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="bulletin-timeline" aria-label="Trajetória">
|
||||
<div class="timeline-head">
|
||||
<h2>Uma trajetória de compromisso com o serviço público</h2>
|
||||
<p class="timeline-side">${sparse ? 'contexto de carreira · não substitui fato institucional' : 'quatro momentos · linguagem direta'}</p>
|
||||
</div>
|
||||
<div class="timeline-grid">
|
||||
${bulletin.timeline
|
||||
.map(
|
||||
(step) => `<div class="timeline-step">
|
||||
<p class="timeline-period">${htmlEscape(step.period)}</p>
|
||||
<p class="timeline-text">${htmlEscape(step.text)}</p>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="more" aria-label="Outras ações">
|
||||
<div>
|
||||
<div class="more-head">
|
||||
<div>
|
||||
<p class="section-label">E mais</p>
|
||||
<h2>${sparse ? 'Outros registros confirmados' : 'Outras ações confirmadas'}</h2>
|
||||
</div>
|
||||
<p class="side">${sparse ? 'sem completar por inferência' : 'até 14 · sem completar por inferência'}</p>
|
||||
</div>
|
||||
${
|
||||
more.length
|
||||
? `<div class="more-grid">${more.map(renderMoreItem).join('')}</div>`
|
||||
: '<div class="empty-box"><p><strong class="uppercase">Sem itens adicionais com fonte além dos destaques.</strong></p><p>Conferir as lacunas explícitas no dossiê institucional antes de ampliar o boletim.</p></div>'
|
||||
}
|
||||
</div>
|
||||
${
|
||||
sparse
|
||||
? `<aside class="lacuna-panel" style="border-radius: .125rem; padding: 2.5mm" aria-label="Regra editorial para poucos fatos">
|
||||
${INST_WARNING_ICON}
|
||||
<p style="margin: 0; font-size: 10.5pt; font-weight: 800">A página termina com espaço — de propósito.</p>
|
||||
<p style="font-size: 8.7pt; line-height: 1.35">Não repetir item, não ampliar efeito e não preencher com ação de setor ou rede como se fosse entrega exclusiva da instituição.</p>
|
||||
<p class="lacuna-rule">Poucos fatos com lastro > página cheia sem lastro</p>
|
||||
</aside>`
|
||||
: `<aside class="more-aside" aria-label="Ativos visuais pendentes">
|
||||
${ASSET('NEEDS ASSET · selo', ['marca autorizada da instituição', 'arquivo vetorial'])}
|
||||
${ASSET('NEEDS ASSET · clipping', ['registro real da ação', 'origem e licença'])}
|
||||
</aside>`
|
||||
}
|
||||
</section>
|
||||
|
||||
${
|
||||
sparse
|
||||
? `<section style="margin-top: 3mm; display: grid; grid-template-columns: 1fr 48mm; gap: 4mm">
|
||||
<div class="production-note">
|
||||
<p class="section-label">Nota de produção</p>
|
||||
<p style="margin: 0; font-size: 9.2pt; line-height: 1.38; color: #435264">Se novos fatos forem documentados, eles entram por relevância — até seis destaques e até quatorze itens. A ausência nunca vira número zero, percentual ou afirmação genérica.</p>
|
||||
</div>
|
||||
${ASSET('NEEDS ASSET · selo', ['marca autorizada da instituição', 'arquivo vetorial'])}
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
<footer class="bulletin-foot">
|
||||
<div class="row">
|
||||
<div>
|
||||
<p class="orig">Conteúdo selecionado do dossiê institucional.</p>
|
||||
<p class="defeso"><strong>Controle editorial:</strong> fase acompanha cada valor · instituição, setor e rede não são somados · sem percentual estadual absoluto.</p>
|
||||
</div>
|
||||
<p class="stamp">MODELO · INSUMO INTERNO<br />A4 · página 1/1<br />${htmlEscape(bulletin.meta.generatedAtLabel)}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
/** Dispatch: the institution unit renders the institutional composition. */
|
||||
export const renderBulletinHtml = (bulletin) =>
|
||||
isInstitutionUnit(bulletin?.unit)
|
||||
? renderInstitutionBulletinHtml(bulletin)
|
||||
: renderMunicipalityBulletinHtml(bulletin)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
*/
|
||||
|
||||
import { formatDateBr, formatDateTimeBr } from './cityReportFormat.mjs'
|
||||
import { dossierPhaseLabel, dossierSphereLabel } from './dossieBlocks.mjs'
|
||||
import { dossierPhaseLabel, dossierSphereBadgeClass, dossierSphereLabel } from './dossieBlocks.mjs'
|
||||
import { MUNICIPALITY_UNIT, isInstitutionUnit } from './dossieUnit.mjs'
|
||||
import { htmlEscape, stripInlineSources } from './reportText.mjs'
|
||||
|
||||
const SOURCE_ICON =
|
||||
|
|
@ -20,8 +21,10 @@ const sourceLink = (url, label = '(fonte)') =>
|
|||
? `<a class="source-link" href="${htmlEscape(url)}" aria-label="Abrir fonte">${SOURCE_ICON}${htmlEscape(label)}</a>`
|
||||
: ''
|
||||
|
||||
const scopeBadge = (sphere, label = null) =>
|
||||
`<span class="scope${sphere === 'municipio' ? '' : ' scope-region'}">${htmlEscape(label ?? dossierSphereLabel(sphere))}</span>`
|
||||
const scopeBadge = (sphere, label = null, unit = MUNICIPALITY_UNIT) => {
|
||||
const badgeClass = dossierSphereBadgeClass(sphere, unit)
|
||||
return `<span class="scope${badgeClass ? ` ${badgeClass}` : ''}">${htmlEscape(label ?? dossierSphereLabel(sphere, unit))}</span>`
|
||||
}
|
||||
|
||||
/** `dd/mm/aa` — the news table column is 9% wide (no room for a 4-digit year). */
|
||||
const formatShortDateBr = (value) => {
|
||||
|
|
@ -40,7 +43,16 @@ const copyHtml = (value) => htmlEscape(stripInlineSources(value))
|
|||
const briefOr = (brief, fallback) => brief?.title ?? fallback
|
||||
const noteOr = (brief, fallback) => brief?.note ?? fallback
|
||||
|
||||
const phaseBadge = (phase) => `<span class="phase">${htmlEscape(dossierPhaseLabel(phase))}</span>`
|
||||
const PHASE_MODIFIER = {
|
||||
autorizado: 'phase-pending',
|
||||
empenhado: 'phase-pending',
|
||||
liquidado: 'phase-paid',
|
||||
pago: 'phase-paid',
|
||||
}
|
||||
const phaseBadge = (phase) => {
|
||||
const modifier = PHASE_MODIFIER[phase]
|
||||
return `<span class="phase${modifier ? ` ${modifier}` : ''}">${htmlEscape(dossierPhaseLabel(phase))}</span>`
|
||||
}
|
||||
|
||||
const placeholder = (label) =>
|
||||
`<span class="placeholder-bar" aria-label="${htmlEscape(label)}"></span>`
|
||||
|
|
@ -636,10 +648,11 @@ const PRINT_CSS = `
|
|||
.cover-foot-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 8mm; }
|
||||
.cover-scope { margin: 0; max-width: 118mm; font-size: 10.5pt; line-height: 1.4; color: #435264; }
|
||||
.cover-version { margin: 0; text-align: right; font-size: 8pt; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: #315c75; }
|
||||
.cover-accent { color: #315c75; }
|
||||
.defeso { margin-top: auto; }
|
||||
`
|
||||
|
||||
export const renderDossierHtml = (report) => {
|
||||
const renderMunicipalityDossierHtml = (report) => {
|
||||
const gapChunks = chunkGaps(report.gaps)
|
||||
const newsChunks = chunkNews(report.news)
|
||||
const eraSheets = report.eras.map((era) => ({ era, chunks: chunkEraActions(era) }))
|
||||
|
|
@ -701,7 +714,7 @@ const mdEra = (era) => {
|
|||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export const renderDossierMd = (report) => {
|
||||
const renderMunicipalityDossierMd = (report) => {
|
||||
const lines = [
|
||||
`# ${report.meta.title} — ${report.meta.municipalityName}`,
|
||||
'',
|
||||
|
|
@ -789,3 +802,791 @@ export const renderDossierMd = (report) => {
|
|||
)
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Institution renderer (C187) — ports the approved institutional *
|
||||
* hi-fi (`dossie-solla-instituicao-ui-design.html`) class-by-class; *
|
||||
* shares the print shell, source tokens and table CSS of the city *
|
||||
* renderer (one owner). *
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const INSTITUTION_PRINT_CSS = `
|
||||
${PRINT_CSS}
|
||||
.identity-badge { display: inline-flex; align-items: center; min-height: 5mm; padding: .45mm 1.6mm; border: .25mm solid #cbd5dc; border-radius: 99px; color: #435264; background: #f3f6f8; font-size: 7.4pt; line-height: 1; font-weight: 700; letter-spacing: .035em; text-transform: uppercase; }
|
||||
.scope-sector { border-color: #9d8b64; color: #65501f; background: #fffaf0; }
|
||||
.scope-network { border-color: #8f819c; color: #554362; background: #faf7fc; }
|
||||
.phase-paid { color: #285338; background: #e6f2e9; }
|
||||
.phase-pending { color: #6b4918; background: #fff2d9; }
|
||||
.empty-panel { border: .45mm dashed #b18549; color: #684819; background: #fff7e8; border-radius: .25rem; padding: 5mm 8mm; text-align: center; }
|
||||
.empty-panel .rule-title { margin-top: 1.5mm; }
|
||||
.empty-panel .warn-icon { width: 8mm; height: 8mm; margin: 0 auto; color: #8a5a18; }
|
||||
.timeline-item { position: relative; padding-left: 5mm; }
|
||||
.timeline-item::before { content: ''; position: absolute; top: 1.5mm; left: 0; width: 2.6mm; height: 2.6mm; border: .5mm solid #fff; border-radius: 99px; background: #315c75; box-shadow: 0 0 0 .35mm #315c75; }
|
||||
.id-card { border: .25mm solid #cbd5dc; border-radius: .25rem; background: #f3f6f8; padding: 3mm; }
|
||||
.id-card dl { display: grid; grid-template-columns: 22mm 1fr; gap: 1mm 2mm; font-size: 9pt; line-height: 1.3; }
|
||||
.id-card dt { font-weight: 600; color: #435264; }
|
||||
.id-card dd { margin: 0; }
|
||||
.summary-top { display: grid; grid-template-columns: 58mm 1fr; gap: 4mm; margin-top: 4mm; }
|
||||
.vinc-timeline { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2mm; border-top: .25mm solid #cbd5dc; padding-top: 3mm; margin-top: 3mm; }
|
||||
.vinc-period { margin: 0; font-size: 8pt; font-weight: 700; color: #315c75; }
|
||||
.vinc-text { margin: 1mm 0 0; font-size: 9pt; font-weight: 600; line-height: 1.25; }
|
||||
.vinc-source { margin: 1mm 0 0; font-size: 8pt; line-height: 1.2; color: #435264; }
|
||||
.scope-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3mm; margin-top: 4mm; }
|
||||
.scope-card { border: .25mm solid #cbd5dc; border-radius: .25rem; padding: 3mm; }
|
||||
.scope-card.card-sector { border-color: #c9b483; background: #fffcf6; }
|
||||
.scope-card.card-network { border-color: #b9acc3; background: #fcf9fd; }
|
||||
.scope-count { margin: 3mm 0 0; font-size: 16pt; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.scope-cards .tight-list { margin-top: 2mm; }
|
||||
.more-note { margin: 1.5mm 0 0; font-size: 8pt; font-weight: 600; color: #8a5a18; }
|
||||
`
|
||||
|
||||
const instHeader = (report, title, subtitle, pageNo) => `
|
||||
<header class="report-header">
|
||||
<div>
|
||||
<p class="report-kicker">Dossiê institucional · ${htmlEscape(report.meta.subjectName)}</p>
|
||||
<h2 class="sheet-title">${htmlEscape(title)}</h2>
|
||||
${subtitle ? `<p class="sheet-sub">${htmlEscape(subtitle)}</p>` : ''}
|
||||
</div>
|
||||
<div class="report-meta">
|
||||
<p><strong class="warning-text">INSUMO INTERNO — defeso 2026</strong></p>
|
||||
<p>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</p>
|
||||
<p>pág. ${pageNo}/${report.meta.pageTotal}</p>
|
||||
</div>
|
||||
</header>`
|
||||
|
||||
const instFooter = (report, left, pageNo) => `
|
||||
<footer class="report-footer">
|
||||
<span>Dossiê institucional · ${htmlEscape(report.meta.subjectName)} · ${htmlEscape(left)}</span>
|
||||
<span>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))} · pág. ${pageNo}/${report.meta.pageTotal}</span>
|
||||
</footer>`
|
||||
|
||||
/** Singular of the capped-list nouns (never derive it by chopping the "s"). */
|
||||
const CAPPED_NOUNS = {
|
||||
itens: 'item',
|
||||
entregas: 'entrega',
|
||||
vínculos: 'vínculo',
|
||||
}
|
||||
|
||||
const moreNote = (cappedList, noun = 'itens') => {
|
||||
if (cappedList.omitted <= 0) return ''
|
||||
const label = cappedList.omitted === 1 ? (CAPPED_NOUNS[noun] ?? noun) : noun
|
||||
return `<p class="more-note">e mais ${cappedList.omitted} ${label} com fonte fora desta página — nada foi descartado.</p>`
|
||||
}
|
||||
|
||||
const identityBadgeList = (report) =>
|
||||
report.meta.identityBadges
|
||||
.map((badge) => `<span class="identity-badge">${htmlEscape(badge)}</span>`)
|
||||
.join(' ')
|
||||
|
||||
const renderInstitutionCover = (report) => `
|
||||
<article class="sheet sheet-cover" data-page="capa" aria-label="Capa do dossiê institucional">
|
||||
<div class="accent-bar"></div>
|
||||
<header class="cover-head">
|
||||
<div>
|
||||
<p class="report-kicker">${htmlEscape(report.cover.kicker)}</p>
|
||||
<p class="cover-series">${htmlEscape(report.cover.series)}</p>
|
||||
</div>
|
||||
<div class="internal-note"><strong>${htmlEscape(report.cover.internalNote)}</strong><br />${htmlEscape(report.cover.internalNoteSub)}</div>
|
||||
</header>
|
||||
|
||||
<div class="cover-block">
|
||||
<p class="eyebrow">${htmlEscape(report.cover.eyebrow)}</p>
|
||||
<h1 class="cover-title">Dossiê Solla —<br /><span class="cover-accent">${htmlEscape(report.cover.title)}</span></h1>
|
||||
<p class="cover-sub">${htmlEscape(report.cover.subtitle)}</p>
|
||||
</div>
|
||||
|
||||
<dl class="cover-dl">
|
||||
<dt>Identificação</dt><dd>${identityBadgeList(report)}</dd>
|
||||
<dt>Data de geração</dt><dd class="tabular">${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</dd>
|
||||
<dt>Escopo e versão</dt><dd>Eras A / B / C · instituição, setor e rede separados · versão 01</dd>
|
||||
</dl>
|
||||
|
||||
<div class="cover-grid">
|
||||
<div class="how-to"><p class="how-to-title">Como usar</p><p>${htmlEscape(report.cover.howToUse)}</p></div>
|
||||
${assetBox('NEEDS ASSET', ['selo da instituição', 'uso autorizado e origem'])}
|
||||
</div>
|
||||
|
||||
<div class="cover-foot">
|
||||
<div class="ink-rule"></div>
|
||||
<div class="cover-foot-row">
|
||||
<p class="cover-scope">${htmlEscape(report.cover.scope)}</p>
|
||||
<p class="cover-version">Documento de trabalho<br />versão 01</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>`
|
||||
|
||||
const renderInstitutionIdentity = (report) => {
|
||||
const identity = report.meta.identity ?? {}
|
||||
const rows = [
|
||||
['Nome', identity.name],
|
||||
['Tipo', identity.kindLabel],
|
||||
['Esfera', identity.sphereLabel],
|
||||
['Alcance', identity.scopeLabel],
|
||||
['Alias', (identity.aliases ?? []).join(' · ') || '—'],
|
||||
].filter(([, value]) => Boolean(value))
|
||||
return `<div class="id-card">
|
||||
<p class="eyebrow">Identificação</p>
|
||||
<dl>${rows.map(([key, value]) => `<dt>${htmlEscape(key)}</dt><dd>${htmlEscape(value)}</dd>`).join('')}</dl>
|
||||
</div>`
|
||||
}
|
||||
|
||||
const renderInstitutionVincTimeline = (report) => {
|
||||
const timeline = report.page1.timeline
|
||||
if (timeline.items.length === 0) {
|
||||
return '<p class="muted small">Nenhum vínculo datado com fonte — ver lacunas explícitas.</p>'
|
||||
}
|
||||
return `
|
||||
<div class="vinc-timeline">
|
||||
${timeline.items
|
||||
.map(
|
||||
(row) => `<div class="timeline-item">
|
||||
<p class="vinc-period">${htmlEscape(row.period || '—')}</p>
|
||||
<p class="vinc-text">${copyHtml(briefOr(row.brief, row.role))}</p>
|
||||
<p class="vinc-source">${htmlEscape(row.source ?? '')} ${sourceLink(row.url)}</p>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
${moreNote(timeline, 'vínculos')}`
|
||||
}
|
||||
|
||||
const renderInstitutionDeliveries = (report) => {
|
||||
const deliveries = report.page1.deliveries
|
||||
if (deliveries.items.length === 0) return ''
|
||||
return `
|
||||
<section class="block">
|
||||
<div class="block-head">
|
||||
<div><p class="eyebrow">02 · destaques com lastro</p><h3 class="section-title">Principais entregas localizadas</h3></div>
|
||||
<p class="warning-text meta strong">Cada valor mantém a fase e a abrangência</p>
|
||||
</div>
|
||||
<div class="deliveries">
|
||||
${deliveries.items
|
||||
.map(
|
||||
(row) => `<div class="delivery-row">
|
||||
<div class="delivery-scope">${scopeBadge(row.sphere, null, report.unit)}<p class="meta">Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}</p></div>
|
||||
<div>
|
||||
<p class="delivery-title">${copyHtml(briefOr(row.brief, row.title))}</p>
|
||||
${noteOr(row.brief, row.detail) ? `<p class="muted small">${copyHtml(noteOr(row.brief, row.detail))}</p>` : ''}
|
||||
</div>
|
||||
<div class="delivery-value">
|
||||
<p class="value-big tabular">${valueCell(row.value, 'Valor não informado')}</p>
|
||||
${row.phase ? phaseBadge(row.phase) : ''}
|
||||
${sourceLink(row.sourceUrl)}
|
||||
</div>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
${moreNote(deliveries, 'entregas')}
|
||||
<p class="muted small strong">Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor.</p>
|
||||
</section>`
|
||||
}
|
||||
|
||||
const renderInstitutionHooksAndPending = (report) => {
|
||||
const hooks = report.page1.hooks
|
||||
const pending = report.page1.pending
|
||||
if (hooks.items.length === 0 && pending.items.length === 0) return ''
|
||||
return `
|
||||
<section class="page1-grid">
|
||||
${
|
||||
hooks.items.length
|
||||
? `<div class="panel">
|
||||
<p class="eyebrow">Gancho para a agenda</p>
|
||||
<ul class="tight-list">${hooks.items
|
||||
.map(
|
||||
(hook) =>
|
||||
`<li><strong>${htmlEscape(hook.topic)}:</strong> ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)}</li>`,
|
||||
)
|
||||
.join('')}</ul>
|
||||
</div>`
|
||||
: '<div></div>'
|
||||
}
|
||||
${
|
||||
pending.items.length
|
||||
? `<div class="panel panel-warning">
|
||||
<p class="eyebrow warning-text">O que falta</p>
|
||||
<ul class="tight-list">${pending.items.map((item) => `<li>${htmlEscape(item)}</li>`).join('')}</ul>
|
||||
<p class="warning-text small strong">Ausência é resultado: não completar por inferência.</p>
|
||||
</div>`
|
||||
: '<div></div>'
|
||||
}
|
||||
</section>`
|
||||
}
|
||||
|
||||
const renderInstitutionSummary = (report, pageNo) => `
|
||||
<article class="sheet" data-page="resumo" aria-label="Resumo de uma olhada do dossiê institucional">
|
||||
${instHeader(report, 'Resumo de uma olhada', 'Informação datada e pronta para conferência', pageNo)}
|
||||
<section class="summary-top">
|
||||
${renderInstitutionIdentity(report)}
|
||||
<div>
|
||||
<p class="eyebrow">01 · vínculo com a instituição</p>
|
||||
<h3 class="section-title">Linha do tempo documentada</h3>
|
||||
${renderInstitutionVincTimeline(report)}
|
||||
</div>
|
||||
</section>
|
||||
${renderInstitutionDeliveries(report)}
|
||||
${renderInstitutionHooksAndPending(report)}
|
||||
${instFooter(report, 'Resumo · INSUMO INTERNO', pageNo)}
|
||||
</article>`
|
||||
|
||||
const renderInstitutionEraEmpty = (report, era, pageNo) => `
|
||||
<article class="sheet" data-page="era-${era.id.toLowerCase()}" aria-label="${htmlEscape(era.label)}">
|
||||
${instHeader(report, `${era.label} · Pesquisa sem evidência suficiente`, era.period, pageNo)}
|
||||
<div class="era-method">
|
||||
<div>
|
||||
<p class="eyebrow">Recorte previsto</p>
|
||||
<p class="muted">${htmlEscape(era.method)}</p>
|
||||
</div>
|
||||
<aside class="aside-accent"><strong>Trilha tentada</strong><br />${htmlEscape(era.recovery)}</aside>
|
||||
</div>
|
||||
<section class="empty-panel" style="margin-top: 5mm">
|
||||
${WARNING_ICON}
|
||||
<p class="eyebrow warning-text">Lacuna explícita · não é “zero”</p>
|
||||
<h3 class="rule-title">Nenhuma fonte suficiente foi localizada para esta era.</h3>
|
||||
<p class="muted">A pesquisa não encontrou documento que sustente uma afirmação institucional. Isso não prova que não houve vínculo ou atuação; prova apenas que o dossiê ainda não pode afirmá-los.</p>
|
||||
</section>
|
||||
<section class="page1-grid">
|
||||
<div class="panel">
|
||||
<p class="eyebrow">O que foi feito</p>
|
||||
<ul class="tight-list">
|
||||
<li>Busca nominal por instituição e aliases.</li>
|
||||
<li>Consulta a fontes oficiais e acervos datados.</li>
|
||||
<li>Triagem de resultados por vínculo e abrangência.</li>
|
||||
<li>Descarte de menções sem lastro suficiente.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<p class="eyebrow">O que falta fazer</p>
|
||||
<ul class="tight-list">
|
||||
<li>Solicitar consulta ao arquivo físico.</li>
|
||||
<li>Validar nome histórico e unidade da instituição.</li>
|
||||
<li>Recuperar ato, ata ou documento contemporâneo.</li>
|
||||
<li>Repetir busca com alias confirmado.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="rule-warning">
|
||||
${WARNING_ICON}
|
||||
<div>
|
||||
<p class="eyebrow warning-text">Regra editorial</p>
|
||||
<p><strong>Não preencher a seção por memória, cargo provável ou texto de outra era.</strong> Registrar “${htmlEscape(era.label)}: evidência institucional não localizada nas fontes consultadas”.</p>
|
||||
</div>
|
||||
</aside>
|
||||
${instFooter(report, `${era.label} · lacuna explícita`, pageNo)}
|
||||
</article>`
|
||||
|
||||
const renderInstitutionEra = (report, era, pageNo) => {
|
||||
if (era.empty) return renderInstitutionEraEmpty(report, era, pageNo)
|
||||
return `
|
||||
<article class="sheet" data-page="era-${era.id.toLowerCase()}" aria-label="${htmlEscape(era.label)}">
|
||||
${instHeader(report, era.label, `${era.period} · ${era.subtitle}`, pageNo)}
|
||||
|
||||
<div class="era-method">
|
||||
<div>
|
||||
<p class="eyebrow">Recorte e método</p>
|
||||
<p class="muted">${htmlEscape(era.method)}</p>
|
||||
</div>
|
||||
<aside class="aside-accent"><strong>Trilha de recuperação</strong><br />${htmlEscape(era.recovery)}</aside>
|
||||
</div>
|
||||
|
||||
${
|
||||
era.numbers.length
|
||||
? `<section class="block">
|
||||
<div class="block-head">
|
||||
<div><p class="eyebrow">Entregas localizadas</p><h3 class="section-title">Objeto, valor, fase e alcance</h3></div>
|
||||
</div>
|
||||
<table class="document-table">
|
||||
<caption class="sr-only">Valores por ano, fase, abrangência e fonte</caption>
|
||||
<colgroup><col style="width:26%" /><col style="width:14%" /><col style="width:9%" /><col style="width:16%" /><col style="width:15%" /><col style="width:20%" /></colgroup>
|
||||
<thead><tr><th>Objeto</th><th>Valor</th><th>Ano</th><th>Fase</th><th>Abrangência</th><th>Fonte</th></tr></thead>
|
||||
<tbody>${era.numbers
|
||||
.map(
|
||||
(row) => `<tr>
|
||||
<td>${htmlEscape(row.object)}</td>
|
||||
<td class="numeric">${valueCell(row.value, 'Valor a recuperar')}</td>
|
||||
<td class="numeric">${htmlEscape(row.year ?? '—')}</td>
|
||||
<td>${phaseBadge(row.phase)}</td>
|
||||
<td>${scopeBadge(row.sphere, null, report.unit)}</td>
|
||||
<td>${sourceLink(row.sourceUrl)}${row.sphere !== 'instituicao' ? '<br /><span class="meta">não somar à instituição</span>' : ''}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}</tbody>
|
||||
</table>
|
||||
<p class="warning-text small strong">Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.</p>
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
era.actions.length
|
||||
? `<section class="block">
|
||||
<p class="eyebrow">Atuação / vínculo registrado</p>
|
||||
<h3 class="section-title">Papéis com evidência visível</h3>
|
||||
<div class="action-grid">
|
||||
${era.actions
|
||||
.map(
|
||||
(action) => `<article class="panel">
|
||||
<div class="action-head">${scopeBadge(action.sphere, null, report.unit)}<span class="meta">${htmlEscape(action.year ?? '—')}</span></div>
|
||||
<h4 class="action-title">${copyHtml(briefOr(action.brief, action.title))}</h4>
|
||||
${noteOr(action.brief, action.detail) ? `<p class="muted small">${copyHtml(noteOr(action.brief, action.detail))}</p>` : ''}
|
||||
${sourceLink(action.sourceUrl)}
|
||||
</article>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
era.honors.length
|
||||
? `<section class="block honors">
|
||||
<div class="panel">
|
||||
<p class="eyebrow">Títulos, honrarias e vínculos institucionais</p>
|
||||
<ul class="tight-list">${era.honors
|
||||
.map(
|
||||
(honor) =>
|
||||
`<li>${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}</li>`,
|
||||
)
|
||||
.join('')}</ul>
|
||||
</div>
|
||||
${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])}
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${instFooter(report, era.label, pageNo)}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const renderInstitutionScope = (report, pageNo) => {
|
||||
const reach = report.reach
|
||||
const cardClass = { institution: '', sector: 'card-sector', network: 'card-network' }
|
||||
return `
|
||||
<article class="sheet" data-page="abrangencia" aria-label="Painel de abrangência institucional">
|
||||
${instHeader(report, 'Abrangência: instituição × setor × rede', 'Três recortes, três leituras, nenhuma soma combinada', pageNo)}
|
||||
|
||||
<section class="rule-warning">
|
||||
${WARNING_ICON}
|
||||
<div>
|
||||
<p class="rule-title">${htmlEscape(reach.ruleTitle)}</p>
|
||||
<p class="muted">${htmlEscape(reach.ruleBody)}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<p class="eyebrow">Leitura lado a lado</p>
|
||||
<h3 class="section-title">O alcance acompanha cada evidência</h3>
|
||||
<div class="scope-cards">
|
||||
${reach.lists
|
||||
.map(
|
||||
(list) => `<article class="scope-card ${cardClass[list.key] ?? ''}">
|
||||
<div class="action-head">${scopeBadge(list.sphere, null, report.unit)}<span class="meta strong">${list.sphere === 'instituicao' ? 'DIRETO' : 'NÃO SOMAR'}</span></div>
|
||||
<p class="scope-count tabular">${list.total} ${list.total === 1 ? 'item' : 'itens'}</p>
|
||||
<p class="meta">com fonte</p>
|
||||
<ul class="tight-list">${list.items
|
||||
.map(
|
||||
(item) =>
|
||||
`<li>${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}</li>`,
|
||||
)
|
||||
.join(
|
||||
'',
|
||||
)}${list.items.length === 0 ? '<li class="muted">Nenhum item com fonte.</li>' : ''}</ul>
|
||||
${moreNote(list, 'itens')}
|
||||
</article>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${
|
||||
reach.evidence.items.length
|
||||
? `<section class="block">
|
||||
<div class="block-head"><div><p class="eyebrow">Evidência de alcance</p><h3 class="section-title">Por que cada item está neste recorte</h3></div><p class="warning-text meta strong">Sem evidência, vira lacuna</p></div>
|
||||
<table class="document-table">
|
||||
<caption class="sr-only">Itens de setor e rede com evidência de alcance</caption>
|
||||
<colgroup><col style="width:26%" /><col style="width:16%" /><col style="width:38%" /><col style="width:20%" /></colgroup>
|
||||
<thead><tr><th>Item</th><th>Abrangência</th><th>Evidência de alcance</th><th>Fonte</th></tr></thead>
|
||||
<tbody>${reach.evidence.items
|
||||
.map(
|
||||
(row) => `<tr>
|
||||
<td>${copyHtml(briefOr(row.brief, row.item))}</td>
|
||||
<td>${scopeBadge(row.sphere, null, report.unit)}</td>
|
||||
<td>${copyHtml(row.evidence)}</td>
|
||||
<td>${sourceLink(row.sourceUrl)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}</tbody>
|
||||
</table>
|
||||
${moreNote(reach.evidence, 'itens')}
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
reach.hook || reach.priorityGap
|
||||
? `<section class="page1-grid">
|
||||
${
|
||||
reach.hook
|
||||
? `<div class="aside-accent"><p class="eyebrow">Gancho possível</p><p><strong>${htmlEscape(reach.hook.topic)}:</strong> ${copyHtml(briefOr(reach.hook.brief, reach.hook.angle))} ${sourceLink(reach.hook.sourceUrl)}</p></div>`
|
||||
: '<div></div>'
|
||||
}
|
||||
${
|
||||
reach.priorityGap
|
||||
? `<div class="panel panel-warning"><p class="eyebrow warning-text">Lacuna prioritária</p><p>${htmlEscape(reach.priorityGap)}</p></div>`
|
||||
: '<div></div>'
|
||||
}
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${instFooter(report, 'Abrangência · recortes não somáveis', pageNo)}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const honorDate = (honor) => {
|
||||
if (honor.date) return formatDateBr(honor.date)
|
||||
if (honor.year) return String(honor.year)
|
||||
return '—'
|
||||
}
|
||||
|
||||
const institutionHonors = (report) => report.eras.find((era) => era.id === 'C')?.honors ?? []
|
||||
|
||||
const hasInstitutionHonors = (report) => institutionHonors(report).length > 0
|
||||
|
||||
const renderInstitutionHonors = (report, pageNo) => {
|
||||
const honors = institutionHonors(report)
|
||||
if (honors.length === 0) return ''
|
||||
const gap = report.gaps?.[0] ?? null
|
||||
const hook = report.reach.hook ?? report.page1.hooks.items[0] ?? null
|
||||
return `
|
||||
<article class="sheet" data-page="titulos" aria-label="Títulos, honrarias e vínculos institucionais">
|
||||
${instHeader(report, 'Títulos, honrarias e vínculos', 'Reconhecimento não substitui entrega; vínculo não prova resultado', pageNo)}
|
||||
<section class="block">
|
||||
<p class="eyebrow">Registros confirmados</p>
|
||||
<h3 class="section-title">Data, natureza e fonte primária</h3>
|
||||
<table class="document-table">
|
||||
<caption class="sr-only">Títulos, honrarias e vínculos por data e fonte</caption>
|
||||
<colgroup><col style="width:14%" /><col style="width:20%" /><col style="width:35%" /><col style="width:31%" /></colgroup>
|
||||
<thead><tr><th>Data</th><th>Natureza</th><th>Registro</th><th>Fonte</th></tr></thead>
|
||||
<tbody>${honors
|
||||
.map(
|
||||
(honor) => `<tr>
|
||||
<td class="numeric">${htmlEscape(honorDate(honor))}</td>
|
||||
<td><span class="identity-badge">reconhecimento</span></td>
|
||||
<td><strong>${copyHtml(briefOr(honor.brief, honor.text))}</strong>${noteOr(honor.brief, honor.detail) ? `<br /><span class="meta">${copyHtml(noteOr(honor.brief, honor.detail))}</span>` : ''}</td>
|
||||
<td>${sourceLink(honor.sourceUrl)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="block honors">
|
||||
<div>
|
||||
<p class="eyebrow">Como ler</p>
|
||||
<div class="action-grid">
|
||||
<div class="panel">
|
||||
<h3 class="action-title">Vínculo documentado</h3>
|
||||
<p class="muted small">Informa papel, unidade e período. Não autoriza atribuir toda ação posterior da instituição.</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3 class="action-title">Honraria documentada</h3>
|
||||
<p class="muted small">Registra reconhecimento e órgão concedente. Não vira prova de recurso ou entrega.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${assetBox('NEEDS ASSET', ['reprodução do ato, diploma ou clipping', 'origem, autoria e licença'])}
|
||||
</section>
|
||||
<section class="page1-grid">
|
||||
<div class="aside-accent">
|
||||
<p class="eyebrow">Gancho para a agenda</p>
|
||||
<p>${hook ? `<strong>${htmlEscape(hook.topic)}:</strong> ${copyHtml(briefOr(hook.brief, hook.angle))}` : 'Mencionar um vínculo só na formulação que a fonte sustenta e conectá-lo a uma entrega comprovada, se houver.'}</p>
|
||||
</div>
|
||||
<div class="panel panel-warning">
|
||||
<p class="eyebrow warning-text">Pendência documental</p>
|
||||
<p>${gap ? `${htmlEscape(gap.label)}: ${htmlEscape(gap.reason)}` : 'Nenhuma pendência declarada para os registros desta página.'}</p>
|
||||
</div>
|
||||
</section>
|
||||
${instFooter(report, 'Títulos e vínculos · INSUMO INTERNO', pageNo)}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const renderInstitutionGapsPage = (report, chunk, index, pageNo) => {
|
||||
const rows = chunk.length
|
||||
? chunk
|
||||
.map(
|
||||
(gap) => `<tr>
|
||||
<td><strong>${htmlEscape(gap.label)}</strong></td>
|
||||
<td>${htmlEscape(gap.reason)}</td>
|
||||
<td>Não completar por inferência.</td>
|
||||
<td>${htmlEscape(gap.nextStep)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')
|
||||
: '<tr><td colspan="4" class="muted">Nenhuma lacuna declarada — confira a cobertura das eras.</td></tr>'
|
||||
return `
|
||||
<article class="sheet" data-page="lacunas${index > 0 ? `-${index + 1}` : ''}" aria-label="Lacunas explícitas">
|
||||
${instHeader(report, 'Lacunas explícitas', `O que não foi encontrado${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)}
|
||||
<section class="block">
|
||||
<div class="block-head"><div><p class="eyebrow">O que não foi encontrado</p><h3 class="section-title">Lacunas explícitas</h3></div><p class="warning-text meta strong">Não completar por memória ou inferência</p></div>
|
||||
<table class="document-table">
|
||||
<caption class="sr-only">Tabela de lacunas explícitas e próximos passos de apuração</caption>
|
||||
<colgroup><col style="width:26%" /><col style="width:30%" /><col style="width:24%" /><col style="width:20%" /></colgroup>
|
||||
<thead><tr><th>Lacuna</th><th>Situação da busca</th><th>Limite editorial</th><th>Próximo passo</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
${instFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const renderInstitutionSourcesPage = (report, pageNo) => `
|
||||
<article class="sheet" data-page="fontes" aria-label="Fontes e limites do dossiê institucional">
|
||||
${instHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)}
|
||||
|
||||
${
|
||||
report.news.length
|
||||
? `<section class="block">
|
||||
<p class="eyebrow">Lista clicável</p>
|
||||
<h3 class="section-title">Notícias e documentos consultados</h3>
|
||||
<table class="document-table">
|
||||
<caption class="sr-only">Fontes consultadas: data 12%, veículo 13%, título 47% e link 28%</caption>
|
||||
<colgroup><col style="width:12%" /><col style="width:13%" /><col style="width:47%" /><col style="width:28%" /></colgroup>
|
||||
<thead><tr><th>Data</th><th>Veículo</th><th>Título</th><th>Link</th></tr></thead>
|
||||
<tbody>${report.news
|
||||
.map(
|
||||
(row) => `<tr>
|
||||
<td class="numeric nowrap">${htmlEscape(formatShortDateBr(row.date))}</td>
|
||||
<td>${htmlEscape(row.outlet)}</td>
|
||||
<td>${htmlEscape(row.title)}</td>
|
||||
<td>${sourceLink(row.url, '(fonte)')}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}</tbody>
|
||||
</table>
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
report.acervo?.items?.length
|
||||
? `<section class="block">
|
||||
<div class="block-head"><div><p class="eyebrow">Acervo interno (read-only)</p><h3 class="section-title">Falas do mandato sobre o recorte</h3></div><p class="meta">Base Teqo, 2011+</p></div>
|
||||
<ul class="tight-list">${report.acervo.items
|
||||
.map(
|
||||
(row) =>
|
||||
`<li><strong>${htmlEscape(row.period)}</strong> — ${copyHtml(row.text)}${row.excerpt ? ` <span class="muted small">(${copyHtml(row.excerpt)})</span>` : ''} ${sourceLink(row.sourceUrl)}</li>`,
|
||||
)
|
||||
.join('')}</ul>
|
||||
${moreNote(report.acervo, 'itens')}
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
<section class="page1-grid">
|
||||
<div class="panel"><p class="eyebrow">Limites de cobertura</p><ul class="tight-list">${report.limits.coverage
|
||||
.map((item) => `<li>${htmlEscape(item)}</li>`)
|
||||
.join('')}</ul></div>
|
||||
<div class="panel"><p class="eyebrow">Regras para uso editorial</p><ul class="tight-list">${report.limits.editorial
|
||||
.map((item) => `<li>${htmlEscape(item)}</li>`)
|
||||
.join('')}</ul></div>
|
||||
</section>
|
||||
|
||||
<aside class="rule-warning defeso">
|
||||
${WARNING_ICON}
|
||||
<div>
|
||||
<p class="eyebrow warning-text">Nota de defeso eleitoral 2026</p>
|
||||
<h3 class="rule-title">Insumo interno não é autorização para publicar.</h3>
|
||||
<p class="muted">Publicidade institucional e propaganda eleitoral obedecem a regimes diferentes. Antes de adaptar qualquer trecho para peça externa, submeta texto, imagem, autoria, período e canal à revisão responsável. Este dossiê organiza evidências; não substitui análise jurídica nem editorial.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
${instFooter(report, 'Fontes e limites · INSUMO INTERNO', pageNo)}
|
||||
</article>`
|
||||
|
||||
const renderInstitutionDossierHtml = (report) => {
|
||||
const gapChunks = chunkGaps(report.gaps)
|
||||
report.meta.pageTotal =
|
||||
1 + // capa
|
||||
1 + // resumo
|
||||
report.eras.length +
|
||||
1 + // abrangência
|
||||
(hasInstitutionHonors(report) ? 1 : 0) +
|
||||
gapChunks.length +
|
||||
1 // fontes
|
||||
let page = 0
|
||||
const parts = []
|
||||
parts.push(renderInstitutionCover(report))
|
||||
page += 1
|
||||
parts.push(renderInstitutionSummary(report, ++page))
|
||||
for (const era of report.eras) parts.push(renderInstitutionEra(report, era, ++page))
|
||||
parts.push(renderInstitutionScope(report, ++page))
|
||||
if (hasInstitutionHonors(report)) parts.push(renderInstitutionHonors(report, ++page))
|
||||
gapChunks.forEach((chunk, index) =>
|
||||
parts.push(renderInstitutionGapsPage(report, chunk, index, ++page)),
|
||||
)
|
||||
parts.push(renderInstitutionSourcesPage(report, ++page))
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head><meta charset="utf-8"><title>${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.subjectName)}</title><style>${INSTITUTION_PRINT_CSS}</style></head>
|
||||
<body>
|
||||
${parts.join('\n')}
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
const institutionMdEra = (era) => {
|
||||
const lines = [
|
||||
`## ${era.label} (${era.period})`,
|
||||
'',
|
||||
`_${era.method}_`,
|
||||
'',
|
||||
`Trilha: ${era.recovery}`,
|
||||
]
|
||||
if (era.empty) {
|
||||
lines.push(
|
||||
'',
|
||||
`> Lacuna explícita: nenhuma fonte suficiente foi localizada para a ${era.label}. Isso não prova ausência — prova que o dossiê ainda não pode afirmar.`,
|
||||
)
|
||||
return lines.join('\n')
|
||||
}
|
||||
if (era.numbers.length) {
|
||||
lines.push(
|
||||
'',
|
||||
'| Objeto | Valor | Ano | Fase | Abrangência | Fonte |',
|
||||
'| --- | --- | --- | --- | --- | --- |',
|
||||
)
|
||||
for (const row of era.numbers) {
|
||||
lines.push(
|
||||
`| ${row.object} | ${row.value ?? '—'} | ${row.year ?? '—'} | ${dossierPhaseLabel(row.phase)} | ${row.sphere} | ${row.sourceUrl ?? '—'} |`,
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const action of era.actions) {
|
||||
lines.push(
|
||||
'',
|
||||
`- **${stripInlineSources(action.title)}** (${action.sphere}${action.year ? ` · ${action.year}` : ''})${action.detail ? ` — ${stripInlineSources(action.detail)}` : ''} [fonte](${action.sourceUrl})`,
|
||||
)
|
||||
}
|
||||
for (const honor of era.honors)
|
||||
lines.push('', `- Honraria: ${stripInlineSources(honor.text)} [fonte](${honor.sourceUrl})`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const renderInstitutionDossierMd = (report) => {
|
||||
const lines = [
|
||||
`# ${report.meta.title} — ${report.meta.subjectName}`,
|
||||
'',
|
||||
`**Identificação:** ${report.meta.identityBadges.join(' · ')}`,
|
||||
`**Gerado em:** ${formatDateTimeBr(report.meta.generatedAt)} · **Base Teqo (read-only) lida em:** ${report.meta.readAtLabel}`,
|
||||
'',
|
||||
'> INSUMO INTERNO — não circular, não publicar. Documento de trabalho.',
|
||||
'',
|
||||
`## Identificação`,
|
||||
'',
|
||||
`- **Nome:** ${report.meta.identity?.name ?? '—'}`,
|
||||
`- **Tipo:** ${report.meta.identity?.kindLabel ?? '—'}`,
|
||||
`- **Esfera:** ${report.meta.identity?.sphereLabel ?? '—'}`,
|
||||
`- **Alcance:** ${report.meta.identity?.scopeLabel ?? '—'}`,
|
||||
`- **Alias:** ${(report.meta.identity?.aliases ?? []).join(' · ') || '—'}`,
|
||||
'',
|
||||
'## Linha do tempo documentada',
|
||||
'',
|
||||
]
|
||||
if (report.page1.timeline.items.length === 0) lines.push('_Nenhum vínculo datado com fonte._')
|
||||
for (const row of report.page1.timeline.items) {
|
||||
lines.push(
|
||||
`- **${row.period || '—'}** — ${stripInlineSources(row.role)} (${row.source ?? ''}) [fonte](${row.url})`,
|
||||
)
|
||||
}
|
||||
if (report.page1.timeline.omitted > 0)
|
||||
lines.push(`- _e mais ${report.page1.timeline.omitted} vínculo(s) com fonte._`)
|
||||
|
||||
if (report.page1.deliveries.items.length) {
|
||||
lines.push('', '## Principais entregas localizadas', '')
|
||||
for (const row of report.page1.deliveries.items) {
|
||||
lines.push(
|
||||
`- **${stripInlineSources(row.title)}** · _${row.sphere}_${row.value ? ` · ${row.value}` : ''}${row.phase ? ` (${dossierPhaseLabel(row.phase)})` : ''}${row.detail ? ` — ${stripInlineSources(row.detail)}` : ''} [fonte](${row.sourceUrl})`,
|
||||
)
|
||||
}
|
||||
if (report.page1.deliveries.omitted > 0)
|
||||
lines.push(`- _e mais ${report.page1.deliveries.omitted} entrega(s) com fonte._`)
|
||||
lines.push('', '_Autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor._')
|
||||
}
|
||||
|
||||
for (const era of report.eras) lines.push('', institutionMdEra(era))
|
||||
|
||||
lines.push('', '## Abrangência: instituição × setor × rede', '')
|
||||
lines.push(`> ${report.reach.ruleTitle}`, `> ${report.reach.ruleBody}`, '')
|
||||
for (const list of report.reach.lists) {
|
||||
lines.push(`### ${list.label}`, '')
|
||||
if (list.items.length === 0) lines.push('- Nenhum item com fonte.')
|
||||
for (const item of list.items) {
|
||||
lines.push(`- ${stripInlineSources(item.answer)} — [fonte](${item.sourceUrl})`)
|
||||
}
|
||||
if (list.omitted > 0) lines.push(`- _e mais ${list.omitted} item(ns) com fonte._`)
|
||||
lines.push('')
|
||||
}
|
||||
if (report.reach.evidence.items.length) {
|
||||
lines.push('| Item | Abrangência | Evidência | Fonte |', '| --- | --- | --- | --- |')
|
||||
for (const item of report.reach.evidence.items) {
|
||||
lines.push(
|
||||
`| ${stripInlineSources(item.item)} | ${item.sphere} | ${stripInlineSources(item.evidence)} | ${item.sourceUrl} |`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (report.acervo?.items?.length) {
|
||||
lines.push('', '## Acervo interno (read-only)', '')
|
||||
for (const row of report.acervo.items) {
|
||||
lines.push(
|
||||
`- **${row.period}** — ${stripInlineSources(row.text)}${row.excerpt ? ` _(${stripInlineSources(row.excerpt)})_` : ''} [fonte](${row.sourceUrl})`,
|
||||
)
|
||||
}
|
||||
if (report.acervo.omitted > 0)
|
||||
lines.push(`- _e mais ${report.acervo.omitted} fala(s) com fonte._`)
|
||||
}
|
||||
|
||||
lines.push('', '## Lacunas explícitas', '')
|
||||
if (report.gaps.length === 0) lines.push('_Nenhuma lacuna declarada._')
|
||||
for (const gap of report.gaps) {
|
||||
lines.push(`- **${gap.label}:** ${gap.reason} → ${gap.nextStep}`)
|
||||
}
|
||||
|
||||
if (report.news.length) {
|
||||
lines.push('', '## Notícias e documentos consultados', '')
|
||||
for (const row of report.news) {
|
||||
lines.push(`- ${formatDateBr(row.date)} · ${row.outlet} — ${row.title} — ${row.url}`)
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'## Limites de cobertura',
|
||||
'',
|
||||
...report.limits.coverage.map((item) => `- ${item}`),
|
||||
'',
|
||||
'## Regras para uso editorial',
|
||||
'',
|
||||
...report.limits.editorial.map((item) => `- ${item}`),
|
||||
'',
|
||||
'## Nota de defeso eleitoral 2026',
|
||||
'',
|
||||
'Insumo interno não é autorização para publicar. Publicidade institucional e propaganda eleitoral obedecem a regimes diferentes. Antes de adaptar qualquer trecho para peça externa, submeta texto, imagem, autoria, período e canal à revisão responsável.',
|
||||
'',
|
||||
`_Documento interno de campanha, datado (${formatDateTimeBr(report.meta.generatedAt)}). O dado envelhece: confira a data de leitura da base._`,
|
||||
)
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
/** Dispatch: institution reports render with the institutional sections. */
|
||||
export const renderDossierHtml = (report) =>
|
||||
isInstitutionUnit(report.unit)
|
||||
? renderInstitutionDossierHtml(report)
|
||||
: renderMunicipalityDossierHtml(report)
|
||||
|
||||
export const renderDossierMd = (report) =>
|
||||
isInstitutionUnit(report.unit)
|
||||
? renderInstitutionDossierMd(report)
|
||||
: renderMunicipalityDossierMd(report)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
/**
|
||||
* Research contract for the C186 dossiê (owner). The dossiê is researched one
|
||||
* file per era (`<slug>.<era>.research.json`); each claim either carries a date
|
||||
* + URL or becomes an explicit gap. Reuses the C163 validation primitives
|
||||
* (exported additively from `cityReportResearch.mjs`) instead of twinning them.
|
||||
* Research contract for the dossiê (owner: C186; unit seam added by C187). The
|
||||
* dossiê is researched one file per era (`<slug>.<era>.research.json`); each
|
||||
* claim either carries a date + URL or becomes an explicit gap. Reuses the C163
|
||||
* validation primitives (exported additively from `cityReportResearch.mjs`)
|
||||
* instead of twinning them.
|
||||
*
|
||||
* The `unit` descriptor selects the slug field, the sphere vocabulary and the
|
||||
* per-era checklist; without it the behaviour is the C186 municipality one.
|
||||
*/
|
||||
|
||||
import { collectExtraSources, isNonEmptyString, isValidDate } from './cityReportResearch.mjs'
|
||||
import { DOSSIER_ERA_IDS } from './dossieCareer.mjs'
|
||||
|
||||
/** Explicit spheres: a regional/pole item is never summed into the município. */
|
||||
const DOSSIER_SPHERES = ['municipio', 'regiao', 'polo']
|
||||
import { MUNICIPALITY_UNIT, resolveDossierUnit } from './dossieUnit.mjs'
|
||||
|
||||
/** Execution phases (empenho ≠ pagamento). Unknown/absent degrades to "não informada". */
|
||||
const DOSSIER_PHASES = ['autorizado', 'empenhado', 'liquidado', 'pago', 'restos', 'nao_informado']
|
||||
|
|
@ -24,8 +26,8 @@ const DOSSIER_PHASES = ['autorizado', 'empenhado', 'liquidado', 'pago', 'restos'
|
|||
export const DOSSIER_BRIEF_TITLE_MAX = 80
|
||||
export const DOSSIER_BRIEF_NOTE_MAX = 120
|
||||
|
||||
/** Checklist per era — the dossiê's research checklist. `area` labels the boletim card. */
|
||||
const DOSSIER_RESEARCH_CHECKLIST = [
|
||||
/** Municipality checklist (C186). `area` labels the boletim card. */
|
||||
const MUNICIPALITY_RESEARCH_CHECKLIST = [
|
||||
{
|
||||
id: 'era_a_formacao',
|
||||
era: 'A',
|
||||
|
|
@ -107,17 +109,135 @@ const DOSSIER_RESEARCH_CHECKLIST = [
|
|||
},
|
||||
]
|
||||
|
||||
export const DOSSIER_RESEARCH_CHECKLIST_IDS = DOSSIER_RESEARCH_CHECKLIST.map((item) => item.id)
|
||||
/** Institution checklist (C187) — same eras, institution-oriented. */
|
||||
const INSTITUTION_RESEARCH_CHECKLIST = [
|
||||
{
|
||||
id: 'era_a_formacao',
|
||||
era: 'A',
|
||||
area: 'Formação',
|
||||
label: 'Formação, residência e pesquisa com vínculo nominal à instituição',
|
||||
},
|
||||
{
|
||||
id: 'era_a_sesab',
|
||||
era: 'A',
|
||||
area: 'Gestão pública',
|
||||
label: 'Atuação na SESAB com efeito ou menção à instituição',
|
||||
},
|
||||
{
|
||||
id: 'era_a_consultor_ms',
|
||||
era: 'A',
|
||||
area: 'Ministério da Saúde',
|
||||
label: 'Consultoria no Ministério da Saúde com interface institucional',
|
||||
},
|
||||
{
|
||||
id: 'era_a_conquista',
|
||||
era: 'A',
|
||||
area: 'Gestão municipal',
|
||||
label: 'Gestão municipal de saúde com parceria institucional',
|
||||
},
|
||||
{
|
||||
id: 'era_a_sas_ms',
|
||||
era: 'A',
|
||||
area: 'Ministério da Saúde',
|
||||
label: 'Secretaria de Atenção à Saúde do MS com interface institucional',
|
||||
},
|
||||
{
|
||||
id: 'era_a_vinculo',
|
||||
era: 'A',
|
||||
area: 'Vínculo',
|
||||
label: 'Vínculo institucional documentado (professor, pesquisador, conselheiro)',
|
||||
},
|
||||
{
|
||||
id: 'era_b_sesab',
|
||||
era: 'B',
|
||||
area: 'Gestão estadual',
|
||||
label: 'Gestão da SESAB (2007–2014) com a instituição',
|
||||
},
|
||||
{
|
||||
id: 'era_b_equipamentos',
|
||||
era: 'B',
|
||||
area: 'Equipamentos',
|
||||
label: 'Equipamentos e serviços de saúde ligados à instituição',
|
||||
},
|
||||
{
|
||||
id: 'era_b_programas',
|
||||
era: 'B',
|
||||
area: 'Programas',
|
||||
label: 'Programas e políticas estaduais com a instituição',
|
||||
},
|
||||
{
|
||||
id: 'era_b_obras',
|
||||
era: 'B',
|
||||
area: 'Obras',
|
||||
label: 'Obras e investimentos na instituição',
|
||||
},
|
||||
{
|
||||
id: 'era_b_convenios',
|
||||
era: 'B',
|
||||
area: 'Convênios',
|
||||
label: 'Convênios, termos e parcerias formais com a instituição',
|
||||
},
|
||||
{
|
||||
id: 'era_c_discursos',
|
||||
era: 'C',
|
||||
area: 'Mandato',
|
||||
label: 'Pronunciamentos e falas com menção à instituição',
|
||||
},
|
||||
{
|
||||
id: 'era_c_proposicoes',
|
||||
era: 'C',
|
||||
area: 'Mandato',
|
||||
label: 'Proposições e relatorias de interesse da instituição',
|
||||
},
|
||||
{
|
||||
id: 'era_c_emendas',
|
||||
era: 'C',
|
||||
area: 'Emendas',
|
||||
label: 'Emendas e recursos destinados à instituição',
|
||||
},
|
||||
{
|
||||
id: 'era_c_titulos',
|
||||
era: 'C',
|
||||
area: 'Reconhecimento',
|
||||
label: 'Títulos, honrarias e homenagens da instituição',
|
||||
},
|
||||
{
|
||||
id: 'era_c_atuacao',
|
||||
era: 'C',
|
||||
area: 'Atuação',
|
||||
label: 'Atuação e articulação institucional',
|
||||
},
|
||||
{
|
||||
id: 'era_c_parcerias',
|
||||
era: 'C',
|
||||
area: 'Parcerias',
|
||||
label: 'Parcerias, audiências e articulação formal com a instituição',
|
||||
},
|
||||
]
|
||||
|
||||
const checklistById = new Map(DOSSIER_RESEARCH_CHECKLIST.map((item) => [item.id, item]))
|
||||
/** @type {Record<string, Array<{ id: string, era: string, area: string, label: string }>>} */
|
||||
const CHECKLIST_BY_UNIT = {
|
||||
municipality: MUNICIPALITY_RESEARCH_CHECKLIST,
|
||||
institution: INSTITUTION_RESEARCH_CHECKLIST,
|
||||
}
|
||||
|
||||
export const dossierChecklistForEra = (era) =>
|
||||
DOSSIER_RESEARCH_CHECKLIST.filter((item) => item.era === era)
|
||||
const checklistFor = (unit) => CHECKLIST_BY_UNIT[unit.id] ?? MUNICIPALITY_RESEARCH_CHECKLIST
|
||||
|
||||
const normalizeSphere = (value) => {
|
||||
if (!isNonEmptyString(value)) return 'municipio'
|
||||
/** Municipality checklist ids (C186 surface kept stable). */
|
||||
export const DOSSIER_RESEARCH_CHECKLIST_IDS = MUNICIPALITY_RESEARCH_CHECKLIST.map((item) => item.id)
|
||||
|
||||
/**
|
||||
* @param {string} era
|
||||
* @param {any} [unit]
|
||||
* @returns {Array<{ id: string, era: string, area: string, label: string }>}
|
||||
*/
|
||||
export const dossierChecklistForEra = (era, unit = MUNICIPALITY_UNIT) =>
|
||||
checklistFor(resolveDossierUnit(unit)).filter((item) => item.era === era)
|
||||
|
||||
const normalizeSphere = (value, unit) => {
|
||||
if (!isNonEmptyString(value)) return unit.defaultSphere
|
||||
const sphere = value.trim()
|
||||
return DOSSIER_SPHERES.includes(sphere) ? sphere : null
|
||||
return unit.spheres.includes(sphere) ? sphere : null
|
||||
}
|
||||
|
||||
const normalizePhase = (value) => {
|
||||
|
|
@ -166,19 +286,39 @@ const normalizeNumbers = (entry) => {
|
|||
return numbers
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} NormalizedDossierResearch
|
||||
* @property {string} era
|
||||
* @property {string} researchedAt
|
||||
* @property {any[]} items
|
||||
* @property {any[]} news
|
||||
* @property {any[]} gaps
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates/normalizes one era's agent-written research JSON.
|
||||
*
|
||||
* Structural failures (no slug, no research date, no/unknown era) throw — the
|
||||
* operator fixes the file. Content failures (missing source, unknown item, item
|
||||
* from another era) degrade to explicit gaps.
|
||||
*
|
||||
* @param {any} raw
|
||||
* @param {{ unit?: any }} [options]
|
||||
* @returns {NormalizedDossierResearch & Record<string, any>}
|
||||
*/
|
||||
export const normalizeDossierResearchInput = (raw) => {
|
||||
export const normalizeDossierResearchInput = (raw, { unit } = {}) => {
|
||||
const resolvedUnit = resolveDossierUnit(unit)
|
||||
const checklist = checklistFor(resolvedUnit)
|
||||
const checklistById = new Map(checklist.map((item) => [item.id, item]))
|
||||
const slug = isNonEmptyString(raw?.[resolvedUnit.slugField])
|
||||
? raw[resolvedUnit.slugField].trim()
|
||||
: null
|
||||
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Pesquisa do dossiê inválida: o arquivo precisa ser um objeto JSON.')
|
||||
}
|
||||
if (!isNonEmptyString(raw.municipalitySlug)) {
|
||||
throw new Error('Pesquisa do dossiê inválida: falta "municipalitySlug".')
|
||||
if (!slug) {
|
||||
throw new Error(`Pesquisa do dossiê inválida: falta "${resolvedUnit.slugField}".`)
|
||||
}
|
||||
if (!isValidDate(raw.researchedAt)) {
|
||||
throw new Error(
|
||||
|
|
@ -230,12 +370,12 @@ export const normalizeDossierResearchInput = (raw) => {
|
|||
})
|
||||
continue
|
||||
}
|
||||
const sphere = normalizeSphere(entry.sphere)
|
||||
const sphere = normalizeSphere(entry.sphere, resolvedUnit)
|
||||
if (!sphere) {
|
||||
gaps.push({
|
||||
id,
|
||||
label: checklistItem.label,
|
||||
reason: `Esfera inválida (esperado ${DOSSIER_SPHERES.join('/')}).`,
|
||||
reason: `Esfera inválida (esperado ${resolvedUnit.spheres.join('/')}).`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
|
@ -270,7 +410,7 @@ export const normalizeDossierResearchInput = (raw) => {
|
|||
}
|
||||
}
|
||||
|
||||
for (const checklistItem of dossierChecklistForEra(era)) {
|
||||
for (const checklistItem of dossierChecklistForEra(era, resolvedUnit)) {
|
||||
if (!seen.has(checklistItem.id)) {
|
||||
gaps.push({ id: checklistItem.id, label: checklistItem.label, reason: 'Não pesquisado.' })
|
||||
}
|
||||
|
|
@ -299,7 +439,7 @@ export const normalizeDossierResearchInput = (raw) => {
|
|||
}
|
||||
|
||||
return {
|
||||
municipalitySlug: raw.municipalitySlug.trim(),
|
||||
[resolvedUnit.slugField]: slug,
|
||||
era,
|
||||
researchedAt: researchedAt.toISOString(),
|
||||
items,
|
||||
|
|
@ -308,31 +448,48 @@ export const normalizeDossierResearchInput = (raw) => {
|
|||
}
|
||||
}
|
||||
|
||||
/** Short receipt the researcher subagent returns (never the body). */
|
||||
export const dossierResearchReceipt = (research) => ({
|
||||
slug: research.municipalitySlug,
|
||||
era: research.era,
|
||||
status: 'ok',
|
||||
researchPath: `data/dossie-solla-cidade/${research.municipalitySlug}.${research.era.toLowerCase()}.research.json`,
|
||||
researchedAt: research.researchedAt,
|
||||
itemCount: research.items.length,
|
||||
gapCount: research.gaps.length,
|
||||
newsCount: research.news.length,
|
||||
gaps: research.gaps.map((gap) => gap.id),
|
||||
})
|
||||
/**
|
||||
* Short receipt the researcher subagent returns (never the body).
|
||||
*
|
||||
* @param {any} research
|
||||
* @param {{ unit?: any }} [options]
|
||||
* @returns {any}
|
||||
*/
|
||||
export const dossierResearchReceipt = (research, { unit } = {}) => {
|
||||
const resolvedUnit = resolveDossierUnit(unit)
|
||||
const slug = research[resolvedUnit.slugField]
|
||||
return {
|
||||
slug,
|
||||
era: research.era,
|
||||
status: 'ok',
|
||||
researchPath: `${resolvedUnit.researchDir}/${slug}.${research.era.toLowerCase()}.research.json`,
|
||||
researchedAt: research.researchedAt,
|
||||
itemCount: research.items.length,
|
||||
gapCount: research.gaps.length,
|
||||
newsCount: research.news.length,
|
||||
gaps: research.gaps.map((gap) => gap.id),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the per-era research files into one view. Fails closed when the slugs
|
||||
* disagree (a mixed bundle would print another city's facts).
|
||||
* disagree (a mixed bundle would print another unit's facts).
|
||||
*
|
||||
* @param {any} researches
|
||||
* @param {{ unit?: any }} [options]
|
||||
* @returns {{ eras: string[], items: any[], news: any[], gaps: any[], researchedAt: string } & Record<string, any>}
|
||||
*/
|
||||
export const mergeDossierResearch = (researches) => {
|
||||
export const mergeDossierResearch = (researches, { unit } = {}) => {
|
||||
const resolvedUnit = resolveDossierUnit(unit)
|
||||
const slugField = resolvedUnit.slugField
|
||||
const list = Array.isArray(researches) ? researches.filter(Boolean) : []
|
||||
if (list.length === 0) throw new Error('Dossiê sem pesquisa: nenhum arquivo por era foi lido.')
|
||||
const slug = list[0].municipalitySlug
|
||||
const slug = list[0][slugField]
|
||||
for (const research of list) {
|
||||
if (research.municipalitySlug !== slug) {
|
||||
if (research[slugField] !== slug) {
|
||||
const noun = resolvedUnit.id === 'institution' ? 'instituições' : 'municípios'
|
||||
throw new Error(
|
||||
`Pesquisas de municípios diferentes ("${slug}" e "${research.municipalitySlug}") — pare e regenere.`,
|
||||
`Pesquisas de ${noun} diferentes ("${slug}" e "${research[slugField]}") — pare e regenere.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -349,7 +506,7 @@ export const mergeDossierResearch = (researches) => {
|
|||
.sort()
|
||||
.at(-1)
|
||||
return {
|
||||
municipalitySlug: slug,
|
||||
[slugField]: slug,
|
||||
eras: list.map((research) => research.era),
|
||||
items,
|
||||
news,
|
||||
|
|
|
|||
67
scripts/lib/dossieUnit.mjs
Normal file
67
scripts/lib/dossieUnit.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Unit seam for the Solla dossiê pipeline (C187, owner: the C186 dossiê).
|
||||
*
|
||||
* The dossiê was born municipality-shaped (C186). C187 adds an INSTITUTION
|
||||
* recorte that must not fork the pipeline, so the shared mechanics
|
||||
* (research contract, print shell, PDF emit, bulletin ledger) take a `unit`
|
||||
* descriptor and default to `MUNICIPALITY_UNIT` — the C186 behaviour stays
|
||||
* byte-identical when no unit is passed.
|
||||
*
|
||||
* Vocabulary note: a unit's `spheres` are the item ABRANGÊNCIA values
|
||||
* (`municipio|regiao|polo` × `instituicao|setor|rede`). The catalog `sphere`
|
||||
* (federal/estadual/…) is a different object and never enters an item row.
|
||||
*/
|
||||
|
||||
export const MUNICIPALITY_UNIT = {
|
||||
id: 'municipality',
|
||||
/** Key that carries the resolved unit slug in research/snapshot files. */
|
||||
slugField: 'municipalitySlug',
|
||||
spheres: ['municipio', 'regiao', 'polo'],
|
||||
defaultSphere: 'municipio',
|
||||
sphereLabels: { municipio: 'município', regiao: 'região', polo: 'polo' },
|
||||
sphereBadgeClass: { municipio: '', regiao: 'scope-region', polo: 'scope-region' },
|
||||
/** Bulletin ranking: a direct item outranks a shared-scope one. */
|
||||
breadthRank: { municipio: 0, regiao: 1, polo: 2 },
|
||||
researchDir: 'data/dossie-solla-cidade',
|
||||
series: 'Série municipal · BA',
|
||||
title: 'Dossiê Solla por cidade',
|
||||
bulletinTitle: 'Boletim informativo modelo',
|
||||
}
|
||||
|
||||
export const INSTITUTION_UNIT = {
|
||||
id: 'institution',
|
||||
slugField: 'institutionSlug',
|
||||
spheres: ['instituicao', 'setor', 'rede'],
|
||||
defaultSphere: 'instituicao',
|
||||
sphereLabels: { instituicao: 'instituição', setor: 'setor', rede: 'rede' },
|
||||
sphereBadgeClass: { instituicao: '', setor: 'scope-sector', rede: 'scope-network' },
|
||||
breadthRank: { instituicao: 0, setor: 1, rede: 2 },
|
||||
researchDir: 'data/dossie-solla-instituicao',
|
||||
series: 'Série institucional · agenda com lastro',
|
||||
title: 'Dossiê Solla por instituição',
|
||||
bulletinTitle: 'Boletim informativo modelo',
|
||||
}
|
||||
|
||||
const DOSSIER_UNITS = {
|
||||
municipality: MUNICIPALITY_UNIT,
|
||||
institution: INSTITUTION_UNIT,
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts an id string or a descriptor; absent → municipality (C186 default).
|
||||
*
|
||||
* @param {any} [unit]
|
||||
* @returns {any}
|
||||
*/
|
||||
export const resolveDossierUnit = (unit) => {
|
||||
if (!unit) return MUNICIPALITY_UNIT
|
||||
if (typeof unit === 'string') {
|
||||
const resolved = DOSSIER_UNITS[unit]
|
||||
if (!resolved) throw new Error(`Unidade de dossiê desconhecida: "${unit}".`)
|
||||
return resolved
|
||||
}
|
||||
return unit
|
||||
}
|
||||
|
||||
/** Central predicate for the dispatch points (dossiê, boletim, blocks). */
|
||||
export const isInstitutionUnit = (unit) => resolveDossierUnit(unit).id === 'institution'
|
||||
67
scripts/lib/readOnlyExtract.mjs
Normal file
67
scripts/lib/readOnlyExtract.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Read-only extraction contract (owner: the C163 report pipeline; reused by the
|
||||
* C187 institutional dossiê). Both extractors run where production lives, only
|
||||
* ever read, and refuse to guess the target: an explicit confirmation env plus
|
||||
* a connection forced read-only.
|
||||
*
|
||||
* Extracted so the actor resolution and the read-only session have one owner —
|
||||
* the institution extractor must not twin the subtle fail-closed recipe.
|
||||
*/
|
||||
|
||||
import {
|
||||
databaseLabel,
|
||||
READ_ONLY_PG_OPTIONS,
|
||||
withReadOnlyDatabaseUrl,
|
||||
} from './cityReportDatabase.mjs'
|
||||
import { databaseHostname, isTruthyEnv } from './cli.mjs'
|
||||
|
||||
/**
|
||||
* Resolves a real campaign actor that can see everything (candidate, then
|
||||
* coordinator). `die` is injected so each CLI keeps its own label/exit.
|
||||
*
|
||||
* @param {any} payload
|
||||
* @param {{ die: (message: string) => void, roles?: string[] }} options
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export const resolveCampaignActor = async (
|
||||
payload,
|
||||
{ die, roles = ['candidate', 'coordinator'] },
|
||||
) => {
|
||||
for (const role of roles) {
|
||||
const result = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { role: { equals: role } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const doc = result.docs[0]
|
||||
if (doc) return { ...doc, collection: 'campaignUser' }
|
||||
}
|
||||
die(
|
||||
`Nenhum campaignUser com papel ${roles.join('/')} encontrado — o relatório exige um ator real que enxergue tudo.`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the explicit opt-in and rewrites `DATABASE_URL` to force
|
||||
* `default_transaction_read_only=on`. Returns the raw URL (for labels).
|
||||
*
|
||||
* @param {{ confirmEnv: string, label: string, die: (message: string) => void, context?: string | null }} params
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
export const enterReadOnlySession = ({ confirmEnv, label, die, context = null }) => {
|
||||
if (!isTruthyEnv(process.env[confirmEnv])) {
|
||||
die(
|
||||
`Opt-in explícito ausente: rode com ${confirmEnv}=1.\n` +
|
||||
` Alvo: ${databaseHostname(process.env.DATABASE_URL) ?? 'DATABASE_URL ausente'} — a leitura é read-only, mas o comando não adivinha o alvo.`,
|
||||
)
|
||||
}
|
||||
const rawDatabaseUrl = process.env.DATABASE_URL
|
||||
process.env.DATABASE_URL = withReadOnlyDatabaseUrl(rawDatabaseUrl, READ_ONLY_PG_OPTIONS)
|
||||
console.log(
|
||||
`[${label}] alvo ${databaseLabel(rawDatabaseUrl)} · sessão read-only${context ? ` · ${context}` : ''}`,
|
||||
)
|
||||
return rawDatabaseUrl
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
126
src/lib/institutionCatalog.ts
Normal file
126
src/lib/institutionCatalog.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import type { SpeechTopic } from '@/lib/speechFacets'
|
||||
|
||||
/**
|
||||
* Institution catalog for the `dossie-solla-instituicao` skill (C187). The
|
||||
* dossiê's institutional recorte is resolved here, fail-closed: a token that is
|
||||
* neither a known slug nor a known name/alias never becomes a slug. Mirrors the
|
||||
* `municipalityCatalog` precedent (static TS source of truth, no DB).
|
||||
*
|
||||
* The `topics` field is the bridge to the internal acervo: `speech` documents
|
||||
* have no institution relationship, so the institutional recorte is
|
||||
* theme→institution. Keep the list conservative — a loose theme would drag
|
||||
* unrelated speeches into the dossiê.
|
||||
*/
|
||||
|
||||
export const INSTITUTION_KINDS = [
|
||||
'universidade',
|
||||
'instituto_federal',
|
||||
'empresa_publica',
|
||||
'autarquia',
|
||||
'orgao_publico',
|
||||
'conselho_classe',
|
||||
'entidade_classe',
|
||||
'categoria_profissional',
|
||||
'movimento',
|
||||
'rede',
|
||||
'outro',
|
||||
] as const
|
||||
export type InstitutionKind = (typeof INSTITUTION_KINDS)[number]
|
||||
|
||||
export const INSTITUTION_KIND_LABELS: Record<InstitutionKind, string> = {
|
||||
universidade: 'Universidade',
|
||||
instituto_federal: 'Instituto Federal',
|
||||
empresa_publica: 'Empresa pública',
|
||||
autarquia: 'Autarquia',
|
||||
orgao_publico: 'Órgão público',
|
||||
conselho_classe: 'Conselho de classe',
|
||||
entidade_classe: 'Entidade de classe',
|
||||
categoria_profissional: 'Categoria profissional',
|
||||
movimento: 'Movimento',
|
||||
rede: 'Rede',
|
||||
outro: 'Outro',
|
||||
}
|
||||
|
||||
export const INSTITUTION_SPHERES = [
|
||||
'federal',
|
||||
'estadual',
|
||||
'municipal',
|
||||
'nao_governamental',
|
||||
] as const
|
||||
export type InstitutionSphere = (typeof INSTITUTION_SPHERES)[number]
|
||||
|
||||
export const INSTITUTION_SPHERE_LABELS: Record<InstitutionSphere, string> = {
|
||||
federal: 'Federal',
|
||||
estadual: 'Estadual',
|
||||
municipal: 'Municipal',
|
||||
nao_governamental: 'Não governamental',
|
||||
}
|
||||
|
||||
export const INSTITUTION_SCOPES = ['nacional', 'BA'] as const
|
||||
export type InstitutionScope = (typeof INSTITUTION_SCOPES)[number]
|
||||
|
||||
export const INSTITUTION_SCOPE_LABELS: Record<InstitutionScope, string> = {
|
||||
nacional: 'Abrangência nacional',
|
||||
BA: 'Abrangência BA',
|
||||
}
|
||||
|
||||
export interface InstitutionCatalogEntry {
|
||||
slug: string
|
||||
name: string
|
||||
aliases: readonly string[]
|
||||
kind: InstitutionKind
|
||||
sphere: InstitutionSphere
|
||||
scope: InstitutionScope
|
||||
topics: readonly SpeechTopic[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal v1 seed (intention plan §Dados da decisão). Adding an institution is
|
||||
* a data edit here — the escape hatch `--slug=<x> --name="<Nome>"` covers a
|
||||
* one-off without inventing a catalog entry.
|
||||
*/
|
||||
export const institutionCatalog: readonly InstitutionCatalogEntry[] = [
|
||||
{
|
||||
slug: 'ufba',
|
||||
name: 'UFBA',
|
||||
aliases: ['Universidade Federal da Bahia'],
|
||||
kind: 'universidade',
|
||||
sphere: 'federal',
|
||||
scope: 'BA',
|
||||
topics: ['educacao', 'ciencia-tecnologia', 'saude'],
|
||||
},
|
||||
{
|
||||
slug: 'correios',
|
||||
name: 'Correios',
|
||||
aliases: ['ECT', 'Empresa Brasileira de Correios e Telégrafos'],
|
||||
kind: 'empresa_publica',
|
||||
sphere: 'federal',
|
||||
scope: 'nacional',
|
||||
topics: ['economia-trabalho', 'infraestrutura'],
|
||||
},
|
||||
{
|
||||
slug: 'enfermagem',
|
||||
name: 'Enfermagem',
|
||||
aliases: ['ABEn', 'ABEn-BA', 'COFEN', 'Coren-BA'],
|
||||
kind: 'categoria_profissional',
|
||||
sphere: 'federal',
|
||||
scope: 'nacional',
|
||||
topics: ['saude'],
|
||||
},
|
||||
]
|
||||
|
||||
const catalogBySlug = new Map(institutionCatalog.map((entry) => [entry.slug, entry]))
|
||||
|
||||
export const getInstitutionCatalogEntry = (slug: string): InstitutionCatalogEntry | undefined =>
|
||||
catalogBySlug.get(slug)
|
||||
|
||||
export const isInstitutionSlug = (value: string): boolean => catalogBySlug.has(value)
|
||||
|
||||
export const institutionSlugs = (): readonly string[] =>
|
||||
institutionCatalog.map((entry) => entry.slug)
|
||||
|
||||
/** Every spelling declared for an entry (canonical name + aliases). */
|
||||
export const institutionSpellings = (entry: InstitutionCatalogEntry): readonly string[] => [
|
||||
entry.name,
|
||||
...entry.aliases,
|
||||
]
|
||||
49
src/lib/institutionNameAliases.ts
Normal file
49
src/lib/institutionNameAliases.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import {
|
||||
type InstitutionCatalogEntry,
|
||||
institutionCatalog,
|
||||
institutionSpellings,
|
||||
} from '@/lib/institutionCatalog'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
|
||||
/**
|
||||
* Institution-name resolution for the institutional dossiê (C187). Mirrors the
|
||||
* `resolveMunicipalityName` fold: accent/case/punctuation-insensitive lookup
|
||||
* over the catalog's canonical name + aliases. Returns null when unknown — the
|
||||
* caller keeps the fail-closed policy (skill resolves aliases, never invents a
|
||||
* slug). A fold shared by two different entries is treated as ambiguous (null),
|
||||
* so the operator must pass the canonical slug.
|
||||
*/
|
||||
|
||||
const entriesBySearchValue = new Map<string, Set<InstitutionCatalogEntry>>()
|
||||
|
||||
const register = (spelling: string, entry: InstitutionCatalogEntry) => {
|
||||
const key = normalizeSearchPhrase(spelling)
|
||||
if (!key) return
|
||||
const bucket = entriesBySearchValue.get(key)
|
||||
if (bucket) bucket.add(entry)
|
||||
else entriesBySearchValue.set(key, new Set([entry]))
|
||||
}
|
||||
|
||||
for (const entry of institutionCatalog) {
|
||||
register(entry.slug, entry)
|
||||
for (const spelling of institutionSpellings(entry)) register(spelling, entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a token (slug, canonical name or alias) to its catalog entry. Returns
|
||||
* null when unknown or when the fold is ambiguous.
|
||||
*/
|
||||
export const resolveInstitutionEntry = (
|
||||
value: string | undefined | null,
|
||||
): InstitutionCatalogEntry | null => {
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const bucket = entriesBySearchValue.get(normalizeSearchPhrase(trimmed))
|
||||
if (!bucket || bucket.size !== 1) return null
|
||||
return [...bucket][0] ?? null
|
||||
}
|
||||
|
||||
/** Canonical name for a token, or null when unknown/ambiguous. */
|
||||
export const resolveInstitutionName = (value: string | undefined | null): string | null =>
|
||||
resolveInstitutionEntry(value)?.name ?? null
|
||||
119
tests/int/institutionSnapshot.int.spec.ts
Normal file
119
tests/int/institutionSnapshot.int.spec.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { composeInstitutionSnapshot } from '../../scripts/institutionSnapshot.mjs'
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
// C187: the institutional snapshot recorte is theme→institution (the acervo has
|
||||
// no institution relationship). Pins the read projection (no PII), the topic
|
||||
// filter and the explicit gap when the recorte is empty.
|
||||
|
||||
let payload: Payload
|
||||
const createdSpeechIds: number[] = []
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const identity = {
|
||||
slug: 'ufba',
|
||||
name: 'UFBA',
|
||||
kind: 'universidade',
|
||||
kindLabel: 'Universidade',
|
||||
sphere: 'federal',
|
||||
sphereLabel: 'Federal',
|
||||
scope: 'BA',
|
||||
scopeLabel: 'Abrangência BA',
|
||||
aliases: ['Universidade Federal da Bahia'],
|
||||
topics: ['ciencia-tecnologia'],
|
||||
}
|
||||
|
||||
describe('institution snapshot (C187)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdSpeechIds) {
|
||||
try {
|
||||
await payload.delete({ collection: 'speech', id, depth: 0, overrideAccess: true })
|
||||
} catch {
|
||||
// already deleted
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it(
|
||||
'projects the topic-matched speeches without PII and records the recorte',
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const candidate = await fixtures.createCampaignUser('candidate')
|
||||
|
||||
const speech = await payload.create({
|
||||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: `test-inst-${randomUUID()}`,
|
||||
speechAt: '2024-05-10T17:28',
|
||||
type: 'DISCURSO',
|
||||
phase: 'Discurso',
|
||||
summary: 'Defesa do financiamento das universidades federais.',
|
||||
officialTranscript: 'O SR. JORGE SOLLA (Bloco/PT - BA) - Sr. Presidente...',
|
||||
keywords: ['UFBA', 'universidade'],
|
||||
topics: ['ciencia-tecnologia'],
|
||||
classifiedBy: 'gazetteer',
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
createdSpeechIds.push(speech.id)
|
||||
|
||||
const actor = { ...candidate, collection: 'campaignUser' as const }
|
||||
const snapshot = await composeInstitutionSnapshot({
|
||||
payload,
|
||||
actor,
|
||||
identity,
|
||||
readAt: '2026-09-17T11:00:00.000Z',
|
||||
codeSha: 'test-sha',
|
||||
database: 'localhost:5432/teqo_test',
|
||||
})
|
||||
|
||||
expect(snapshot.meta.kind).toBe('institution')
|
||||
expect(snapshot.institution).toMatchObject({ slug: 'ufba', name: 'UFBA' })
|
||||
expect(snapshot.institution.topics).toEqual(['ciencia-tecnologia'])
|
||||
expect(snapshot.speeches.rows.some((row: { id: number }) => row.id === speech.id)).toBe(true)
|
||||
const row = snapshot.speeches.rows.find(
|
||||
(candidateRow: { id: number }) => candidateRow.id === speech.id,
|
||||
)!
|
||||
expect('officialTranscript' in row).toBe(false)
|
||||
expect(JSON.stringify(snapshot)).not.toContain('@')
|
||||
},
|
||||
)
|
||||
|
||||
it('declares an explicit gap when the institution has no topic', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const candidate = await fixtures.createCampaignUser('candidate')
|
||||
const actor = { ...candidate, collection: 'campaignUser' as const }
|
||||
|
||||
const snapshot = await composeInstitutionSnapshot({
|
||||
payload,
|
||||
actor,
|
||||
identity: { ...identity, slug: 'sem-tema', name: 'Sem Tema', topics: [] },
|
||||
readAt: '2026-09-17T11:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(snapshot.speeches.rows).toEqual([])
|
||||
expect(snapshot.gaps).toContainEqual(expect.objectContaining({ id: 'acervo_sem_tema' }))
|
||||
})
|
||||
})
|
||||
45
tests/unit/buildPdf.unit.spec.ts
Normal file
45
tests/unit/buildPdf.unit.spec.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
A4_HEIGHT_PX,
|
||||
A4_PAGE_BUDGET_PX,
|
||||
A4_WIDTH_PX,
|
||||
assertPageFits,
|
||||
PAGE_FIT_TOLERANCE_PX,
|
||||
} from '../../scripts/lib/buildPdf.mjs'
|
||||
|
||||
// C187 extracted the A4 print plumbing so the institution builder (third
|
||||
// consumer) shares one owner with the C186 dossiê. Pins the mm→px budget and
|
||||
// the overflow guard that turns a cut page into a hard failure.
|
||||
|
||||
describe('A4 print budget', () => {
|
||||
it('derives height/width from 297×210mm at 96dpi with rounding slack', () => {
|
||||
expect(A4_HEIGHT_PX).toBe(Math.round(297 * (96 / 25.4)))
|
||||
expect(A4_WIDTH_PX).toBe(Math.round(210 * (96 / 25.4)))
|
||||
expect(A4_PAGE_BUDGET_PX).toBe(A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertPageFits', () => {
|
||||
it('passes when no page overflows', () => {
|
||||
expect(() => assertPageFits([], 'O dossiê', 'hint')).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws listing the overflowing page and its height', () => {
|
||||
expect(() =>
|
||||
assertPageFits(
|
||||
[{ page: 'resumo', height: A4_PAGE_BUDGET_PX + 50 }],
|
||||
'O dossiê',
|
||||
'corte copy',
|
||||
),
|
||||
).toThrow(/resumo: \d+px.*corte copy/s)
|
||||
})
|
||||
})
|
||||
|
||||
import { emitHtmlPairPdf } from '../../scripts/lib/buildPdf.mjs'
|
||||
|
||||
describe('emitHtmlPairPdf', () => {
|
||||
it('is exported as the single owner of the two-document emit contract', () => {
|
||||
expect(typeof emitHtmlPairPdf).toBe('function')
|
||||
})
|
||||
})
|
||||
312
tests/unit/dossieInstitution.unit.spec.ts
Normal file
312
tests/unit/dossieInstitution.unit.spec.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildDossierReport } from '../../scripts/lib/dossieBlocks.mjs'
|
||||
import { buildBulletin } from '../../scripts/lib/dossieBulletin.mjs'
|
||||
import { renderBulletinHtml } from '../../scripts/lib/dossieBulletinRender.mjs'
|
||||
import { renderDossierHtml, renderDossierMd } from '../../scripts/lib/dossieRender.mjs'
|
||||
import {
|
||||
mergeDossierResearch,
|
||||
normalizeDossierResearchInput,
|
||||
} from '../../scripts/lib/dossieResearch.mjs'
|
||||
import { INSTITUTION_UNIT } from '../../scripts/lib/dossieUnit.mjs'
|
||||
|
||||
// C187: institution content model + render. Grounds the institutional recorte:
|
||||
// identity badges, `instituicao|setor|rede` never summed, per-row phase/source,
|
||||
// empty-era state instead of silent omission, and a one-page boletim that
|
||||
// inherits only sourced facts.
|
||||
|
||||
const generatedAt = new Date('2026-09-17T12:00:00.000Z')
|
||||
|
||||
const snapshot = {
|
||||
meta: { readAt: '2026-09-17T11:00:00.000Z' },
|
||||
institution: {
|
||||
slug: 'ufba',
|
||||
name: 'UFBA',
|
||||
kind: 'universidade',
|
||||
kindLabel: 'Universidade',
|
||||
sphere: 'federal',
|
||||
sphereLabel: 'Federal',
|
||||
scope: 'BA',
|
||||
scopeLabel: 'Abrangência BA',
|
||||
aliases: ['Universidade Federal da Bahia'],
|
||||
topics: ['educacao', 'ciencia-tecnologia'],
|
||||
},
|
||||
speeches: {
|
||||
topics: ['ciencia-tecnologia'],
|
||||
totalCount: 1,
|
||||
rows: [
|
||||
{
|
||||
id: 11,
|
||||
speechAt: '2024-05-10T00:00:00.000Z',
|
||||
summary: 'Defesa do financiamento das universidades federais.',
|
||||
officialTextUrl: 'https://camara.test/discurso-ufba',
|
||||
mentionExcerpt: 'a UFBA e as demais universidades federais',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const item = (id: string, _era: 'A' | 'B' | 'C', overrides: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
answer: `Ação ${id} documentada`,
|
||||
sphere: 'instituicao',
|
||||
sourceUrl: `https://ufba.test/${id}`,
|
||||
sourceDate: '2024-06-01',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const eraFile = (era: 'A' | 'B' | 'C', items: unknown[]) => ({
|
||||
institutionSlug: 'ufba',
|
||||
era,
|
||||
researchedAt: '2026-09-17T10:00:00.000Z',
|
||||
items,
|
||||
news: [
|
||||
{
|
||||
title: 'Notícia institucional',
|
||||
outlet: 'UFBA',
|
||||
publishedAt: '2024-05-01',
|
||||
url: 'https://ufba.test/noticia',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const research = mergeDossierResearch(
|
||||
[
|
||||
normalizeDossierResearchInput(
|
||||
eraFile('A', [
|
||||
item('era_a_formacao', 'A'),
|
||||
item('era_a_vinculo', 'A', { sphere: 'setor' }),
|
||||
item('era_a_sesab', 'A', { sphere: 'rede' }),
|
||||
]),
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
),
|
||||
normalizeDossierResearchInput(
|
||||
eraFile('B', [
|
||||
item('era_b_sesab', 'B', {
|
||||
numbers: [{ label: 'Investimento', value: 'R$ 2,0 mi', year: '2012', phase: 'pago' }],
|
||||
}),
|
||||
]),
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
),
|
||||
normalizeDossierResearchInput(
|
||||
eraFile('C', [
|
||||
item('era_c_emendas', 'C', {
|
||||
numbers: [{ label: 'Emenda', value: 'R$ 5,0 mi', year: '2024', phase: 'empenhado' }],
|
||||
}),
|
||||
item('era_c_titulos', 'C', { answer: 'Título de cidadão honorário' }),
|
||||
]),
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
),
|
||||
],
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
)
|
||||
|
||||
const report = buildDossierReport({
|
||||
snapshot,
|
||||
research,
|
||||
generatedAt,
|
||||
unit: INSTITUTION_UNIT,
|
||||
})
|
||||
|
||||
describe('institution era without evidence', () => {
|
||||
const emptyReport = buildDossierReport({
|
||||
snapshot,
|
||||
research: mergeDossierResearch(
|
||||
(['A', 'B', 'C'] as const).map((era) =>
|
||||
normalizeDossierResearchInput(eraFile(era, []), { unit: INSTITUTION_UNIT }),
|
||||
),
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
),
|
||||
generatedAt,
|
||||
unit: INSTITUTION_UNIT,
|
||||
})
|
||||
|
||||
it('marks an evidence-less era as an explicit gap, never a silent blank', () => {
|
||||
expect(emptyReport.eras.every((era) => era.empty)).toBe(true)
|
||||
const html = renderDossierHtml(emptyReport)
|
||||
expect(html).toContain('Lacuna explícita · não é “zero”')
|
||||
expect(html).toContain('Nenhuma fonte suficiente foi localizada para esta era.')
|
||||
expect(html).toContain('empty-panel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDossierReport (institution unit)', () => {
|
||||
it('carries the institution identity and badges', () => {
|
||||
expect(report.unit.id).toBe('institution')
|
||||
expect(report.meta.subjectName).toBe('UFBA')
|
||||
expect(report.meta.identityBadges).toEqual(['Universidade', 'Federal', 'Abrangência BA'])
|
||||
})
|
||||
|
||||
it('keeps all three eras, marking evidence-less ones as explicit gaps', () => {
|
||||
expect(report.eras.map((era) => era.id)).toEqual(['A', 'B', 'C'])
|
||||
expect(report.eras.every((era) => typeof era.empty === 'boolean')).toBe(true)
|
||||
})
|
||||
|
||||
it('never sums setor/rede into the instituicao list', () => {
|
||||
const listFor = (key: string) => report.reach.lists.find((list) => list.key === key)
|
||||
expect(listFor('institution')?.items.every((row) => row.sphere === 'instituicao')).toBe(true)
|
||||
expect(listFor('institution')?.items.some((row) => row.id === 'era_a_vinculo')).toBe(false)
|
||||
expect(listFor('sector')?.total).toBe(1)
|
||||
expect(listFor('network')?.total).toBe(1)
|
||||
})
|
||||
|
||||
it('declares overflow instead of truncating silently (C188 rule)', () => {
|
||||
for (const list of report.reach.lists) {
|
||||
expect(list).toMatchObject({ total: expect.any(Number), omitted: expect.any(Number) })
|
||||
}
|
||||
expect(report.page1.deliveries.omitted).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('prints "e mais N" with the right singular when a list is capped', () => {
|
||||
const manyItems = [
|
||||
item('era_c_emendas', 'C', { sphere: 'setor' }),
|
||||
item('era_c_atuacao', 'C', { sphere: 'setor' }),
|
||||
item('era_c_discursos', 'C', { sphere: 'setor' }),
|
||||
item('era_c_proposicoes', 'C', { sphere: 'setor' }),
|
||||
item('era_c_titulos', 'C', { sphere: 'setor' }),
|
||||
item('era_c_parcerias', 'C', { sphere: 'setor' }),
|
||||
]
|
||||
const cappedReport = buildDossierReport({
|
||||
snapshot,
|
||||
research: mergeDossierResearch(
|
||||
[normalizeDossierResearchInput(eraFile('C', manyItems), { unit: INSTITUTION_UNIT })],
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
),
|
||||
generatedAt,
|
||||
unit: INSTITUTION_UNIT,
|
||||
})
|
||||
const list = cappedReport.reach.lists.find((row) => row.key === 'sector')
|
||||
expect(list?.omitted).toBeGreaterThan(0)
|
||||
const html = renderDossierHtml(cappedReport)
|
||||
expect(html).toContain(`${list?.omitted} item`)
|
||||
expect(html, 'the plural noun must not be chopped into "iten"').not.toContain('iten ')
|
||||
})
|
||||
|
||||
it('derives bulletinFacts only from sourced items', () => {
|
||||
expect(report.bulletinFacts.length).toBeGreaterThan(0)
|
||||
expect(report.bulletinFacts.every((fact) => Boolean(fact.sourceUrl))).toBe(true)
|
||||
})
|
||||
|
||||
it('consumes the read-only acervo rows as sourced evidence (never silent)', () => {
|
||||
expect(report.acervo.items.length).toBe(1)
|
||||
expect(report.acervo.items[0]).toMatchObject({ period: '2024', sourceUrl: expect.any(String) })
|
||||
expect(report.bulletinFacts.some((fact) => fact.id === 'fala-11')).toBe(true)
|
||||
expect(renderDossierMd(report)).toContain('## Acervo interno (read-only)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderDossierHtml/Md (institution unit)', () => {
|
||||
const html = renderDossierHtml(report)
|
||||
const md = renderDossierMd(report)
|
||||
|
||||
it('anchors every institution page', () => {
|
||||
for (const anchor of [
|
||||
'capa',
|
||||
'resumo',
|
||||
'era-a',
|
||||
'era-b',
|
||||
'era-c',
|
||||
'abrangencia',
|
||||
'lacunas',
|
||||
'fontes',
|
||||
]) {
|
||||
expect(html).toContain(`data-page="${anchor}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it('ports the institutional classes and identity badges', () => {
|
||||
expect(html).toContain('identity-badge')
|
||||
expect(html).toContain('scope-sector')
|
||||
expect(html).toContain('scope-network')
|
||||
expect(html).toContain('phase-paid')
|
||||
expect(html).toContain('phase-pending')
|
||||
})
|
||||
|
||||
it('prints the non-summable rule and the defeso note', () => {
|
||||
expect(html).toContain('Setor/rede não é a instituição')
|
||||
expect(html).toContain('Nota de defeso eleitoral 2026')
|
||||
})
|
||||
|
||||
it('renders the honors scene with date, nature and the reading panels', () => {
|
||||
expect(html).toContain('Data, natureza e fonte primária')
|
||||
expect(html).toContain('Como ler')
|
||||
expect(html).toContain('Vínculo documentado')
|
||||
expect(html).toContain('Honraria documentada')
|
||||
})
|
||||
|
||||
it('escapes untrusted research text', () => {
|
||||
const hostile = buildDossierReport({
|
||||
snapshot,
|
||||
research: mergeDossierResearch(
|
||||
[
|
||||
normalizeDossierResearchInput(
|
||||
eraFile('C', [item('era_c_emendas', 'C', { answer: '<script>alert(1)</script>' })]),
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
),
|
||||
],
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
),
|
||||
generatedAt,
|
||||
unit: INSTITUTION_UNIT,
|
||||
})
|
||||
const hostileHtml = renderDossierHtml(hostile)
|
||||
expect(hostileHtml).not.toContain('<script>alert(1)</script>')
|
||||
expect(hostileHtml).toContain('<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')
|
||||
})
|
||||
})
|
||||
95
tests/unit/dossieInstitutionBatchSkill.unit.spec.ts
Normal file
95
tests/unit/dossieInstitutionBatchSkill.unit.spec.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// C187: pins the batch contract of the dossie-solla-instituicao skill, the
|
||||
// per-era fan-out and the researcher-only role of its subagent. The batch entry
|
||||
// and the era briefing are prompt text, so the contract lives in the SKILL.md
|
||||
// prose — this guard fails the build if the batch/resolver/receipt/fail-closed
|
||||
// wording is dropped, or if the subagent regains the extract/build pipeline.
|
||||
|
||||
const repoRoot = process.cwd()
|
||||
|
||||
const read = (path: string): string => readFileSync(resolve(repoRoot, path), 'utf8')
|
||||
|
||||
const skill = read('.agents/skills/dossie-solla-instituicao/SKILL.md')
|
||||
const researcher = read('.opencode/agent/dossie-solla-instituicao.md')
|
||||
|
||||
describe('skill /dossie-solla-instituicao documents the batch contract', () => {
|
||||
it('keeps the comma-separated batch entry and the single-institution case', () => {
|
||||
expect(skill).toContain('## Lote (várias instituições)')
|
||||
expect(skill, 'batch example must be literal').toContain(
|
||||
'/dossie-solla-instituicao UFBA, Correios',
|
||||
)
|
||||
expect(skill, 'single-institution must remain valid').toContain(
|
||||
'/dossie-solla-instituicao UFBA',
|
||||
)
|
||||
expect(skill, 'comma is the separator').toContain('vírgula')
|
||||
expect(skill, 'N=1 is the preserved path').toContain('N=1')
|
||||
})
|
||||
|
||||
it('pins the fail-closed catalog resolution and the one-off escape', () => {
|
||||
expect(skill).toContain('isInstitutionSlug')
|
||||
expect(skill).toContain('resolveInstitutionName')
|
||||
expect(skill, 'unknown token must fail closed').toContain('falha fechado')
|
||||
expect(skill, 'must never invent a slug').toContain('nunca')
|
||||
expect(skill, 'the one-off escape must be documented').toContain('--slug=<x> --name="<Nome>"')
|
||||
})
|
||||
|
||||
it('documents the staged pipeline with a serialized extraction', () => {
|
||||
expect(skill).toContain('## Pipeline (etapas)')
|
||||
expect(skill).toContain('serializada')
|
||||
expect(skill, 'isolated failure must be visible as partial success').toContain(
|
||||
'sucesso parcial',
|
||||
)
|
||||
expect(skill, 'summary lists status per entry').toContain('(ok|failed)')
|
||||
})
|
||||
|
||||
it('pins the per-era research fan-out and the concurrency guard', () => {
|
||||
expect(skill).toContain('## Briefing por era (A/B/C)')
|
||||
expect(skill, 'the three eras are the recorte').toContain('Era A')
|
||||
expect(skill, 'the three eras are the recorte').toContain('Era B')
|
||||
expect(skill, 'the three eras are the recorte').toContain('Era C')
|
||||
expect(skill, 'the fan-out must be bounded').toContain('MAX_RESEARCHERS_IN_FLIGHT')
|
||||
})
|
||||
|
||||
it('documents the dossiê and the boletim outputs', () => {
|
||||
expect(skill).toContain('-dossie.pdf')
|
||||
expect(skill).toContain('-boletim.pdf')
|
||||
expect(skill, 'the boletim has no source list').toContain('Sem declaração de fontes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('finite researcher receipt (C187)', () => {
|
||||
it('declares the short receipt and its fields', () => {
|
||||
expect(skill).toContain('## Recibo do researcher')
|
||||
for (const field of ['itemCount', 'gapCount', 'newsCount', 'failureReason']) {
|
||||
expect(skill, `receipt must declare ${field}`).toContain(field)
|
||||
}
|
||||
expect(skill, 'receipt must never carry the body').toMatch(/corpo\s+do\s+`research\.json`/)
|
||||
})
|
||||
|
||||
it('declares the per-era JSON contract', () => {
|
||||
expect(skill).toContain('## Contrato dos JSONs')
|
||||
expect(skill, 'era is required in the file').toContain('"era": "C"')
|
||||
expect(skill, 'the unit slug field is institutionSlug').toContain('"institutionSlug": "ufba"')
|
||||
for (const id of ['era_a_vinculo', 'era_b_convenios', 'era_c_parcerias']) {
|
||||
expect(skill, `checklist must list ${id}`).toContain(id)
|
||||
}
|
||||
expect(skill, 'setor/rede is never summed').toMatch(/nunca\s+(\*\*)?são?\s+somados/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent .opencode/agent/dossie-solla-instituicao.md is researcher-only', () => {
|
||||
it('stays a subagent that writes the era research.json and returns the receipt', () => {
|
||||
expect(researcher).toMatch(/^---\n[\s\S]*mode: subagent[\s\S]*\n---/)
|
||||
expect(researcher).toContain('researcher')
|
||||
expect(researcher).toContain('.c.research.json')
|
||||
expect(researcher).toContain('Recibo do researcher')
|
||||
})
|
||||
|
||||
it('does not run the deterministic extract/build stages', () => {
|
||||
expect(researcher, 'researcher must not run the extractor CLI').not.toContain('--institution=')
|
||||
expect(researcher, 'researcher must not run the builder CLI').not.toContain('--snapshot=')
|
||||
})
|
||||
})
|
||||
128
tests/unit/dossieInstitutionResearch.unit.spec.ts
Normal file
128
tests/unit/dossieInstitutionResearch.unit.spec.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DOSSIER_RESEARCH_CHECKLIST_IDS,
|
||||
dossierChecklistForEra,
|
||||
dossierResearchReceipt,
|
||||
mergeDossierResearch,
|
||||
normalizeDossierResearchInput,
|
||||
} from '../../scripts/lib/dossieResearch.mjs'
|
||||
import { INSTITUTION_UNIT } from '../../scripts/lib/dossieUnit.mjs'
|
||||
|
||||
// C187: the institution branch of the shared research contract. Same eras,
|
||||
// institution slug field and `instituicao|setor|rede` spheres; the C186
|
||||
// municipality behaviour is untouched (see dossieResearch.unit.spec.ts).
|
||||
|
||||
const validItem = (id: string, overrides: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
answer: 'Entrega documentada.',
|
||||
sphere: 'instituicao',
|
||||
sourceUrl: 'https://ufba.test/ato',
|
||||
sourceDate: '2024-02-01',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const eraResearch = (era: 'A' | 'B' | 'C', overrides: Record<string, unknown> = {}) => ({
|
||||
institutionSlug: 'ufba',
|
||||
era,
|
||||
researchedAt: '2026-09-17T10:00:00.000Z',
|
||||
items: dossierChecklistForEra(era, INSTITUTION_UNIT).map((item) => validItem(item.id)),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('institution checklist', () => {
|
||||
it('exposes an institution checklist id per era (distinct ids)', () => {
|
||||
const ids = [
|
||||
...dossierChecklistForEra('A', INSTITUTION_UNIT),
|
||||
...dossierChecklistForEra('B', INSTITUTION_UNIT),
|
||||
...dossierChecklistForEra('C', INSTITUTION_UNIT),
|
||||
].map((row) => row.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
expect(ids).toContain('era_a_vinculo')
|
||||
expect(ids).toContain('era_c_parcerias')
|
||||
// Municipality ids remain the C186 public surface.
|
||||
expect(DOSSIER_RESEARCH_CHECKLIST_IDS).toContain('era_a_formacao')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeDossierResearchInput (institution unit)', () => {
|
||||
it('requires institutionSlug and keeps institution items', () => {
|
||||
const research = normalizeDossierResearchInput(eraResearch('C'), { unit: INSTITUTION_UNIT })
|
||||
expect(research.institutionSlug).toBe('ufba')
|
||||
expect(research.items.length).toBe(dossierChecklistForEra('C', INSTITUTION_UNIT).length)
|
||||
expect(research.items.every((item) => item.sphere === 'instituicao')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects the municipality slug field', () => {
|
||||
expect(() =>
|
||||
normalizeDossierResearchInput(
|
||||
{ municipalitySlug: 'ilheus', era: 'C', researchedAt: '2026-09-17T10:00:00.000Z' },
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
),
|
||||
).toThrow(/institutionSlug/)
|
||||
})
|
||||
|
||||
it('turns an invalid sphere into a gap with the institution vocabulary', () => {
|
||||
const items = dossierChecklistForEra('A', INSTITUTION_UNIT).map((item) => validItem(item.id))
|
||||
items[0] = validItem(items[0].id, { sphere: 'municipio' })
|
||||
const research = normalizeDossierResearchInput(eraResearch('A', { items }), {
|
||||
unit: INSTITUTION_UNIT,
|
||||
})
|
||||
expect(research.gaps).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: items[0].id,
|
||||
reason: expect.stringMatching(/instituicao\/setor\/rede/),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the sphere to instituicao and keeps setor/rede', () => {
|
||||
const research = normalizeDossierResearchInput(
|
||||
eraResearch('B', {
|
||||
items: [
|
||||
validItem('era_b_sesab', { sphere: undefined }),
|
||||
validItem('era_b_equipamentos', { sphere: 'setor' }),
|
||||
validItem('era_b_programas', { sphere: 'rede' }),
|
||||
],
|
||||
}),
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
)
|
||||
const byId = Object.fromEntries(research.items.map((item) => [item.id, item.sphere]))
|
||||
expect(byId.era_b_sesab).toBe('instituicao')
|
||||
expect(byId.era_b_equipamentos).toBe('setor')
|
||||
expect(byId.era_b_programas).toBe('rede')
|
||||
})
|
||||
|
||||
it('builds an institution receipt with the institution research path', () => {
|
||||
const receipt = dossierResearchReceipt(
|
||||
normalizeDossierResearchInput(eraResearch('A'), { unit: INSTITUTION_UNIT }),
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
)
|
||||
expect(receipt.slug).toBe('ufba')
|
||||
expect(receipt.researchPath).toBe('data/dossie-solla-instituicao/ufba.a.research.json')
|
||||
expect(receipt.itemCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeDossierResearch (institution unit)', () => {
|
||||
it('merges the institution eras', () => {
|
||||
const merged = mergeDossierResearch(
|
||||
(['A', 'B', 'C'] as const).map((era) =>
|
||||
normalizeDossierResearchInput(eraResearch(era), { unit: INSTITUTION_UNIT }),
|
||||
),
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
)
|
||||
expect(merged.institutionSlug).toBe('ufba')
|
||||
expect(merged.eras).toEqual(['A', 'B', 'C'])
|
||||
})
|
||||
|
||||
it('fails closed when institution slugs disagree', () => {
|
||||
const a = normalizeDossierResearchInput(eraResearch('A'), { unit: INSTITUTION_UNIT })
|
||||
const b = normalizeDossierResearchInput(eraResearch('B', { institutionSlug: 'correios' }), {
|
||||
unit: INSTITUTION_UNIT,
|
||||
})
|
||||
expect(() => mergeDossierResearch([a, b], { unit: INSTITUTION_UNIT })).toThrow(
|
||||
/instituições diferentes/,
|
||||
)
|
||||
})
|
||||
})
|
||||
62
tests/unit/institutionCatalog.unit.spec.ts
Normal file
62
tests/unit/institutionCatalog.unit.spec.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getInstitutionCatalogEntry,
|
||||
institutionCatalog,
|
||||
isInstitutionSlug,
|
||||
} from '../../src/lib/institutionCatalog'
|
||||
import {
|
||||
resolveInstitutionEntry,
|
||||
resolveInstitutionName,
|
||||
} from '../../src/lib/institutionNameAliases'
|
||||
|
||||
// C187: the institutional dossiê resolves its recorte in a catalog and fails
|
||||
// closed. This pins the v1 seed, the slug/name/alias folds and the ambiguity
|
||||
// policy (a fold shared by two entries resolves to null, never to a guess).
|
||||
|
||||
describe('institutionCatalog', () => {
|
||||
it('seeds UFBA, Correios and Enfermagem with kind/sphere/scope/topics', () => {
|
||||
expect(institutionCatalog.map((entry) => entry.slug).sort()).toEqual([
|
||||
'correios',
|
||||
'enfermagem',
|
||||
'ufba',
|
||||
])
|
||||
for (const entry of institutionCatalog) {
|
||||
expect(entry.name.length).toBeGreaterThan(0)
|
||||
expect(entry.topics.length).toBeGreaterThan(0)
|
||||
}
|
||||
expect(getInstitutionCatalogEntry('ufba')).toMatchObject({
|
||||
kind: 'universidade',
|
||||
sphere: 'federal',
|
||||
scope: 'BA',
|
||||
})
|
||||
expect(getInstitutionCatalogEntry('enfermagem')).toMatchObject({
|
||||
kind: 'categoria_profissional',
|
||||
scope: 'nacional',
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts only catalog slugs', () => {
|
||||
expect(isInstitutionSlug('ufba')).toBe(true)
|
||||
expect(isInstitutionSlug('correios')).toBe(true)
|
||||
expect(isInstitutionSlug('universidade-federal-da-bahia')).toBe(false)
|
||||
expect(isInstitutionSlug('ufba-fake')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveInstitutionName', () => {
|
||||
it('resolves canonical name and aliases accent/case-insensitively', () => {
|
||||
expect(resolveInstitutionName('UFBA')).toBe('UFBA')
|
||||
expect(resolveInstitutionName('ufba')).toBe('UFBA')
|
||||
expect(resolveInstitutionName('Universidade Federal da Bahia')).toBe('UFBA')
|
||||
expect(resolveInstitutionName('correios')).toBe('Correios')
|
||||
expect(resolveInstitutionName('ECT')).toBe('Correios')
|
||||
expect(resolveInstitutionEntry('ABEn-BA')?.slug).toBe('enfermagem')
|
||||
})
|
||||
|
||||
it('fails closed on unknown tokens', () => {
|
||||
expect(resolveInstitutionName('UFRB')).toBeNull()
|
||||
expect(resolveInstitutionName('')).toBeNull()
|
||||
expect(resolveInstitutionEntry(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
64
tests/unit/readOnlyExtract.unit.spec.ts
Normal file
64
tests/unit/readOnlyExtract.unit.spec.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { enterReadOnlySession, resolveCampaignActor } from '../../scripts/lib/readOnlyExtract.mjs'
|
||||
|
||||
// C187 extracted the read-only extraction contract so the institution extractor
|
||||
// does not twin it. Pins the fail-closed opt-in, the forced read-only URL and
|
||||
// the actor resolution order.
|
||||
|
||||
const originalDatabaseUrl = process.env.DATABASE_URL
|
||||
|
||||
afterEach(() => {
|
||||
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL
|
||||
else process.env.DATABASE_URL = originalDatabaseUrl
|
||||
delete process.env.READONLY_TEST_CONFIRM
|
||||
})
|
||||
|
||||
describe('enterReadOnlySession', () => {
|
||||
it('refuses to run without the explicit confirmation env', () => {
|
||||
const die = vi.fn((message: string) => {
|
||||
throw new Error(message)
|
||||
})
|
||||
expect(() =>
|
||||
enterReadOnlySession({ confirmEnv: 'READONLY_TEST_CONFIRM', label: 'test', die }),
|
||||
).toThrow(/READONLY_TEST_CONFIRM=1/)
|
||||
expect(die).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forces default_transaction_read_only on and returns the raw url', () => {
|
||||
process.env.READONLY_TEST_CONFIRM = '1'
|
||||
process.env.DATABASE_URL = 'postgresql://user:pass@127.0.0.1:5432/teqo_test'
|
||||
const die = vi.fn()
|
||||
const raw = enterReadOnlySession({
|
||||
confirmEnv: 'READONLY_TEST_CONFIRM',
|
||||
label: 'test',
|
||||
die,
|
||||
context: 'instituição ufba',
|
||||
})
|
||||
expect(raw).toBe('postgresql://user:pass@127.0.0.1:5432/teqo_test')
|
||||
expect(decodeURIComponent(process.env.DATABASE_URL ?? '')).toContain(
|
||||
'default_transaction_read_only=on',
|
||||
)
|
||||
expect(die).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCampaignActor', () => {
|
||||
it('prefers candidate, then coordinator', async () => {
|
||||
const find = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ docs: [] })
|
||||
.mockResolvedValueOnce({ docs: [{ id: 7, role: 'coordinator', name: 'Coord' }] })
|
||||
const actor = await resolveCampaignActor({ find }, { die: vi.fn() })
|
||||
expect(actor).toMatchObject({ id: 7, role: 'coordinator', collection: 'campaignUser' })
|
||||
expect(find).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('fails closed when no privileged actor exists', async () => {
|
||||
const find = vi.fn().mockResolvedValue({ docs: [] })
|
||||
const die = vi.fn((message: string) => {
|
||||
throw new Error(message)
|
||||
})
|
||||
await expect(resolveCampaignActor({ find }, { die })).rejects.toThrow(/candidate\/coordinator/)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue