mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C190): dossiê Solla por tema/área + boletim modelo (skill + builder)
Terceira variante da família de dossiês: recorte pela área canônica do acervo (SPEECH_TOPICS), com o pipeline C186/C187 generalizado para a forma subject via THEME_UNIT (generalização aditiva; C186/C187 byte-idênticos). - resolveSpeechTopic no dono speechFacets (fail-closed, sem slug inventado) - themeSnapshot + extract-theme-snapshot (THEME_REPORT_CONFIRM=1, read-only) - build-dossie-solla-tema (pack medido, PDF A4 + .md + boletim 1 página) - skill/comando/2 subagentes (researcher por era + redator) + specs - cenas theme-only no descritor (acervo universo/amostra, abrangência, lacunas, hierarquia de fontes, pills do boletim) certificadas pelo designer Closes #1143
This commit is contained in:
parent
6ec14823e5
commit
e5e1ae7ee8
24 changed files with 2997 additions and 272 deletions
369
.agents/skills/dossie-solla-tema/SKILL.md
Normal file
369
.agents/skills/dossie-solla-tema/SKILL.md
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
---
|
||||
name: dossie-solla-tema
|
||||
description: 'Gera o dossiê Solla por tema/área (PDF A4 + .md, tudo o que Solla fez pela área por era) e o Boletim Informativo modelo de 1 página, a partir da base Teqo read-only + pesquisa web datada + fontes oficiais; aceita uma área da taxonomia do acervo ou um lote separado por vírgula.'
|
||||
---
|
||||
|
||||
# Dossiê Solla por tema/área + boletim modelo (C190)
|
||||
|
||||
Entrega, por **área de política** (as 18 áreas canônicas do acervo — Saúde,
|
||||
Educação, Cultura, Esporte, …), um **par PDF A4 datado + companion `.md`** com
|
||||
tudo o que **Jorge Solla fez pela área ao longo da carreira** — toda afirmação
|
||||
não trivial com **URL + data**, tudo sem fonte vira **lacuna explícita** — **e**
|
||||
o **Boletim Informativo modelo** (1 página A4, linguagem de eleitor, sem
|
||||
declaração de fontes). Skill irmã da `dossie-solla-cidade` (C186) e da
|
||||
`dossie-solla-instituicao` (C187): mesmo pipeline, recorte por área. O layout
|
||||
vem dos artefatos hi-fi aprovados pelo `designer`.
|
||||
|
||||
O recorte da área é **nativo do acervo**: cada fala já é classificada por tema
|
||||
(`Speech.topics`, taxonomia `SPEECH_TOPICS`, C153/C154), então aqui o acervo é
|
||||
evidência de primeira classe — não uma ponte nominal. A taxonomia é **fechada**:
|
||||
token fora dos 18 canônicos **falha fechado**, nunca vira slug inventado.
|
||||
|
||||
O dossiê temático **não usa caps**: cada seção (eras, abrangência, lacunas,
|
||||
notícias, acervo) flui por quantas páginas precisar, com quebra só entre linhas
|
||||
— o orquestrador não decide o corte, o builder mede e pagina. Sobre essa base
|
||||
vêm a **redação de abertura** (carta), a **síntese** dos números e os **gráficos
|
||||
consolidados**, e cada era abre com um **parágrafo de consolidação** do que ela
|
||||
entrega.
|
||||
|
||||
## Quando usar
|
||||
|
||||
- A comunicação pede "o dossiê da <área>" e/ou o "boletim modelo da <área>" — ou
|
||||
várias numa invocação (`/dossie-solla-tema Educação, Saúde`).
|
||||
- Quem executa são o **orquestrador** (agente principal) + um sub-agente
|
||||
**researcher por era, por área**, em paralelo + um sub-agente **redator** +
|
||||
os scripts (`extract` no homeserver, `build` local). O detalhe está em
|
||||
"Pipeline (etapas)".
|
||||
|
||||
## Lote (várias áreas)
|
||||
|
||||
A invocação aceita **uma** área ou **várias**, separadas por **vírgula**:
|
||||
|
||||
```text
|
||||
/dossie-solla-tema Educação # N=1
|
||||
/dossie-solla-tema Educação, Saúde # lote
|
||||
```
|
||||
|
||||
Parsing (orquestrador, **antes** de qualquer pesquisa):
|
||||
|
||||
- separa por `,`; aplica `trim`; descarta vazio;
|
||||
- cada token é resolvido pela taxonomia canônica do acervo
|
||||
(`resolveSpeechTopic` sobre `SPEECH_TOPICS`): aceita o **valor** (`educacao`)
|
||||
ou o **label pt-BR** (`Educação`), fold acento/caixa-insensível;
|
||||
- **0 entradas** = token desconhecido; o token **falha fechado** listando as 18
|
||||
áreas canônicas e **nunca** inventa slug (a taxonomia é fechada — não há
|
||||
escape one-off);
|
||||
- **dedupe após a resolução**, preservando a ordem;
|
||||
- token inválido vira **falha isolada** com o motivo no summary final —
|
||||
**nunca** aborta o lote.
|
||||
|
||||
Uma área = um dossiê (PDF+MD) + **um** boletim PDF; **sem** índice/PDF agregado.
|
||||
Falha de uma área/era **não** cancela as demais (**sucesso parcial** explícito).
|
||||
|
||||
## Pipeline (etapas)
|
||||
|
||||
1. **Orquestrador (agente principal).** Parseia a lista (seção "Lote"), resolve
|
||||
os tokens na taxonomia (`resolveSpeechTopic` de `src/lib/speechFacets.ts`) e
|
||||
dispara **um researcher por era (A/B/C), por área** (Task), em paralelo, com
|
||||
guarda de concorrência (`MAX_RESEARCHERS_IN_FLIGHT = 6`). Não invente slug:
|
||||
token fora dos 18 falha fechado. Nunca retém pesquisa web nem o corpo de um
|
||||
`research.json` — só os recibos.
|
||||
2. **Researcher (sub-agente, ×3 por área, em paralelo).**
|
||||
`.opencode/agent/dossie-solla-tema.md` — o **único** passo pesado de
|
||||
contexto. Faz a pesquisa web datada da sua era e escreve
|
||||
`data/dossie-solla-tema/<slug>.<era>.research.json` (cada item com
|
||||
`sourceUrl` + `sourceDate`; sem fonte, **não escreva o item** — ele vira
|
||||
lacuna). Devolve **apenas o recibo curto** (seção "Recibo do researcher").
|
||||
Não roda ssh nem build.
|
||||
3. **Redação (sub-agente, 1 por área).**
|
||||
`.opencode/agent/dossie-solla-tema-redacao.md` — depois das eras
|
||||
pesquisadas, escreve `data/dossie-solla-tema/<slug>.narrative.json` (redação
|
||||
de abertura + 1 parágrafo por era) **somente** a partir dos itens com fonte;
|
||||
devolve só o recibo (seção "Recibo do redator"). O orquestrador **audita
|
||||
citações** (nome, data, valor, órgão) contra os `research.json` e corrige ou
|
||||
descarta o trecho sem lastro — nunca aceita fato novo. Sem o arquivo, o
|
||||
builder cai na consolidação determinística dos números.
|
||||
4. **Extração read-only no homeserver — serializada, 1 área por vez.**
|
||||
`scripts/extract-theme-snapshot.mjs` (`THEME_REPORT_CONFIRM=1`, sessão
|
||||
read-only) lê o acervo interno de falas **pelo tema direto**
|
||||
(`Speech.topics = valor canônico`; o acervo cobre 2011+) e grava
|
||||
`data/dossie-solla-tema/<slug>.theme.snapshot.json`.
|
||||
5. **Fontes oficiais em tempo de build.** O Portal da Transparência e a Câmara
|
||||
**não filtram por área**; emendas/proposições/relatorias por área entram como
|
||||
**itens de pesquisa** (com fase e fonte). Sem atribuição → lacuna, **nunca
|
||||
zero silencioso** nem inferência por palavra-chave na ementa. O builder
|
||||
**não** busca emendas/Câmara/IBGE nem dados de saúde.
|
||||
6. **Render local (por área):**
|
||||
```bash
|
||||
NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-tema.mjs \
|
||||
--snapshot=data/dossie-solla-tema/<slug>.theme.snapshot.json \
|
||||
--research-dir=data/dossie-solla-tema \
|
||||
--out-dir=docs/research/dossie-solla-tema
|
||||
```
|
||||
O builder pagina por medição (`scripts/lib/dossiePack.mjs`): render de prova
|
||||
→ pack → **grow/shrink medido** por folha até estabilizar (nada de página
|
||||
pela metade, nada cortado), e só então emite o PDF com a guarda de A4.
|
||||
Saídas: `docs/research/dossie-solla-tema/<slug>-<YYYY-MM-DD>-dossie.pdf`
|
||||
+ `-dossie.md` + `-boletim.pdf`. Intermediários gitignored em
|
||||
`data/dossie-solla-tema/` (HTML, JSONs, logs). Flags úteis:
|
||||
`--generated-at=<ISO>`, `DOSSIER_STRICT=1` (falha o run se houver lacuna —
|
||||
conferência, não entrega).
|
||||
7. **Summary final.** Uma linha por entrada, na ordem da lista: `entrada · status
|
||||
(ok|failed) · dossiê/boletim (quando ok) · motivo (quando falha)`.
|
||||
|
||||
## Briefing por era (A/B/C)
|
||||
|
||||
O researcher recebe **uma era** e usa o checklist da era (ver "Contrato dos
|
||||
JSONs"). As eras são as mesmas da C186 (não criar eras novas):
|
||||
|
||||
- **Era A (até 2006)** — formação/residência/pesquisa na área; SESAB;
|
||||
consultoria no MS; gestão municipal; Secretaria de Atenção à Saúde do MS;
|
||||
atuação documentada na área (pesquisa, conselho, docência). O acervo interno
|
||||
de falas cobre 2011+: **pré-2011 é lacuna explícita**, não invenção.
|
||||
- **Era B (2007–2014)** — gestão da SESAB com a área; políticas/programas
|
||||
estaduais; investimentos/equipamentos; convênios/termos; articulação regional
|
||||
e setorial. Fontes: DOE-BA (DOOL), SESAB, Transparência Bahia, SIOPS/DATASUS.
|
||||
- **Era C (2015–2027)** — pronunciamentos; proposições; relatorias; emendas;
|
||||
programas federais; audiências; títulos/honrarias. Fontes: API Câmara (deputy
|
||||
id `178857`; `/discursos` **default 7 dias** — sempre passe a janela;
|
||||
relatorias saem das tramitações), Portal da Transparência, Siga Brasil.
|
||||
|
||||
Templates de busca: `"Jorge Solla" <área>` + `<tema>`; recorte por data
|
||||
`after:YYYY-MM-DD before:YYYY-MM-DD`; `site:` por fonte oficial/setorial
|
||||
(camara.leg.br, saude.ba.gov.br, educacao.ba.gov.br, inep.gov.br, ptbahia.org.br,
|
||||
jorgesolla.com.br); excluir ruído de campanha 2026 (`-eleição -candidato -voto
|
||||
-coligação -pesquisa`) e preferir verbos de entrega (emenda, recurso, convênio,
|
||||
programa, projeto, relatoria, audiência, homenagem, repassado).
|
||||
|
||||
## Recibo do researcher
|
||||
|
||||
O sub-agente devolve **só** este recibo curto (≤ ~15 linhas) — **nunca** o corpo
|
||||
do `research.json` (`items[].answer/details`):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"slug": "educacao",
|
||||
"era": "C",
|
||||
"status": "ok", // ou "failed"
|
||||
"researchPath": "data/dossie-solla-tema/educacao.c.research.json",
|
||||
"researchedAt": "2026-09-18T10:00:00.000Z",
|
||||
"itemCount": 5, // itens da era com fonte
|
||||
"gapCount": 2, // == gaps.length
|
||||
"newsCount": 3,
|
||||
"gaps": ["era_c_relatorias", "…"],
|
||||
"failureReason": "…" // opcional (só quando status = failed)
|
||||
}
|
||||
```
|
||||
|
||||
`status: "failed"` carrega o motivo e **não** escreve corpo parcial; o
|
||||
orquestrador agrega os recibos no summary final — é o único dado de pesquisa que
|
||||
cruza para ele.
|
||||
|
||||
## Recibo do redator
|
||||
|
||||
O sub-agente da redação devolve **só** este recibo curto — **nunca** os
|
||||
parágrafos:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"slug": "educacao",
|
||||
"status": "ok", // ou "failed"
|
||||
"narrativePath": "data/dossie-solla-tema/educacao.narrative.json",
|
||||
"paragraphs": 3, // parágrafos de abertura
|
||||
"eraParagraphs": 3, // eras com parágrafo (A/B/C)
|
||||
"wordCounts": { "opening": [127, 146, 140], "A": 119, "B": 119, "C": 118 },
|
||||
"failureReason": "…" // opcional (só quando status = failed)
|
||||
}
|
||||
```
|
||||
|
||||
O orquestrador **audita** cada citação factual (nome, data, valor, órgão) contra
|
||||
os `research.json` antes de aceitar o arquivo; trecho sem lastro é corrigido ou
|
||||
removido. `status: "failed"` não escreve arquivo parcial — o builder segue com a
|
||||
consolidação determinística.
|
||||
|
||||
## Contrato dos JSONs
|
||||
|
||||
`<slug>.<era>.research.json` (um arquivo por era; `era` ∈ `A|B|C`):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"themeSlug": "educacao", // obrigatório
|
||||
"era": "C", // obrigatório: A | B | C
|
||||
"researchedAt": "2026-09-18T10:00:00.000Z", // obrigatório
|
||||
"items": [
|
||||
{
|
||||
"id": "era_c_emendas", // id do checklist da era
|
||||
"answer": "R$ 5,0 milhões para a rede de ensino",
|
||||
"details": "opcional; aceita {{fonte}} / {{fonte:N}}",
|
||||
"sphere": "area", // area | segmento | rede (default area)
|
||||
"numbers": [ // opcional
|
||||
{ "label": "Emenda", "value": "R$ 5,0 mi", "year": "2024", "phase": "empenhado" }
|
||||
],
|
||||
"sourceUrl": "https://…", // obrigatório no item publicado
|
||||
"sourceDate": "2026-09-15", // obrigatório
|
||||
"extraSources": [{ "label": "…", "url": "https://…", "date": "2026-09-01" }],
|
||||
"consultedAt": "2026-09-18T09:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"news": [
|
||||
{ "title": "…", "outlet": "…", "publishedAt": "2024-06-01", "url": "https://…", "summary": "…" }
|
||||
],
|
||||
"gaps": [{ "id": "…", "label": "…", "reason": "…" }]
|
||||
}
|
||||
```
|
||||
|
||||
Checklist por era (ids):
|
||||
|
||||
- **A:** `era_a_formacao`, `era_a_sesab`, `era_a_consultor_ms`, `era_a_conquista`,
|
||||
`era_a_sas_ms`, `era_a_vinculo`.
|
||||
- **B:** `era_b_sesab`, `era_b_politicas`, `era_b_investimentos`,
|
||||
`era_b_convenios`, `era_b_articulacao`.
|
||||
- **C:** `era_c_discursos`, `era_c_proposicoes`, `era_c_relatorias`,
|
||||
`era_c_emendas`, `era_c_programas`, `era_c_audiencias`, `era_c_titulos`.
|
||||
|
||||
A **abrangência** (`sphere`) é `area` | `segmento` | `rede`; `segmento` e `rede`
|
||||
não são a área e **nunca são somados** a ela (cada linha informa sua
|
||||
abrangência). Item sem `sourceUrl`/`sourceDate`, item ausente ou item de outra
|
||||
era vira **lacuna** (`Não pesquisado.` / `Sem fonte: …`); abrangência inválida
|
||||
vira lacuna. `phase` ∈ `autorizado|empenhado|liquidado|pago|restos` (default
|
||||
`nao_informado`; empenho **não** é pagamento). O `bulletinFacts` (ledger do
|
||||
boletim) só é populado por item **com fonte** — o boletim não introduz fato novo.
|
||||
|
||||
`<slug>.narrative.json` (opcional; escrito pelo redator, auditado pelo
|
||||
orquestrador):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"themeSlug": "educacao", // obrigatório
|
||||
"generatedAt": "2026-09-18T00:26:19.000Z", // obrigatório
|
||||
"title": "O que Jorge Solla fez pela Educação",
|
||||
"opening": ["parágrafo", "parágrafo", "parágrafo"],
|
||||
"eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" }
|
||||
}
|
||||
```
|
||||
|
||||
Sem o arquivo (ou com `themeSlug` diferente do snapshot), o builder **não
|
||||
falha**: a abertura repete a leitura dos números e cada era usa a consolidação
|
||||
determinística. `opening` e `eras` só podem conter fatos dos itens com fonte —
|
||||
nenhum número, data, nome ou órgão novo.
|
||||
|
||||
## Conteúdo do dossiê
|
||||
|
||||
- **Capa** — série temática, "INSUMO INTERNO — defeso 2026", identificação da
|
||||
área (label `Educação` + valor canônico `educacao` + nota da taxonomia), data,
|
||||
"como usar", escopo/versão.
|
||||
- **A contribuição (carta)** — redação de abertura sobre o que Solla fez pela
|
||||
área ao longo das eras, em prosa, com a nota "Como ler" (cada afirmação tem
|
||||
lastro em item datado; a redação não preenche por inferência).
|
||||
- **Resumo de uma olhada** — identificação (área/token/origem/filtro) + linha do
|
||||
tempo documentada do vínculo; principais entregas localizadas (badge de
|
||||
abrangência + valor + badge de fase + fonte); gancho para a agenda; o que
|
||||
falta. Guarda: **autorizado ≠ empenhado ≠ liquidado ≠ pago**. É a única página
|
||||
com listas capadas — e o "e mais N" aponta para a seção onde a lista completa
|
||||
está.
|
||||
- **Síntese** — leitura dos números com lastro (pontos por era e abrangência,
|
||||
concentração relativa, itens com valor por fase, recursos por fase, temas,
|
||||
lacunas) + guardas de leitura; nada que não venha dos itens.
|
||||
- **Gráficos consolidados** — recursos **com execução por ano** (empilhado por
|
||||
fase), **propostas/articulações sem fase informada**, abrangência, trajetória
|
||||
por ano, áreas e lacunas por era, painel do acervo. Valores sempre em R$ com a
|
||||
fase; recortes nunca somados.
|
||||
- **Seção por era (A/B/C)** — parágrafo de consolidação ("O que esta era
|
||||
entrega") + recorte e método + trilha de recuperação; tabela
|
||||
**Objeto/Valor/Ano/Fase/Abrangência/Fonte**; cards "Papéis e iniciativas com
|
||||
evidência visível". **Sem cap**: a era flui por quantas folhas precisar
|
||||
(continuações com cabeçalho "continuação N"). Uma era **sem evidência** vira
|
||||
**página de lacuna explícita** (nunca seção em branco nem "zero").
|
||||
- **Abrangência: área × segmento × rede** — painel "Segmento/rede não é a área.
|
||||
Não some os recortes." com contagem por recorte (**nenhum total combinado**) e
|
||||
prévia; em seguida **as três listas completas** em tabela
|
||||
(item/abrangência/evidência/fonte), cada uma fluindo por quantas folhas
|
||||
precisar; gancho e lacuna prioritária.
|
||||
- **Lacunas explícitas**, **Notícias e documentos consultados** e **Acervo
|
||||
interno por tema** — tabelas/listas completas, também correntes (sem cap). O
|
||||
acervo é **amostra declarada** (as falas mais recentes com link, de N do
|
||||
recorte `Speech.topics = <valor>`) com painel do total e do universo.
|
||||
- **Fontes e limites** — limites de cobertura; regras para uso editorial; nota de
|
||||
defeso eleitoral 2026.
|
||||
- **Nada some e nada é cortado:** o dossiê temático **não usa caps** — o builder
|
||||
mede a altura real de cada linha e pagina (grow/shrink) até a página ficar
|
||||
cheia sem estourar; listas continuam em folhas de continuação com o texto
|
||||
inteiro. O único "e mais N" é o do resumo e o da amostra do acervo, ambos
|
||||
apontando para onde o resto está.
|
||||
|
||||
## Conteúdo do Boletim modelo (1 página A4)
|
||||
|
||||
- Cabeçalho com o nome da área + identity pills + rótulo **"Modelo — insumo
|
||||
interno"**; faixa de **defeso** ("sem CTA · sem propaganda"); abertura "O que
|
||||
Jorge Solla fez na área de <área>"; **≤6 destaques** (eyebrow "Área ·
|
||||
abrangência", número em destaque, título curto, nota); timeline de 4 passos da
|
||||
trajetória; **"E mais" com ≤14 itens** em duas colunas; rodapé com controle
|
||||
editorial e defeso. Com **poucos fatos**, usa a variação de lacuna (não cria
|
||||
cards vazios para chegar a seis).
|
||||
- **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem
|
||||
CTA de campanha; herda **apenas** fatos com fonte do dossiê.
|
||||
|
||||
## Guardrails de produto (não negociáveis)
|
||||
|
||||
- **Sem fonte, não publica**: afirmação não trivial sem URL+data vira lacuna.
|
||||
- **Redação e parágrafos com lastro**: a carta e o parágrafo de cada era só usam
|
||||
fatos dos itens com fonte; o orquestrador audita citação por citação (nome,
|
||||
data, valor, órgão) e remove o que não tiver lastro.
|
||||
- **Empenho ≠ pagamento**: cada valor acompanha sua fase; nunca consolidar.
|
||||
- **Abrangência explícita** (`área`/`segmento`/`rede`): segmento e rede
|
||||
**nunca** são somados como se fossem a área.
|
||||
- **Atribuição temática só com fonte**: emenda/proposição/relatoria por área não
|
||||
é filtrada pelo Portal/Câmara; sem fonte que a ligue à área → lacuna (nunca
|
||||
zero silencioso nem match por palavra-chave).
|
||||
- **Taxonomia fechada**: token fora dos 18 `SPEECH_TOPICS` falha fechado —
|
||||
nunca slug inventado.
|
||||
- **Dossiê sem caps**: o conteúdo flui por quantas páginas precisar; a página é
|
||||
preenchida por medição (grow/shrink), nunca por corte de texto.
|
||||
- **Leitura relativa**: nunca % estadual absoluto.
|
||||
- **Lacuna explícita** em vez de inferência; o acervo interno cobre 2011+.
|
||||
- **PII mínima**: telefone/e-mail nunca entram.
|
||||
- **Falha isolada** por era/área não cancela o lote (**sucesso parcial**).
|
||||
- **Artefato gitignored** (repo público): PDF/MD/JSON nunca são commitados.
|
||||
- **Defeso**: dossiê e boletim saem como insumo interno/modelo, sem CTA; a peça
|
||||
final publicável é da comunicação/campanha. **Sem schema/migration/DB write.**
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Folha do dossiê estourou**: o builder mede a altura real de cada linha e
|
||||
repagina (grow/shrink) até estabilizar; se uma folha continuar estourando, é
|
||||
porque **uma linha sozinha** não cabe — aperte a copy/caps daquele item, nunca
|
||||
o layout. Página **não-packed** (carta, resumo, síntese, gráficos, fontes)
|
||||
estourando = corte copy ou reposicione o card.
|
||||
- **Página pela metade**: o pack estabilizou? Confira o log (`pack estável`); se
|
||||
uma seção ficou rala, o problema é o custo medido (bloco novo) — não force
|
||||
cap, ajuste a copy do item que abre a seção.
|
||||
- **Era sem pesquisa**: o builder sintetiza o arquivo da era com lacunas
|
||||
explícitas — e a era sai como página de lacuna, não inventada.
|
||||
- **Sem redação (`narrative.json`)**: a abertura repete a leitura dos números e
|
||||
as eras usam a consolidação determinística; não é falha, é degradação.
|
||||
`themeSlug` diferente do snapshot = erro (regenere).
|
||||
- **Token desconhecido no lote**: vira linha `failed` no summary com a lista das
|
||||
18 áreas canônicas; não há escape — a taxonomia é fechada.
|
||||
- **Slug de pesquisa ≠ snapshot**: o builder recusa o par — regenere, nunca
|
||||
edite o JSON à mão para casar.
|
||||
- **Chromium**: vem do `@playwright/test`; se faltar binário,
|
||||
`pnpm exec playwright install chromium`.
|
||||
|
||||
## Referências
|
||||
|
||||
- Intenção: `docs/plans/dossie-solla-tema.md`; impl:
|
||||
`docs/plans/dossie-solla-tema-impl.md`.
|
||||
- Designs hi-fi (fonte de verdade do port):
|
||||
`docs/plans/dossie-solla-tema-ui-design.html` e
|
||||
`docs/plans/dossie-solla-tema-boletim-ui-design.html` — variantes da família
|
||||
C187; um passe do `designer` os mantém sincronizados com a revisão corrente.
|
||||
- Skills irmãs: `.agents/skills/dossie-solla-cidade/SKILL.md` (dona do pipeline)
|
||||
e `.agents/skills/dossie-solla-instituicao/SKILL.md` (segundo recorte).
|
||||
- Scripts: `scripts/build-dossie-solla-tema.mjs`,
|
||||
`scripts/extract-theme-snapshot.mjs`, `scripts/themeSnapshot.mjs`,
|
||||
`scripts/lib/dossiePack.mjs` (packing grow/shrink),
|
||||
`scripts/lib/buildPdf.mjs` (probe/medição/emit),
|
||||
`scripts/lib/dossie*.mjs`, `scripts/lib/readOnlyExtract.mjs`.
|
||||
- Taxonomia/recorte: `src/lib/speechFacets.ts` (`SPEECH_TOPICS`,
|
||||
`resolveSpeechTopic`), `src/utilities/speech/speechListFilters.ts` (`topics`).
|
||||
- Runbook de extração read-only: `docs/ops/teqo-1313-deploy.md` §C163.
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -92,6 +92,10 @@ next-env.d.ts
|
|||
/data/dossie-solla-instituicao/
|
||||
/docs/research/dossie-solla-instituicao/
|
||||
|
||||
# C190 — dossiê Solla por tema/área + boletim modelo: same rule (C186).
|
||||
/data/dossie-solla-tema/
|
||||
/docs/research/dossie-solla-tema/
|
||||
|
||||
# Local-only sensitive material (agents may read; never commit content)
|
||||
/private/**
|
||||
!/private/README.md
|
||||
|
|
|
|||
35
.opencode/agent/dossie-solla-tema-redacao.md
Normal file
35
.opencode/agent/dossie-solla-tema-redacao.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
description: Redator do dossiê Solla por tema/área — escreve a redação de abertura e o parágrafo de consolidação por era (A/B/C) somente a partir dos itens com fonte; devolve só o recibo curto (etapa do lote /dossie-solla-tema)
|
||||
mode: subagent
|
||||
---
|
||||
|
||||
# Dossiê Solla por tema/área — redator (abertura + parágrafos por era)
|
||||
|
||||
Você é a etapa **redação** da skill `.agents/skills/dossie-solla-tema/SKILL.md`
|
||||
(fonte canônica: contrato do `narrative.json`, regras editoriais e recibo).
|
||||
Leia-a antes de escrever. Você é invocado **uma vez por área**, depois de as
|
||||
três eras terem sido pesquisadas, pelo agente principal.
|
||||
|
||||
## Papel
|
||||
|
||||
1. Ler os três `data/dossie-solla-tema/<slug>.<era>.research.json`
|
||||
(`items[]` com `answer`/`details`/`brief`/`numbers`/`sphere`/`sourceUrl`/
|
||||
`sourceDate`; `gaps[]` são lacunas explícitas).
|
||||
2. Escrever `data/dossie-solla-tema/<slug>.narrative.json` no contrato da skill:
|
||||
`themeSlug`, `generatedAt`, `title`, `opening` (3 parágrafos, 110–150
|
||||
palavras) e `eras` (A/B/C, 80–120 palavras cada) — prosa corrida, contando a
|
||||
contribuição de Solla para a área por era.
|
||||
3. Devolver **apenas o recibo curto** (seção "Recibo do redator" da skill).
|
||||
|
||||
## Limites
|
||||
|
||||
- **Só fatos dos itens** (com data e fonte): nenhum número, data, nome, órgão ou
|
||||
valor novo; nada de memória, inferência ou conhecimento externo. O
|
||||
orquestrador audita citação por citação e remove o que não tiver lastro.
|
||||
- `segmento` e `rede` **nunca** são somados à área — diga isso quando citar.
|
||||
- Valor sempre com a fase informada; **empenho ≠ pagamento**; "emenda de bancada
|
||||
proposta" não é execução.
|
||||
- Sem bullets, sem listas, sem reticências, sem CTA de campanha, sem PII; não
|
||||
use as palavras "dossiê", "insumo" ou "pesquisa" dentro dos parágrafos.
|
||||
- **Não** rode ssh, extração ou build; **não** edite os `research.json`; **não**
|
||||
commite nada (artefato gitignored, repo público).
|
||||
46
.opencode/agent/dossie-solla-tema.md
Normal file
46
.opencode/agent/dossie-solla-tema.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
description: Researcher por era — pesquisa web datada e escreve o research.json de uma era (A/B/C) do dossiê Solla por tema/área; devolve só o recibo curto (etapa do lote /dossie-solla-tema)
|
||||
mode: subagent
|
||||
---
|
||||
|
||||
# Dossiê Solla por tema/área — researcher (por era)
|
||||
|
||||
Você é a etapa **researcher** da skill `.agents/skills/dossie-solla-tema/SKILL.md`
|
||||
(fonte canônica: contrato dos JSONs, checklist por era, guardrails, recibo).
|
||||
Leia-a antes de rodar qualquer comando. Você é invocado **uma vez por era** (o
|
||||
orquestrador diz qual: `A`, `B` ou `C`) pelo agente principal, que já resolveu o
|
||||
slug da área na taxonomia e coordena as demais eras/áreas em paralelo.
|
||||
|
||||
## Papel
|
||||
|
||||
1. Confirmar o slug canônico da área (taxonomia `SPEECH_TOPICS` de
|
||||
`src/lib/speechFacets.ts`; ex.: Educação → `educacao`) e a era atribuída.
|
||||
2. Fazer a **pesquisa web datada** da era e escrever
|
||||
`data/dossie-solla-tema/<slug>.<era>.research.json` no contrato da skill
|
||||
(ex.: `educacao.c.research.json`) — item sem `sourceUrl`/`sourceDate` não vai
|
||||
para o arquivo (vira lacuna explícita). Use o checklist da era e marque
|
||||
`sphere` (`area`/`segmento`/`rede`); segmento e rede **nunca** são somados à
|
||||
área.
|
||||
3. Devolver **apenas o recibo curto** (seção "Recibo do researcher" da skill):
|
||||
`slug`, `era`, `status`, `researchPath`, `researchedAt`, `itemCount`,
|
||||
`gapCount`, `newsCount`, `gaps`, `failureReason?`. **Nunca** devolva o corpo
|
||||
do `research.json` (`items[].answer/details`, …).
|
||||
|
||||
## Limites
|
||||
|
||||
- **Não** conduza a extração nem o build — são etapas determinísticas do
|
||||
orquestrador (`scripts/extract-theme-snapshot.mjs` no homeserver,
|
||||
`scripts/build-dossie-solla-tema.mjs` local). Não faça `ssh`, `scp` nem
|
||||
render.
|
||||
- A atribuição de emenda/proposição/relatoria à área **só entra com fonte** que
|
||||
a ligue ao tema; sem fonte, vira lacuna — nunca zero silencioso.
|
||||
- A redação de abertura e o parágrafo de consolidação por era são de **outra
|
||||
etapa** (o redator) — não os escreva; seu produto é só o `research.json` da
|
||||
sua era.
|
||||
- **Nunca** escreva na base de produção: não use `db:pull`/snapshot de banco.
|
||||
- **Nunca** commite o `research.json`/PDF/MD/snapshot (dado interno; repo público
|
||||
— artefato gitignored).
|
||||
- Não invente fato sem fonte; não trate empenho como pagamento (informe a fase);
|
||||
não inclua telefone/e-mail de liderança; não reconstrua a carreira pré-2011
|
||||
fora do acervo (é lacuna).
|
||||
- Não edite os scripts nem o layout para "caber".
|
||||
9
.opencode/commands/dossie-solla-tema.md
Normal file
9
.opencode/commands/dossie-solla-tema.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
description: Gera o dossiê Solla por tema/área (PDF A4 + .md) e o Boletim Informativo modelo de 1 página para uma área ou um lote separado por vírgula
|
||||
---
|
||||
|
||||
Carregue a skill `dossie-solla-tema` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os tokens na taxonomia canônica do acervo (`SPEECH_TOPICS`/`resolveSpeechTopic`), dispara um researcher por era (A/B/C) por área, dispara o redator (redação de abertura + parágrafos por era, auditados contra os itens), roda a extração read-only do acervo (por `Speech.topics`) e o build local (dossiê + boletim, folhas correntes sem caps) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-tema/SKILL.md` — não a transcreva nem recrie o fluxo.
|
||||
|
||||
Uso: `/dossie-solla-tema <Área> [<Área>, ...]` — ex.: `/dossie-solla-tema Educação, Saúde`. Uma área = dossiê (`<slug>-<YYYY-MM-DD>-dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). Token fora da taxonomia dos 18 temas falha fechado, listando as áreas canônicas; nunca invente slug.
|
||||
|
||||
$ARGUMENTS
|
||||
1
docs/changelog/2026-09-18-c190.md
Normal file
1
docs/changelog/2026-09-18-c190.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C190 — Dossiê Solla por tema/área + Boletim modelo (2026-09-18):** terceira variante da família de dossiês — o recorte passa a ser a **área de política** do acervo (os 18 `SPEECH_TOPICS`), ao lado de município (C186) e instituição (C187), sem geminar o pipeline. (1) **Seam generalizado**: `scripts/lib/dossieUnit.mjs` ganha `THEME_UNIT` (`area|segmento|rede`, default `area`) e a forma `subject` compartilhada com a instituição; `dossieBlocks`/`dossieRender`/`dossieBulletinRender` passam a ler identidade, esferas, listas de abrangência, método por era, limites e copy do descritor — município e instituição seguem byte-idênticos (specs C186/C187 verdes; `isSubjectUnit` substitui `isInstitutionUnit`). (2) **Resolução canônica no dono**: `resolveSpeechTopic` exportado de `src/lib/speechFacets.ts` resolve valor/label (acento/caixa-insensível) e **falha fechado** — token fora dos 18 nunca vira slug inventado. (3) **Recorte nativo do acervo**: `scripts/themeSnapshot.mjs` + `scripts/extract-theme-snapshot.mjs` (`THEME_REPORT_CONFIRM=1`, sessão read-only) filtram `Speech.topics` direto, sem ponte nominal nem `mentionExcerpt`; `scripts/build-dossie-solla-tema.mjs` pagina por medição (`dossiePack`) e emite PDF A4 + `.md` + boletim de 1 página. (4) **Skill/comando/2 subagentes** (`dossie-solla-tema` researcher por era + redator), checklist próprio por era, lote por vírgula com dedup e falha isolada (`sucesso parcial`), sem escape one-off. (5) **Guardrails**: abrangência nunca somada (`área × segmento × rede`), empenho ≠ pagamento, atribuição de emenda/proposição só com fonte, acervo 2011+, PII mínima, artefatos gitignored, sem schema/migration/DB write. Specs novas: `dossieTheme`, `dossieThemeResearch`, `dossieThemeBatchSkill` (unit) + `themeSnapshot` (int) e `resolveSpeechTopic` no spec do dono; `SCRIPTS_SPEC_PINNED` e `opencodeCommands` atualizados. (6) **Design certificado** pela crítica final do `designer` (`openai/gpt-5.6-sol`) após adaptação theme-only via descritor: pills/identidade canônica no boletim, cards sem métrica com ano ou variante textual, cena do acervo com universo/amostra + alerta de pertinência, tabelas temáticas de abrangência/lacunas/hierarquia de fontes, cobertura das eras pelo ledger, fases separadas e eixo anual completo; smoke com continuidade real (lacunas 15+16+10, notícias 16+16+9, 20 páginas).
|
||||
171
docs/plans/dossie-solla-tema-impl.md
Normal file
171
docs/plans/dossie-solla-tema-impl.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# Impl: Dossiê Solla por tema/área + Boletim modelo (skill → dossiê + boletim)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-18
|
||||
Issue: #1143
|
||||
Intenção: docs/plans/dossie-solla-tema.md
|
||||
Appetite restante: ~2–3 dias eng (herdado)
|
||||
|
||||
> Aprovado no modo `--auto` (`/work-issue --issue 1143 --auto`): a pausa do GATE virou apresentação-no-chat. Literais e cortes do plano de intenção foram assumidos; **sem schema/migration/DB write**, sem Consent, sem URL pública e sem alterar `/dossie-solla-cidade` nem `/dossie-solla-instituicao` (generalização aditiva do dono, com as specs C186/C187 verdes).
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** `/dossie-solla-tema Educação` entrega, numa invocação, um par **PDF A4 datado + companion `.md`** (identificação da área — label `Educação` + valor canônico `educacao` + nota da taxonomia do acervo; seções por era A/B/C; tabelas **Objeto/Valor/Ano/Fase/Abrangência/Fonte**; painel de abrangência `área × segmento × rede`; **evidência direta do acervo por `Speech.topics`**; lacunas; fontes; defeso) **e** o **Boletim modelo** (1 página A4, linguagem de eleitor, ≤6 destaques + ≤14 "E mais", sem fontes, sem CTA, rótulo "Modelo — insumo interno"). Lote de 1+N; falha isolada não cancela o lote.
|
||||
- **O que NÃO negociar:** token **na taxonomia fechada** dos 18 `SPEECH_TOPICS` (`src/lib/speechFacets.ts:14`), **fail-closed** — nunca slug inventado; eras A/B/C **reusadas** do C186 (sem eras novas); abrangência `area | segmento | rede` **nunca somada**; fases `autorizado|empenhado|liquidado|pago|restos` (default `nao_informado`, empenho ≠ pago); "sem fonte, não publica" + lacuna explícita (emenda/proposição por área **só com fonte**); acervo cobre **2011+** (pré-2011 = lacuna); PII mínima; leitura relativa (nunca % estadual absoluto); artefato **gitignored**; **sem schema/migration/DB write**; **sem truncamento mecânico/descarte silencioso** (regra C188 — lista capada declara "e mais N"); boletim herda **apenas** `bulletinFacts` (fato com fonte).
|
||||
- **O que reavaliar (hipóteses da "Direção no codebase"):**
|
||||
- "só o render é binário" — verdade a confirmar: o **dispatch** é binário em 4 módulos (`dossieBlocks.mjs:222`, `dossieRender.mjs:813-827/1329-1330/2119-2127`, `dossieBulletinRender.mjs:372-376`); `isInstitutionUnit` tem 11 usos e o boletim (`dossieBulletin.mjs`) já é unit-aware.
|
||||
- "o ramo instituição é quase todo reutilizável" — verdade: a variante tema é **estruturalmente a mesma família** (abrangência 3-listas, eras correntes sem caps, carta/síntese/gráficos, acervo), então o certo é **generalizar a forma `subject`** em vez de geminar um terceiro branch (o gatilho do 3º recorte já estava registrado no simplify do C187).
|
||||
- "catálogo novo" — falso: o "catálogo" do tema já existe (`SPEECH_TOPICS`); falta **exportar** um resolver fail-closed do dono (`topicByKey` é privado, `speechFacets.ts:64`).
|
||||
- "guard de extração igual ao institucional" — a **receita** (`readOnlyExtract.mjs`) reusa, mas o **alvo/guard** é próprio (`THEME_REPORT_CONFIRM=1`); o compositor institucional é _institution-shaped_ (`matchNames`/`mentionExcerpt`, `institutionSnapshot.mjs:84-113`).
|
||||
- **Design (fonte de verdade do port — portar classe a classe):** dossiê `docs/plans/dossie-solla-tema-ui-design.html`, **14 cenas** (01 Capa e identificação da área; 02 A contribuição; 03 Resumo; 04 Síntese; 05 Gráficos; 06/07/08 Eras A/B/C; 09 Folha corrente/continuação; 10 Abrangência — lista completa; 11 Lacunas; 12 Notícias; 13 Acervo interno por tema; 14 Fontes/limites/defeso). Boletim `docs/plans/dossie-solla-tema-boletim-ui-design.html`, **2 cenas** (completo + variação de poucos fatos). O implementer **porta**, não redesenha.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["/dossie-solla-tema Educação, Saúde"] --> B["orquestrador: resolve token<br/>resolveSpeechTopic (18 canônicos)<br/>fail-closed + dedup"]
|
||||
B --> C["N researchers por era (A|B|C × tema)<br/>paralelo · MAX_RESEARCHERS_IN_FLIGHT=6"]
|
||||
C --> R["<slug>.<era>.research.json<br/>+ recibo curto (status/paths/gaps)"]
|
||||
B --> E["extração read-only, 1 tema por vez<br/>extract-theme-snapshot.mjs · THEME_REPORT_CONFIRM=1<br/>composeThemeSnapshot (Speech.topics direto)"]
|
||||
E --> S["<slug>.theme.snapshot.json"]
|
||||
R --> G["build-dossie-solla-tema.mjs"]
|
||||
S --> G
|
||||
G --> I["buildDossierReport(unit=THEME):<br/>capa+resumo+eras+abrangência+acervo+fontes<br/>→ bulletinFacts ledger (só fato com fonte)"]
|
||||
I --> J["renderDossierHtml/Md → PDF A4 multi-folha + .md<br/>(buildPdf.mjs + dossiePack.mjs, sem caps)"]
|
||||
I --> K["buildBulletin(unit=THEME) → renderBulletinHtml"]
|
||||
K --> L["PDF 1×A4 boletim modelo"]
|
||||
J --> M["docs/research/dossie-solla-tema/<slug>-<YYYY-MM-DD>-*.pdf/.md"]
|
||||
L --> M
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) **descritor de unidade data-driven** + generalizar a forma `subject` (instituição e tema no mesmo dono) + registro por `unit.id`; B) terceiro branch `isThemeUnit` ao lado de `isInstitutionUnit` (relatório/render tema irmãos); C) o descritor carregar `buildReport`/`render` (função no dado).
|
||||
**Recomendação:** **A** — a variante tema é a mesma forma da instituição (abrangência 3-listas, eras correntes, carta/síntese/gráficos/acervo); um **registro por id** substitui a cadeia de ternários e o descritor só ganha o vocabulário/copy que muda. Mantém **byte-idêntico** o caminho C186/C187 (mesmas funções, agora resolvidas por tabela) e escala para N recortes sem terceiro fork.
|
||||
**Rejeitadas:** **B** — gemina o maior branch do pipeline (a drift que o repo proíbe; C187 já registrou a duplicação da mecânica com gatilho "3º unit/recorte"); **C** — função no descritor cria import circular (descritor em `dossieUnit.mjs`, builders em `dossieBlocks.mjs`) e transforma o dado em service locator.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
**Seam + resolução canônica (donos editados)**
|
||||
|
||||
- **`scripts/lib/dossieUnit.mjs`** — `THEME_UNIT` (`id:'theme'`, `slugField:'themeSlug'`, `shape:'subject'`, `spheres:['area','segmento','rede']`, `defaultSphere:'area'`, `sphereLabels`, `sphereBadgeClass` (`segmento→scope-sector`, `rede→scope-network`, `area→''`), `breadthRank`, `researchDir:'data/dossie-solla-tema'`, `series`, `title`, `bulletinTitle`, `bulletinMoreLimit`, `nounPlural:'temas'`, `subjectLabel:'Área'`); registro `DOSSIER_UNITS.theme` (`:63`); predicado `isSubjectUnit(unit)` (`shape === 'subject'`) — `isInstitutionUnit` permanece como alias fino (`id === 'institution'`) para não quebrar consumidores; `resolveDossierUnit` já fail-closed (`:74-82`).
|
||||
- **`scripts/lib/dossieUnit.mjs` (descritor `subject`)** — campos que a forma genérica lê: `subjectName(snapshot)`, `identityBadges(snapshot)`, `scopeCopy`, `eraMethod`, `eraRecovery`, `reachCopy{ruleTitle,ruleBody}`, `coverageExtra`, `editorialSpheresLine`, `openingTitle(subjectName)`, `coverSubtitle`, `coverHowToUse`, `acervoNote`, `bulletinCopy`. `INSTITUTION_UNIT` recebe exatamente os literais de hoje (garante paridade); `THEME_UNIT` recebe os da família temática.
|
||||
- **`src/lib/speechFacets.ts`** (editar o dono) — exportar `resolveSpeechTopic(token): SpeechTopic | null` (fecha sobre o `topicByKey`/`facetKey` já existentes), `speechTopicValues()` e `speechTopicLabel(value)`. Sem segundo mapa; o mesmo fold acento/caixa-insensível do parser do LLM.
|
||||
|
||||
**Pipeline generalizado (aditivo; C186/C187 verdes)**
|
||||
|
||||
- **`scripts/lib/dossieResearch.mjs`** — `THEME_RESEARCH_CHECKLIST` + `CHECKLIST_BY_UNIT.theme` (`:219`); nominal `era_a_formacao|era_a_sesab|era_a_consultor_ms|era_a_conquista|era_a_sas_ms|era_a_vinculo`; `era_b_sesab|era_b_politicas|era_b_investimentos|era_b_convenios|era_b_articulacao`; `era_c_discursos|era_c_proposicoes|era_c_relatorias|era_c_emendas|era_c_programas|era_c_audiencias|era_c_titulos`. Trocar o noun fixo do erro de merge (`:494`) por `unit.nounPlural` (`municípios|instituições|temas`).
|
||||
- **`scripts/lib/dossieBlocks.mjs`** — generalizar `buildInstitutionReport` → `buildSubjectReport` (unit-driven): `directItems`/`scopeItem`/`speechFactsFromRows` usam `unit.defaultSphere`/`unit.spheres`; identidade/badges/`scopeCopy`/`eraMethod`/`reach`/`limits.coverage` vêm do descritor; `buildDossierReport` vira registro `{ municipality: buildMunicipalityReport, institution: buildSubjectReport, theme: buildSubjectReport }` (`:222`). `speechFactsFromRows(snapshot, sphere = default)` deixa de ter default `instituicao`.
|
||||
- **`scripts/lib/dossieRender.mjs`** — generalizar `renderInstitutionDossierHtml/Md` → `renderSubjectDossierHtml/Md`; `unitHeader/unitFooter` (`:813-827`) passam a `isSubjectUnit(report.unit)`; `dossierPackAnchors` vira registro (`:1329`) com `THEME_PACK_ANCHORS` (`abrangencia-area|segmento|rede` → `scope:area|scope:segmento|scope:rede`); `INSTITUTION_SCOPE_WORDS` (`:1332`) sai do key do descritor. Reusa as classes já portadas (`.identity-badge`, `.scope-sector`, `.scope-network`, `.scope-cards`, `.more-note`, `.document-table`, etc.); o port das 14 cenas temáticas não introduz CSS novo além de copy.
|
||||
- **`scripts/lib/dossieBulletin.mjs`** — sem mudança estrutural (já unit-aware: `breadthRank`/`bulletinMoreLimit`, `:51-68`).
|
||||
- **`scripts/lib/dossieBulletinRender.mjs`** — generalizar `renderInstitutionBulletinHtml` → `renderSubjectBulletinHtml` (copy do descritor) e registrar `theme` (`:372-376`); mantém `defeso-band`, `identity-pill`, `lacuna-panel`, variação de poucos fatos.
|
||||
|
||||
**Extração read-only + builder (novos, reusando plumbing)**
|
||||
|
||||
- **`scripts/themeSnapshot.mjs`** (novo) — `composeThemeSnapshot({ payload, actor, value, label, taxonomyNote, readAt, codeSha, database })` → `{ meta:{kind:'theme'}, theme:{slug,value,label,taxonomyNote,topics:[value]}, speeches:{topics:[value],totalCount,rows}, gaps }`; reusa `buildSpeechListWhere({ page:1, topics:[value] })` (`speechListFilters.ts:38`) + `loadSegmentsForSpeeches`; projeta `summary`/links/ano/temas e **não** computa `matchNames`/`mentionExcerpt` (o recorte é a tag do acervo).
|
||||
- **`scripts/extract-theme-snapshot.mjs`** (novo) — CLI `--theme=<valor|label> --out=<json>`; resolve com `resolveSpeechTopic` (fail-closed: desconhecido → lista os 18 canônicos, nunca slug inventado); `enterReadOnlySession({ confirmEnv:'THEME_REPORT_CONFIRM', … })`; serializado 1 tema por vez; escreve `data/dossie-solla-tema/<slug>.theme.snapshot.json`.
|
||||
- **`scripts/build-dossie-solla-tema.mjs`** (novo) — CLI `--snapshot= --research-dir= --out-dir= [--generated-at=] --[--theme=]`; exige `snapshot.theme.slug`; lê `<slug>.{a,b,c}.research.json` (recusa slug divergente) e `<slug>.narrative.json` (opcional); **não** chama `fetchAuthorEmendas`/`fetchCamaraActivity`/`fetchHealthData`; `buildDossierReport({ …, unit: THEME_UNIT })` → dossiê + `buildBulletin({ facts, identity, unit: THEME_UNIT })`; `dossierPackAnchors(THEME_UNIT)`; HTML cache em `data/dossie-solla-tema/`; `.md` + dois PDFs via `buildPdf.mjs`/`dossiePack.mjs`; `DOSSIER_STRICT=1` falha em lacuna.
|
||||
|
||||
**Skill / command / subagentes**
|
||||
|
||||
- **`.agents/skills/dossie-solla-tema/SKILL.md`** (novo) — fonte canônica: `Lote`, `Áreas canônicas (taxonomia do acervo)`, `Pipeline (etapas)`, `Briefing por era (A/B/C)`, `Recibo do researcher`, `Recibo do redator`, `Contrato dos JSONs` (`themeSlug`, `sphere: area|segmento|rede`), `Conteúdo`, `Guardrails`, `Troubleshooting`, `Referências` (aponta os dois HTMLs hi-fi).
|
||||
- **`.opencode/commands/dossie-solla-tema.md`** (novo) — frontmatter só `description:`; corpo carrega a skill pelo nome exato e termina em `$ARGUMENTS`.
|
||||
- **`.opencode/agent/dossie-solla-tema.md`** + **`.opencode/agent/dossie-solla-tema-redacao.md`** (novos) — researcher por era / redator, espelhando C187; sem `ssh`/build/`--snapshot=`/`--theme=`/commit.
|
||||
|
||||
**Testes / CI / docs**
|
||||
|
||||
- Novos: `tests/unit/dossieTheme.unit.spec.ts`, `dossieThemeResearch.unit.spec.ts`, `dossieThemeBatchSkill.unit.spec.ts`, `tests/int/themeSnapshot.int.spec.ts`; spec do resolver em `tests/unit/speechFacets.unit.spec.ts` (estender).
|
||||
- Editar: `tests/unit/opencodeCommands.unit.spec.ts` (`:13-21`, incluir `'dossie-solla-tema'`); `scripts/lib/test-affected-core.mjs` (`SCRIPTS_SPEC_PINNED` += `scripts/themeSnapshot.mjs`; `ciSkipInvariants` recalcula o closure até casar); `.gitignore` (`/data/dossie-solla-tema/`, `/docs/research/dossie-solla-tema/`); `docs/changelog/2026-09-18-c190.md`.
|
||||
- **Manter verdes:** `dossie{Research,Blocks,Render,Bulletin}.unit.spec.ts`, `dossieInstitution*.unit.spec.ts`, `institutionCatalog.unit.spec.ts`, `speechFacets/speechListFilters/speechListUrl.unit.spec.ts` e o int institucional — provando o default aditivo.
|
||||
|
||||
### Dados → forma (pergunta 3 — data-presentation)
|
||||
|
||||
- **Dossiê — forma escolhida:** **tabela/lista com fonte por linha + badge de abrangência + badge de fase** (Objeto/Valor/Ano/Fase/Abrangência/Fonte; painel de abrangência com **3 listas e nenhum total combinado**; gráficos consolidados como no dono; **acervo por tema com universos/amostra declarados** — `Speech.topics = educacao` (2011+)). É a forma mais pobre que ainda desbloqueia a decisão (ângulo/entrega + o que falta). **Rejeitadas:** dashboard de área (perde a fonte item a item e convida a somar); gráfico de série entre áreas (não há série comparável); % estadual absoluto (anti-goal); painel territorial por município (território é o recorte do C186).
|
||||
- **Boletim — forma escolhida:** **número destacado + rótulo curto** (≤6 `.highlight-card`) + **lista densa ✓** ("E mais", `bulletinMoreLimit`) + timeline; densidade > destaque grande. **Rejeitadas:** tabela/planilha (documento de gestor); parágrafo longo (estoura 1 página); fontes no boletim (vivem só no dossiê); CTA (defeso).
|
||||
|
||||
## Decisões de engenharia (A–G)
|
||||
|
||||
**A. Vocabulário de abrangência.** Opções: A) `area | segmento | rede` (default `area`); B) reusar `instituicao | setor | rede` do C187; C) só `area`. **Recomendação: A** — o render/blocks já resolvem esfera por descritor (`dossierSphereLabel/BadgeClass`, `scopeItem` vira `unit.spheres`); `area` é a política inteira, `segmento` o recorte interno (básica/superior/EJA), `rede` as entidades/escolas; preserva o guardrail "nunca somar" com vocabulário que não confunde com ente. **Rejeitadas:** B (uma "instituição" dentro do dossiê de área sugere atribuição de entidade — exatamente a confusão que o recorte evita; o hi-fi usa "Abrangência · área"); C (perde segmento/rede, que existem no material e no hi-fi).
|
||||
|
||||
**B. Generalização do dispatch e da forma.** Opções: A) registro por `unit.id` + generalizar a forma `subject` (instituição e tema no mesmo builder/renderer); B) terceiro branch tema irmão; C) descritor com funções `buildReport`/`render`. **Recomendação: A** — byte-parity C186/C187 (mesmas funções, agora resolvidas por tabela), sem terceiro fork do maior branch, honrando o gatilho do 3º recorte registrado no simplify do C187. **Rejeitadas:** B (twin da mecânica de `dossieBlocks`/`dossieRender`, drift garantido no próximo fix de pack/C188); C (import circular/TDZ e service locator).
|
||||
|
||||
**C. Onde vive a resolução do token.** Opções: A) exportar resolver do dono `src/lib/speechFacets.ts`; B) lib nova `src/lib/speechTopicCatalog.ts`; C) resolver na prosa da skill/script. **Recomendação: A** — `SPEECH_TOPICS`, `facetKey` e o `topicByKey` já vivem lá; exportar `resolveSpeechTopic` reusa o **mesmo** fold do parser do LLM (uma fonte de verdade para label↔valor). **Rejeitadas:** B (catálogo paralelo ao dono → drift); C (lógica de resolução não testável e duplicada em prompt).
|
||||
|
||||
**D. Composer e guard da extração read-only.** Opções: A) `scripts/themeSnapshot.mjs` + `extract-theme-snapshot.mjs` com `THEME_REPORT_CONFIRM=1`, reusando `readOnlyExtract.mjs`; B) parametrizar `composeInstitutionSnapshot`; C) reusar o extrator institucional com `--topics=`. **Recomendação: A** — a projeção do tema é diferente (sem `matchNames`/`mentionExcerpt`); a parte profunda (sessão read-only/ator/loaders) é reusada do dono, não geminada; guard próprio é o contrato de intenção explícita (o comando não adivinha o alvo). **Rejeitadas:** B (branch num compositor int-pinado por uma projeção que não compartilha o `institution` shape); C (o extrator institucional é institution-shaped e arrastaria `mentionExcerpt`/identidade de instituição para o dossiê temático).
|
||||
|
||||
**E. Escopo do recorte de dados em build.** Opções: A) sem APIs oficiais em build; checklist por era orientado a área; acervo por tema como **evidência de primeira classe**; emenda/proposição/relatoria só de item de pesquisa com fonte, senão lacuna; B) buscar Câmara por palavra-chave na ementa; C) reusar Portal/Câmara do C186. **Recomendação: A** — Portal não filtra por área e `fetchAuthorEmendas` exige município; match nominal/por tema é falso-positivo/omissão. O acervo (`Speech.topics`) é o recorte nativo e alimenta a seção de evidência e o ledger `bulletinFacts` (só fala com link). **Rejeitadas:** B/C (atribuição errada e "zero silencioso").
|
||||
|
||||
**F. Escape one-off.** Opções: A) **sem escape** — a taxonomia dos 18 temas é fechada; token desconhecido falha fechado com a lista canônica; B) espelhar `--slug=<x> --name="<Nome>"` do C187. **Recomendação: A** — o anti-goal é "inventar área/slug fora da taxonomia"; ao contrário do catálogo aberto de instituições, não existe "área desconhecida porém válida" (os labels são canônicos). **Rejeitada:** B (deixaria um slug inventado virar dossiê — contradiz outcome e recorte).
|
||||
|
||||
**G. Boletim por área.** Opções: A) generalizar `renderInstitutionBulletinHtml` → `renderSubjectBulletinHtml` (copy do descritor) e registrar `theme`; B) renderer tema separado. **Recomendação: A** — o delta é copy (pill `Área`/`educacao`, "pela <área>", rodapé "segmento e rede não são somados à área"); herança continua estrutural (`buildBulletin` só recebe `bulletinFacts`; ≤6 + `bulletinMoreLimit` "e mais"; sem fontes/CTA; `sparse`). **Rejeitada:** B (twin de ~130 linhas com delta de texto).
|
||||
|
||||
## Fases verificáveis (tracer bullet first)
|
||||
|
||||
1. **Tracer (seam + forma `subject` + resolução do token) — quota de meio dia.** `THEME_UNIT`/registro/`isSubjectUnit`; export do resolver em `speechFacets`; generalização de `dossieBlocks`/`dossieRender` para a forma `subject`; checklist tema em `dossieResearch`; um smoke de tema com snapshot/ pesquisa mínimos provando `buildDossierReport(unit=THEME)` → `renderDossierHtml/Md` e o `.md`. **Prova o encadeamento mais arriscado cedo** (paridade C187 + primeira composição temática) com as specs C186/C187 verdes.
|
||||
2. **Pipeline agêntico + taxonomia.** `SKILL.md` + command + 2 subagentes; `dossieThemeBatchSkill.unit.spec.ts` (lote/resolução fail-closed/dedup/fan-out/sem escape/guarda); `opencodeCommands.unit.spec.ts` atualizado; `test-affected-core.mjs` atualizado; spec do resolver.
|
||||
3. **Extração + builder.** `themeSnapshot.mjs`, `extract-theme-snapshot.mjs` (`THEME_REPORT_CONFIRM=1`), `build-dossie-solla-tema.mjs`; `tests/int/themeSnapshot.int.spec.ts`; decisão E exercida (sem emendas/Câmara/saúde em build; acervo alimenta evidência + ledger).
|
||||
4. **Boletim + port visual + docs.** `renderSubjectBulletinHtml` tema (completo + variação de poucos fatos); port classe a classe das **14 cenas** do dossiê e das **2** do boletim; `.gitignore`; changelog `2026-09-18-c190.md`.
|
||||
5. **Gates.** `pnpm gate:fast` (lint 0 warnings, `tsc --noEmit`, unit) → build local de **Educação** e de uma área de rótulo longo (ex.: Direitos Humanos) conferindo PDF/MD/boletim (aceite manual) → `pnpm push` (PR CI roda o resto). Sem e2e (não há runtime no app); `tests/int` só o snapshot novo.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Terceiro fork do pipeline (research/render/ledger/boletim) — twin proibido; tudo passa pelo dono + descritor registro.
|
||||
- `INSTITUTION_SCOPE`/`institutionEraMethod`/copy hardcoded vazando para o tema; `sphere='instituicao'` default em `speechFactsFromRows`; noun fixo do `mergeDossierResearch`.
|
||||
- Puxar emendas/Câmara/IBGE em build para "completar" a área; match por palavra-chave na ementa; tratar ausência como zero; somar segmento/rede à área.
|
||||
- Catálogo/coleção Payload, schema/migration/DB write/segundo cadastro; UI in-app no `/campanha`; índice/PDF agregado de lote; fan-out das 18 áreas.
|
||||
- Reabrir `/dossie-solla-cidade`/`/relatorio-cidade`/`/dossie-solla-instituicao` além da generalização aditiva; migrar o builder C163 para `buildPdf.mjs` agora.
|
||||
- Reconstruir a carreira pré-2011 fora do acervo — vira **lacuna**, não invenção.
|
||||
- Reabrir o design: os hi-fi estão prontos — só port; se faltar superfície, aciona-se o `designer` (não se redesenha na implementação).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regressão C186/C187 pela generalização:** mesmas funções resolvidas por tabela + descritor com os literais atuais; specs `dossie*`/`dossieInstitution*` no `gate:fast` são o oráculo; se a paridade falhar, a contingência é um `buildThemeReport` fino compondo os helpers já compartilhados (não geminar o branch inteiro).
|
||||
- **`SCRIPTS_SPEC_PINNED` fora de sincronia:** `ciSkipInvariants` recalcula o closure e falha fechado; atualizar o mapa na mesma fase que cria `themeSnapshot.mjs`.
|
||||
- **Recorte por tema impreciso (acervo):** o recorte é a tag `Speech.topics` (revisável na base); limite de amostra + "e mais N"; a skill declara "acervo cobre 2011+".
|
||||
- **Token ambíguo/desconhecido:** valores e labels são únicos; o resolver do dono retorna `null` e o CLI/skill falham fechados com a lista canônica.
|
||||
- **Fit/overflow de folha:** orçamentos explícitos + pack medido (`dossiePack` grow/shrink) + guarda A4 fail-closed; se estourar, aperta teto/copy — nunca espreme o layout nem corta texto.
|
||||
- **PII/gitignore/fan-out:** projeção sem contato; artefatos gitignored; subagente proibido de commitar; `MAX_RESEARCHERS_IN_FLIGHT = 6`; extração serializada.
|
||||
- **Appetite:** cortes embutidos — sem APIs em build (E), sem escape (F), sem agregado, sem casos especiais por área além do descritor.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: dossiê PDF A4 + `.md` (identificação da área label+valor canônico, eras A/B/C, abrangência `área×segmento×rede`, acervo por tema, lacunas, fontes, defeso) **e** boletim modelo 1×A4 (≤6 destaques + "E mais", sem fontes, sem CTA, rótulo "Modelo — insumo interno" + defeso, `NEEDS ASSET`).
|
||||
- [ ] Taxonomia **fechada** fail-closed (18 canônicos) + dedup após resolução; **sem escape inventado**; eras A/B/C reusadas; abrangência `area|segmento|rede` **nunca somada**; fases por valor (empenho ≠ pago); "sem fonte, não publica"; acervo 2011+; PII mínima; artefato gitignored.
|
||||
- [ ] Boletim herda **apenas** `bulletinFacts` (fato com fonte do dossiê); sem linha de fontes; sem CTA.
|
||||
- [ ] Sem truncamento mecânico/descarte silencioso: lista capada declara "e mais N"; reformulação no `summary`.
|
||||
- [ ] Generalização **aditiva** do dono; specs C186/C187 e int institucional verdes; `/dossie-solla-cidade` e `/dossie-solla-instituicao` sem mudança de comportamento; `buildPdf.mjs`/`dossiePack.mjs` seguem donos únicos do emit/paginação.
|
||||
- [ ] **Sem schema, migration, Consent ou DB write**; extração read-only com `THEME_REPORT_CONFIRM=1`.
|
||||
- [ ] `test-affected-core.mjs` sincronizado (closure exato) e `opencodeCommands.unit.spec.ts` com `dossie-solla-tema`; command/subagentes acoplados pelo nome exato.
|
||||
- [ ] Unit novos verdes: `dossieTheme`, `dossieThemeResearch`, `dossieThemeBatchSkill` (+ resolver em `speechFacets`); int `themeSnapshot`; `.gitignore` + changelog; `pnpm gate:fast` verde; build local aceito em Educação + área de rótulo longo; `pnpm push`.
|
||||
|
||||
---
|
||||
|
||||
## Self-score decision-quality (gate ≥4/5)
|
||||
|
||||
1. **Decisões caras têm rejeitadas?** Sim — A–G com Opções/Recomendação/Rejeitadas explícitas (vocabulário, dispatch/forma, resolução do token, composer/guard, escopo de dados, escape, boletim). — **5/5**
|
||||
2. **Abordagem cabe no appetite (2–3 dias)?** Sim — reuso máximo do dono C186/C187, sem schema/DB, cortes declarados (sem APIs em build, sem escape, sem agregado, sem e2e). — **4/5** (a generalização da forma `subject` é o trecho que mais consome quota; por isso o tracer cedo e o oráculo C187).
|
||||
3. **Rabbit holes nomeados?** Sim — terceiro fork, copy hardcoded, APIs de área, match por palavra-chave, zero silencioso, catálogo/coleção, UI in-app, agregado, pré-2011 inventado, redesign. — **5/5**
|
||||
4. **Depth check: reusa shells/helpers existentes?** Sim — `dossieUnit/Research/Blocks/Render/Bulletin/BulletinRender/Pack`, `buildPdf`, `readOnlyExtract`, `cityReportFormat`, `reportText`, `cli`, `buildSpeechListWhere`/`loadSegmentsForSpeeches`, `SPEECH_TOPICS` como catálogo; sem helper pass-through novo. — **4/5** (a generalização toca módulos em uso; mitigada por descritor com literais atuais + specs).
|
||||
5. **Intenção (aceite de produto) permanece satisfeita?** Sim — os dois artefatos, a taxonomia fail-closed, os literais (1 página, ≤6/≤14, sem fontes, rótulo de modelo, abrangência não somável), o acervo como evidência de primeira classe e a regra C188 estão no aceite; a engenharia não reescreveu o outcome. — **5/5**
|
||||
|
||||
**Total 23/25 (média 4,6/5) — passa o gate ≥4/5.**
|
||||
|
||||
---
|
||||
|
||||
## Débitos capturados (triage pós-execute — C190)
|
||||
|
||||
Triage do simplify (2 revisores) + crítica do `designer`, via regras de
|
||||
`capture-review-debts`. Nada aqui bloqueia a entrega certificada.
|
||||
|
||||
| # | Achado | Score | Tipo | Destino |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | `scripts/build-dossie-solla-tema.mjs` é ~85% clone do builder institucional (3º clone do CLI); `extract-theme-snapshot.mjs` idem | 3 | cheap_polish | **defer** — lote `escala-dry-pos-c190` (runner único parametrizado por unit); gatilho: 4º recorte ou próxima edição simultânea dos builders |
|
||||
| 2 | `SPEECH_SELECT`/limite/projeção base triplicados entre `themeSnapshot`/`institutionSnapshot`/`cityReportSnapshot` (drift real: typo `excerptTMS` corrigido nesta entrega) | 3 | cheap_polish | **defer** — mesmo lote (helper base + delta por unit) |
|
||||
| 3 | Copy institucional vaza para o município (`MUNICIPALITY_UNIT.synthesisSumGuard`, arias "institucional" nas páginas compartilhadas) | 3 | cheap_polish | **descartar** do escopo C190 (pré-existente C186 sob byte-parity congelada); vira bug separado se o produto pedir |
|
||||
| 4 | Pureza no seam: `scopeListWords` redundante com `scopeLists[].sphere`; regex de era duplicado; condição dupla no ramo de dinheiro | 2 | cheap_polish | **absorver** no lote acima (F3) |
|
||||
| 5 | `dossieThemeBatchSkill.unit.spec.ts` é clone do spec institucional | 2 | cheap_polish | **absorver** (`describe.each`) no lote |
|
||||
| 6 | Assert vacuoso herdado em `dossieTheme.unit.spec.ts` (`omitted >= 0`) | 2 | cheap_polish | **absorver** no lote |
|
||||
| 7 | Sincronização dos artefatos hi-fi do C190 (tabelas 10,5pt; cenas medidas de continuidade 15+16+10 e 16+16+9) | 2 | cheap_polish | **absorver** — passo do artefato (designer), sem Issue; confirmação humana registrada no PR |
|
||||
| 8 | Sem e2e (scripts/artefatos sem runtime no app) | 1 | defer_trigger | **descartar** — já declarado no impl; reavaliar se o pipeline ganhar rota/UI |
|
||||
|
||||
**Registro:** o lote `escala-dry-pos-c190` (itens 1, 2, 4, 5, 6) fica como
|
||||
trabalho deferido deste plano, com gatilho explícito — sem Issue nova nesta
|
||||
entrega para não editar/duplicar o tracker `in-progress`.
|
||||
250
scripts/build-dossie-solla-tema.mjs
Normal file
250
scripts/build-dossie-solla-tema.mjs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
/**
|
||||
* Dossiê + boletim builder (C190, theme/area unit): theme snapshot + per-era
|
||||
* research → dossiê PDF A4 + `.md` and the one-page boletim PDF.
|
||||
*
|
||||
* Runs on the workstation: no database access, no persistence. Official
|
||||
* sources are NOT fetched here — the Transparency Portal and the Câmara do not
|
||||
* filter by area, so area-attributed emendas/proposições come from the per-era
|
||||
* research items (with phase + source); anything unattributed becomes an
|
||||
* explicit gap, never a silent zero.
|
||||
*
|
||||
* Usage:
|
||||
* NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-tema.mjs \
|
||||
* --snapshot=data/dossie-solla-tema/educacao.theme.snapshot.json \
|
||||
* --research-dir=data/dossie-solla-tema \
|
||||
* --out-dir=docs/research/dossie-solla-tema
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
A4_PAGE_BUDGET_PX,
|
||||
emitHtmlPairPdf,
|
||||
launchPdfBrowser,
|
||||
measureDocumentPackProbe,
|
||||
measureDocumentSheets,
|
||||
} from './lib/buildPdf.mjs'
|
||||
import { dieWithLabel, isTruthyEnv, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
|
||||
import { buildDossierReport } from './lib/dossieBlocks.mjs'
|
||||
import { buildBulletin } from './lib/dossieBulletin.mjs'
|
||||
import { renderBulletinHtml } from './lib/dossieBulletinRender.mjs'
|
||||
import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs'
|
||||
import { adjustPackPlan, packProbeSections } from './lib/dossiePack.mjs'
|
||||
import { dossierPackAnchors, renderDossierHtml, renderDossierMd } from './lib/dossieRender.mjs'
|
||||
import { mergeDossierResearch, normalizeDossierResearchInput } from './lib/dossieResearch.mjs'
|
||||
import { THEME_UNIT } from './lib/dossieUnit.mjs'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const LABEL = 'build-dossie-solla-tema'
|
||||
const die = dieWithLabel(LABEL)
|
||||
const DEFAULT_OUT_DIR = 'docs/research/dossie-solla-tema'
|
||||
const CACHE_DIR = 'data/dossie-solla-tema'
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const { flags } = parseEqualsFlags(process.argv.slice(2))
|
||||
const snapshotPath = typeof flags.snapshot === 'string' ? flags.snapshot : null
|
||||
const researchDir = typeof flags['research-dir'] === 'string' ? flags['research-dir'] : CACHE_DIR
|
||||
const outDir = typeof flags['out-dir'] === 'string' ? flags['out-dir'] : DEFAULT_OUT_DIR
|
||||
const generatedAt =
|
||||
typeof flags['generated-at'] === 'string' ? new Date(flags['generated-at']) : new Date()
|
||||
|
||||
if (!snapshotPath) {
|
||||
die('Uso: --snapshot=<json> [--research-dir=<dir>] [--out-dir=<dir>] [--generated-at=<ISO>]')
|
||||
}
|
||||
if (Number.isNaN(generatedAt.getTime())) die(`--generated-at inválida: ${flags['generated-at']}`)
|
||||
|
||||
const readJson = async (relativePath, label) => {
|
||||
try {
|
||||
return JSON.parse(await readFile(resolve(ROOT, relativePath), 'utf8'))
|
||||
} catch (error) {
|
||||
die(
|
||||
`Falha ao ler ${label} (${relativePath}): ${error instanceof Error ? error.message : error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = await readJson(snapshotPath, 'snapshot')
|
||||
const slug = snapshot.theme?.slug
|
||||
if (!slug) die('Snapshot sem "theme.slug" — extração inválida.')
|
||||
|
||||
const readEraResearch = async (era) => {
|
||||
const path = join(researchDir, `${slug}.${era.toLowerCase()}.research.json`)
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8'))
|
||||
if (raw.themeSlug !== slug) {
|
||||
die(
|
||||
`Pesquisa da Era ${era} é de "${raw.themeSlug}", não de "${slug}" (${path}) — pare e regenere.`,
|
||||
)
|
||||
}
|
||||
if (raw.era !== era) {
|
||||
die(`Pesquisa em ${path} declara Era ${raw.era}, não Era ${era} — pare e regenere.`)
|
||||
}
|
||||
return normalizeDossierResearchInput(raw, { unit: THEME_UNIT })
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
console.log(`[${LABEL}] Era ${era}: sem pesquisa (${path}) — vira lacuna explícita.`)
|
||||
return normalizeDossierResearchInput(
|
||||
{
|
||||
themeSlug: slug,
|
||||
era,
|
||||
researchedAt: generatedAt.toISOString(),
|
||||
items: [],
|
||||
},
|
||||
{ unit: THEME_UNIT },
|
||||
)
|
||||
}
|
||||
die(
|
||||
`Pesquisa da Era ${era} inválida (${path}): ${error instanceof Error ? error.message : error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const researches = []
|
||||
for (const era of DOSSIER_ERA_IDS) researches.push(await readEraResearch(era))
|
||||
const research = mergeDossierResearch(researches, { unit: THEME_UNIT })
|
||||
|
||||
const currentCodeSha = (() => {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (snapshot.meta?.codeSha && currentCodeSha && snapshot.meta.codeSha !== currentCodeSha) {
|
||||
console.warn(
|
||||
`[${LABEL}] AVISO: snapshot extraído no SHA ${snapshot.meta.codeSha}, builder no ${currentCodeSha} — regenere se o extrator mudou.`,
|
||||
)
|
||||
}
|
||||
|
||||
const readNarrative = async () => {
|
||||
const path = join(researchDir, `${slug}.narrative.json`)
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8'))
|
||||
if (raw.themeSlug !== slug) {
|
||||
die(`Redação é de "${raw.themeSlug}", não de "${slug}" (${path}) — pare e regenere.`)
|
||||
}
|
||||
return raw
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return null
|
||||
die(`Redação inválida (${path}): ${error instanceof Error ? error.message : error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const narrative = await readNarrative()
|
||||
if (!narrative) {
|
||||
console.log(
|
||||
`[${LABEL}] sem redação autoral (${slug}.narrative.json) — a abertura repete os números.`,
|
||||
)
|
||||
}
|
||||
|
||||
const report = buildDossierReport({
|
||||
snapshot,
|
||||
research,
|
||||
generatedAt,
|
||||
unit: THEME_UNIT,
|
||||
narrative,
|
||||
})
|
||||
const bulletin = buildBulletin({
|
||||
facts: report.bulletinFacts,
|
||||
identity: {
|
||||
name: report.meta.subjectName,
|
||||
badges: report.meta.identityBadges,
|
||||
value: report.meta.identity?.value ?? null,
|
||||
taxonomyNote: report.meta.identity?.taxonomyNote ?? null,
|
||||
},
|
||||
unit: THEME_UNIT,
|
||||
generatedAt,
|
||||
})
|
||||
|
||||
const dossierMd = renderDossierMd(report)
|
||||
const bulletinHtml = renderBulletinHtml(bulletin)
|
||||
|
||||
const baseName = `${slug}-${generatedAt.toISOString().slice(0, 10)}`
|
||||
|
||||
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
|
||||
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml)
|
||||
|
||||
await mkdir(resolve(ROOT, outDir), { recursive: true })
|
||||
const dossierMdFile = join(outDir, `${baseName}-dossie.md`)
|
||||
const dossierPdfFile = join(outDir, `${baseName}-dossie.pdf`)
|
||||
const bulletinPdfFile = join(outDir, `${baseName}-boletim.pdf`)
|
||||
await writeFile(resolve(ROOT, dossierMdFile), dossierMd)
|
||||
console.log(`[${LABEL}] companion → ${dossierMdFile}`)
|
||||
|
||||
/**
|
||||
* Flowing sheets: probe the real height of every unit, pack the sections, then
|
||||
* re-render and measure — an overflowing sheet gives a row to the next chunk, a
|
||||
* sheet with room takes the next row, until the plan stops changing. Nothing is
|
||||
* capped and no page is left half empty; the final emit still guards the A4.
|
||||
*/
|
||||
const browser = await launchPdfBrowser()
|
||||
let dossierHtml = null
|
||||
let pack = null
|
||||
try {
|
||||
const probe = await measureDocumentPackProbe(browser, renderDossierHtml(report, { probe: true }))
|
||||
const anchors = Object.fromEntries(
|
||||
Object.entries(dossierPackAnchors(THEME_UNIT)).map(([anchor, key]) => [key, anchor]),
|
||||
)
|
||||
pack = packProbeSections(probe, A4_PAGE_BUDGET_PX)
|
||||
console.log(
|
||||
`[${LABEL}] probe: ${probe.map((section) => `${section.key}=${section.rows.length}`).join(' ')}`,
|
||||
)
|
||||
|
||||
let settled = false
|
||||
const lockedAnchors = new Set()
|
||||
for (let attempt = 0; attempt < 16 && !settled; attempt += 1) {
|
||||
dossierHtml = renderDossierHtml(report, { pack })
|
||||
const sheets = await measureDocumentSheets(browser, dossierHtml)
|
||||
for (const sheet of sheets) {
|
||||
if (sheet.height > A4_PAGE_BUDGET_PX) lockedAnchors.add(sheet.page)
|
||||
}
|
||||
const adjusted = adjustPackPlan({
|
||||
plan: pack,
|
||||
probe,
|
||||
sheets,
|
||||
anchors,
|
||||
budgetPx: A4_PAGE_BUDGET_PX,
|
||||
lockedAnchors,
|
||||
})
|
||||
if (!adjusted) {
|
||||
settled = true
|
||||
break
|
||||
}
|
||||
pack = adjusted
|
||||
}
|
||||
if (!dossierHtml) dossierHtml = renderDossierHtml(report, { pack })
|
||||
console.log(
|
||||
`[${LABEL}] pack ${settled ? 'estável' : 'no teto de tentativas'}: ${Object.entries(pack)
|
||||
.map(([key, sizes]) => `${key}=${sizes.join('+')}`)
|
||||
.join(' ')}`,
|
||||
)
|
||||
|
||||
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml)
|
||||
await emitHtmlPairPdf(browser, {
|
||||
dossierHtml,
|
||||
bulletinHtml,
|
||||
dossierPdf: resolve(ROOT, dossierPdfFile),
|
||||
bulletinPdf: resolve(ROOT, bulletinPdfFile),
|
||||
})
|
||||
} catch (error) {
|
||||
die(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
console.log(`[${LABEL}] PDF → ${dossierPdfFile}`)
|
||||
console.log(`[${LABEL}] boletim PDF → ${bulletinPdfFile}`)
|
||||
console.log(
|
||||
`[${LABEL}] ${report.meta.subjectName}: eras=${report.eras.length} entregas=${report.page1.deliveries.items.length} ` +
|
||||
`highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} páginas=${report.meta.pageTotal} html_bytes=${Buffer.byteLength(dossierHtml)}`,
|
||||
)
|
||||
|
||||
if (isTruthyEnv(process.env.DOSSIER_STRICT) && research.gaps.length > 0) {
|
||||
die(
|
||||
`DOSSIER_STRICT=1 e ${research.gaps.length} lacuna(s) de pesquisa — o dossiê saiu, mas o modo estrito falha.`,
|
||||
)
|
||||
}
|
||||
117
scripts/extract-theme-snapshot.mjs
Normal file
117
scripts/extract-theme-snapshot.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Snapshot extractor for the theme/area dossiê (C190).
|
||||
*
|
||||
* Runs where production lives (homeserver), read-only by connection options,
|
||||
* and writes a JSON snapshot consumed by `scripts/build-dossie-solla-tema.mjs`.
|
||||
* Nothing is persisted.
|
||||
*
|
||||
* The token is resolved fail-closed against the canonical acervo taxonomy
|
||||
* (`SPEECH_TOPICS`); the taxonomy is closed, so there is no one-off escape —
|
||||
* an unknown token lists the 18 canonical areas and stops.
|
||||
*
|
||||
* Usage (from the homeserver, with `~/stack/teqo-1313.env` loaded):
|
||||
* THEME_REPORT_CONFIRM=1 NODE_OPTIONS="--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs" \
|
||||
* node scripts/extract-theme-snapshot.mjs --theme=educacao --out=data/dossie-solla-tema/educacao.theme.snapshot.json
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { resolveSpeechTopic, SPEECH_TOPICS } from '../src/lib/speechFacets.ts'
|
||||
import { databaseLabel } from './lib/cityReportDatabase.mjs'
|
||||
import { dieWithLabel, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
|
||||
import { enterReadOnlySession, resolveCampaignActor } from './lib/readOnlyExtract.mjs'
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const LABEL = 'extract-theme-snapshot'
|
||||
const die = dieWithLabel(LABEL)
|
||||
|
||||
const codeSha = () => {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Catalog listing used by the fail-closed error (value + label, one per line). */
|
||||
const topicCatalog = () =>
|
||||
SPEECH_TOPICS.map(({ value, label }) => ` ${value} (${label})`).join('\n')
|
||||
|
||||
/**
|
||||
* Resolve the canonical area: value or pt-BR label, accent/case insensitive.
|
||||
* Unknown token fails closed with the full taxonomy — never an invented slug.
|
||||
*/
|
||||
const resolveIdentity = (token) => {
|
||||
const entry = resolveSpeechTopic(token)
|
||||
if (!entry) {
|
||||
die(
|
||||
`Área desconhecida na taxonomia do acervo: "${token}".\n` + ` Canônicas:\n${topicCatalog()}`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
slug: entry.value,
|
||||
value: entry.value,
|
||||
label: entry.label,
|
||||
taxonomyNote: 'Taxonomia do acervo',
|
||||
}
|
||||
}
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const { flags } = parseEqualsFlags(process.argv.slice(2))
|
||||
const out = typeof flags.out === 'string' ? flags.out : null
|
||||
const themeToken = typeof flags.theme === 'string' ? flags.theme.trim() : ''
|
||||
if (!themeToken) {
|
||||
die(
|
||||
'Uso: --theme=<valor|label> --out=<arquivo.json>\n' +
|
||||
` Áreas canônicas:\n${topicCatalog()}\n` +
|
||||
' Env: THEME_REPORT_CONFIRM=1 (obrigatório), DATABASE_URL (read-only é forçado).',
|
||||
)
|
||||
}
|
||||
const identity = resolveIdentity(themeToken)
|
||||
|
||||
if (!out) {
|
||||
die('Uso: --theme=<valor|label> --out=<arquivo.json>.')
|
||||
}
|
||||
|
||||
const rawDatabaseUrl = enterReadOnlySession({
|
||||
confirmEnv: 'THEME_REPORT_CONFIRM',
|
||||
label: LABEL,
|
||||
die,
|
||||
context: `área ${identity.value}`,
|
||||
})
|
||||
|
||||
const { default: configPromise } = await import('../src/payload.config.ts')
|
||||
const { getPayload } = await import('payload')
|
||||
const { composeThemeSnapshot } = await import('./themeSnapshot.mjs')
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
try {
|
||||
const actor = await resolveCampaignActor(payload, { die })
|
||||
console.log(`[${LABEL}] ator: ${actor.name ?? actor.id} (${actor.role})`)
|
||||
|
||||
const snapshot = await composeThemeSnapshot({
|
||||
payload,
|
||||
actor,
|
||||
identity,
|
||||
readAt: new Date().toISOString(),
|
||||
codeSha: codeSha(),
|
||||
database: databaseLabel(rawDatabaseUrl),
|
||||
})
|
||||
|
||||
const outPath = resolve(ROOT, out)
|
||||
await mkdir(dirname(outPath), { recursive: true })
|
||||
await writeFile(outPath, `${JSON.stringify(snapshot, null, 2)}\n`)
|
||||
console.log(
|
||||
`[${LABEL}] snapshot de ${snapshot.theme.label} (${snapshot.theme.value}) → ${out} ` +
|
||||
`(falas=${snapshot.speeches.totalCount} lacunas=${snapshot.gaps.length})`,
|
||||
)
|
||||
} catch (error) {
|
||||
die(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
|
|
@ -18,7 +18,7 @@ import {
|
|||
import {
|
||||
INSTITUTION_UNIT,
|
||||
MUNICIPALITY_UNIT,
|
||||
isInstitutionUnit,
|
||||
isSubjectUnit,
|
||||
resolveDossierUnit,
|
||||
} from './dossieUnit.mjs'
|
||||
import { capList, stripInlineSources, summarySurfaceText } from './reportText.mjs'
|
||||
|
|
@ -220,7 +220,7 @@ const camaraItemsAsActions = (camara, era) =>
|
|||
* @returns {DossierReport}
|
||||
*/
|
||||
export const buildDossierReport = (params) =>
|
||||
isInstitutionUnit(params?.unit) ? buildInstitutionReport(params) : buildMunicipalityReport(params)
|
||||
isSubjectUnit(params?.unit) ? buildSubjectReport(params) : buildMunicipalityReport(params)
|
||||
const buildMunicipalityReport = ({
|
||||
snapshot,
|
||||
research,
|
||||
|
|
@ -520,7 +520,9 @@ const buildMunicipalityReport = ({
|
|||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Institution branch (C187) — same owner, institution-shaped sections. *
|
||||
* Subject branch (C187 institution / C190 theme) — same owner, the *
|
||||
* `subject` shape shares these sections; the vocabulary comes from *
|
||||
* the unit descriptor. *
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
|
|
@ -534,21 +536,6 @@ const MAX_INSTITUTION_TIMELINE = 3
|
|||
const MAX_INSTITUTION_HOOKS = 2
|
||||
const MAX_INSTITUTION_PENDING = 4
|
||||
|
||||
const INSTITUTION_SCOPE =
|
||||
'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara dos Deputados 2015–2027. Instituição, setor e rede separados; sem fonte, não entra como entrega.'
|
||||
|
||||
const institutionEraMethod = {
|
||||
A: 'Registros anteriores a 2007 que citem nominalmente a instituição ou comprovem vínculo. Cargo geral não prova ação institucional.',
|
||||
B: 'A gestão estadual é lida por equipamento, programa, convênio e obra; cada linha informa abrangência e fonte. Setor e rede não somam à instituição.',
|
||||
C: 'Mandato, relatorias, parcerias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase.',
|
||||
}
|
||||
|
||||
const institutionEraRecovery = {
|
||||
A: 'biografias oficiais · atos e diários · acervos institucionais · busca web datada',
|
||||
B: 'DOE-BA (DOOL) · notícias SESAB/instituição · Transparência Bahia · busca web datada',
|
||||
C: 'API Câmara · Portal da Transparência · acervo interno read-only · busca web datada',
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a capped list so the renderer can declare what stayed out ("e mais N")
|
||||
* instead of dropping it silently — the C188 no-truncation rule, enforced at
|
||||
|
|
@ -567,17 +554,12 @@ const capped = (list, max) => {
|
|||
*/
|
||||
const uncapped = (list) => ({ items: list, total: list.length, omitted: 0 })
|
||||
|
||||
const identityBadges = (identity) =>
|
||||
[identity.kindLabel, identity.sphereLabel, identity.scope ? identity.scopeLabel : null].filter(
|
||||
Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Speeches from the internal acervo (read-only snapshot) that the research did
|
||||
* not already carry: they are sourced evidence for the recorte, so they feed
|
||||
* the bulletin ledger too — never a second, unsourced fact.
|
||||
*/
|
||||
const speechFactsFromRows = (snapshot, sphere = 'instituicao') =>
|
||||
const speechFactsFromRows = (snapshot, sphere) =>
|
||||
(snapshot.speeches?.rows ?? [])
|
||||
.filter((row) => Boolean(row.officialTextUrl || row.youtubeUrl || row.vodPlaybackUrl))
|
||||
.map((row) => ({
|
||||
|
|
@ -644,6 +626,12 @@ const gapEraKey = (gap) => {
|
|||
return match ? match[1].toUpperCase() : 'fora'
|
||||
}
|
||||
|
||||
/** Theme gap field: checklist ids carry the era letter, everything else is Acervo. */
|
||||
const gapEraLabel = (id) => {
|
||||
const match = /^era_([abc])_/i.exec(id ?? '')
|
||||
return match ? match[1].toUpperCase() : 'Acervo'
|
||||
}
|
||||
|
||||
const buildDossierSynthesis = ({
|
||||
items,
|
||||
numberRows,
|
||||
|
|
@ -754,7 +742,7 @@ const buildDossierSynthesis = ({
|
|||
.join(' · ')}.`,
|
||||
)
|
||||
lines.push(
|
||||
`Abrangência: ${bySphere.map((row) => `${row.count} ${row.label}`).join(' · ')}. Setor e rede não são somados à instituição.`,
|
||||
`Abrangência: ${bySphere.map((row) => `${row.count} ${row.label}`).join(' · ')}. ${unit.synthesisSumGuard ?? 'Setor e rede não são somados à instituição.'}`,
|
||||
)
|
||||
if (topEra) {
|
||||
lines.push(
|
||||
|
|
@ -767,13 +755,20 @@ const buildDossierSynthesis = ({
|
|||
}. Empenho não é pagamento.`,
|
||||
)
|
||||
if (moneyTotals.count) {
|
||||
const parts = []
|
||||
if (moneyTotals.executed)
|
||||
parts.push(`${formatMoneyCompact(moneyTotals.executed)} pagos/liquidados`)
|
||||
if (moneyTotals.authorized)
|
||||
parts.push(`${formatMoneyCompact(moneyTotals.authorized)} autorizados/empenhados`)
|
||||
if (moneyTotals.proposed)
|
||||
parts.push(`${formatMoneyCompact(moneyTotals.proposed)} em propostas sem fase informada`)
|
||||
// Theme keeps every phase on its own line (never fusing pago/liquidado or
|
||||
// autorizado/empenhado); the totals and charts stay untouched.
|
||||
const parts =
|
||||
unit.moneyPhaseStyle === 'separate'
|
||||
? moneyByPhase.map((row) => `${formatMoneyCompact(row.amount)} ${row.label}`)
|
||||
: []
|
||||
if (unit.moneyPhaseStyle !== 'separate') {
|
||||
if (moneyTotals.executed)
|
||||
parts.push(`${formatMoneyCompact(moneyTotals.executed)} pagos/liquidados`)
|
||||
if (moneyTotals.authorized)
|
||||
parts.push(`${formatMoneyCompact(moneyTotals.authorized)} autorizados/empenhados`)
|
||||
if (moneyTotals.proposed)
|
||||
parts.push(`${formatMoneyCompact(moneyTotals.proposed)} em propostas sem fase informada`)
|
||||
}
|
||||
lines.push(`Recursos localizados: ${parts.join(' · ')}.`)
|
||||
}
|
||||
if (byArea.length) {
|
||||
|
|
@ -877,19 +872,15 @@ const buildEraSummary = ({ era, items, money, scopeLabel, unit = INSTITUTION_UNI
|
|||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const buildInstitutionReport = ({
|
||||
snapshot,
|
||||
research,
|
||||
generatedAt = new Date(),
|
||||
narrative = null,
|
||||
}) => {
|
||||
const identity = snapshot.institution ?? {}
|
||||
const subjectName = identity.name ?? identity.slug ?? 'Instituição'
|
||||
const unit = INSTITUTION_UNIT
|
||||
const speechFacts = speechFactsFromRows(snapshot)
|
||||
const buildSubjectReport = (params) => {
|
||||
const { snapshot, research, generatedAt = new Date(), narrative = null } = params
|
||||
const unit = resolveDossierUnit(params.unit ?? INSTITUTION_UNIT)
|
||||
const identity = snapshot[unit.snapshotField] ?? {}
|
||||
const subjectName = identity.name ?? identity.label ?? identity.slug ?? '—'
|
||||
const speechFacts = speechFactsFromRows(snapshot, unit.defaultSphere)
|
||||
|
||||
const items = research.items ?? []
|
||||
const directItems = items.filter((item) => item.sphere === 'instituicao')
|
||||
const directItems = items.filter((item) => item.sphere === unit.defaultSphere)
|
||||
|
||||
/** Honors live in their own sheet; they are not repeated as era action cards. */
|
||||
const researchActionsByEra = (era) =>
|
||||
|
|
@ -924,13 +915,13 @@ const buildInstitutionReport = ({
|
|||
)
|
||||
return {
|
||||
...era,
|
||||
method: institutionEraMethod[era.id],
|
||||
recovery: institutionEraRecovery[era.id],
|
||||
method: unit.eraMethod[era.id],
|
||||
recovery: unit.eraRecovery[era.id],
|
||||
summary: buildEraSummary({
|
||||
era,
|
||||
items: eraItems,
|
||||
money: moneyRows(eraNumberRows),
|
||||
scopeLabel: 'no recorte institucional',
|
||||
scopeLabel: unit.scopeLabel,
|
||||
unit,
|
||||
}),
|
||||
narrative:
|
||||
|
|
@ -1006,6 +997,7 @@ const buildInstitutionReport = ({
|
|||
label: gap.label ?? gap.id,
|
||||
reason: gap.reason,
|
||||
nextStep: 'Apurar em fonte primária.',
|
||||
era: gapEraLabel(gap.id),
|
||||
}))
|
||||
const pendingList = (research.gaps ?? []).map((gap) => gap.label ?? gap.id)
|
||||
const pending = capped(pendingList, MAX_INSTITUTION_PENDING)
|
||||
|
|
@ -1019,9 +1011,12 @@ const buildInstitutionReport = ({
|
|||
...uncapped(
|
||||
sphereItems.map((item) => ({
|
||||
id: item.id,
|
||||
era: item.era,
|
||||
sphere: item.sphere,
|
||||
area: item.area,
|
||||
year: item.numbers?.[0]?.year ?? (item.sourceDate ?? '').slice(0, 4) ?? null,
|
||||
value: item.numbers?.[0]?.value ?? null,
|
||||
phase: item.numbers?.[0]?.phase ?? null,
|
||||
title: item.answer,
|
||||
brief: item.brief ?? null,
|
||||
evidence: item.details ?? item.label,
|
||||
|
|
@ -1031,11 +1026,9 @@ const buildInstitutionReport = ({
|
|||
),
|
||||
}
|
||||
}
|
||||
const scopeLists = [
|
||||
scopeItem('instituicao', 'institution', unit.sphereLabels.instituicao),
|
||||
scopeItem('setor', 'sector', unit.sphereLabels.setor),
|
||||
scopeItem('rede', 'network', unit.sphereLabels.rede),
|
||||
]
|
||||
const scopeLists = unit.scopeLists.map(({ key, sphere }) =>
|
||||
scopeItem(sphere, key, unit.sphereLabels[sphere]),
|
||||
)
|
||||
|
||||
const synthesis = buildDossierSynthesis({
|
||||
items,
|
||||
|
|
@ -1043,7 +1036,7 @@ const buildInstitutionReport = ({
|
|||
speechFacts,
|
||||
gaps: research.gaps ?? [],
|
||||
acervoTotal: snapshot.speeches?.totalCount ?? speechFacts.length,
|
||||
scopeLabel: 'no recorte institucional',
|
||||
scopeLabel: unit.scopeLabel,
|
||||
unit,
|
||||
})
|
||||
|
||||
|
|
@ -1064,7 +1057,13 @@ const buildInstitutionReport = ({
|
|||
: null,
|
||||
value: item.numbers?.[0]?.value ?? null,
|
||||
numberLabel: item.numbers?.[0]?.label ?? null,
|
||||
year: item.numbers?.[0]?.year ?? null,
|
||||
// Theme bulletin cards fall back to the sourced year when there is no
|
||||
// number (C190); the institution ledger keeps its C187 shape.
|
||||
year:
|
||||
item.numbers?.[0]?.year ??
|
||||
(unit.bulletinNumberFallback && item.sourceDate
|
||||
? String(item.sourceDate).slice(0, 4)
|
||||
: null),
|
||||
phase: item.numbers?.[0]?.phase ?? null,
|
||||
sourceUrl: item.sourceUrl,
|
||||
sourceDate: item.sourceDate,
|
||||
|
|
@ -1081,7 +1080,7 @@ const buildInstitutionReport = ({
|
|||
title:
|
||||
typeof narrative?.title === 'string' && narrative.title.trim()
|
||||
? narrative.title.trim()
|
||||
: `O que Jorge Solla fez pela e na ${subjectName}`,
|
||||
: unit.copy.openingTitle(subjectName),
|
||||
paragraphs: narrativeParagraphs.length > 0 ? narrativeParagraphs : [synthesis.lines.join(' ')],
|
||||
authored: narrativeParagraphs.length > 0,
|
||||
}
|
||||
|
|
@ -1092,7 +1091,7 @@ const buildInstitutionReport = ({
|
|||
title: unit.title,
|
||||
subjectName,
|
||||
identity,
|
||||
identityBadges: identityBadges(identity),
|
||||
identityBadges: unit.identityBadges(identity),
|
||||
generatedAt,
|
||||
generatedAtLabel,
|
||||
readAtLabel,
|
||||
|
|
@ -1105,10 +1104,9 @@ const buildInstitutionReport = ({
|
|||
internalNoteSub: 'Não circular · não publicar',
|
||||
eyebrow: 'Dossiê de atuação pública',
|
||||
title: subjectName,
|
||||
subtitle: 'O que Jorge Solla fez por, na e com a instituição ao longo da carreira',
|
||||
howToUse:
|
||||
'Localize o vínculo ou a entrega, confira fonte, data, fase e abrangência. Transforme toda lacuna em tarefa de apuração. Este arquivo não é fala pronta nem peça pública.',
|
||||
scope: INSTITUTION_SCOPE,
|
||||
subtitle: unit.copy.coverSubtitle(subjectName),
|
||||
howToUse: unit.copy.coverHowToUse,
|
||||
scope: unit.copy.coverScope,
|
||||
version: 'Documento de trabalho · versão 01',
|
||||
},
|
||||
page1: { timeline, deliveries, hooks, pending },
|
||||
|
|
@ -1117,9 +1115,8 @@ const buildInstitutionReport = ({
|
|||
acervo,
|
||||
synthesis,
|
||||
reach: {
|
||||
ruleTitle: 'Setor/rede não é a instituição. Não some os recortes.',
|
||||
ruleBody:
|
||||
'Uma política para uma categoria ou uma articulação com entidades correlatas pode alcançar a instituição sem constituir entrega exclusiva para ela.',
|
||||
ruleTitle: unit.copy.reachRuleTitle,
|
||||
ruleBody: unit.copy.reachRuleBody,
|
||||
lists: scopeLists,
|
||||
hook: hooks.items[0] ?? null,
|
||||
priorityGap: pending.items[0] ?? null,
|
||||
|
|
@ -1135,18 +1132,8 @@ const buildInstitutionReport = ({
|
|||
url: row.url,
|
||||
})),
|
||||
limits: {
|
||||
coverage: [
|
||||
'O acervo interno de falas cobre 2011+; períodos anteriores dependem de fontes externas.',
|
||||
'Resultado de busca não prova ausência histórica.',
|
||||
'Emenda de bancada ou relator só recebe autoria quando a fonte a confirma.',
|
||||
'Menção a entidade correlata (setor/rede) não é atribuição à instituição.',
|
||||
],
|
||||
editorial: [
|
||||
'Sem fonte, não publica.',
|
||||
'URL e data acompanham cada afirmação não trivial.',
|
||||
'Instituição, setor e rede permanecem separados.',
|
||||
'Valor sempre informa a fase de execução.',
|
||||
],
|
||||
coverage: unit.copy.limits.coverage,
|
||||
editorial: unit.copy.limits.editorial,
|
||||
},
|
||||
bulletinFacts,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,14 +49,22 @@ export const buildBulletin = ({
|
|||
const resolvedUnit = resolveDossierUnit(unit)
|
||||
const ordered = rankFacts(facts, resolvedUnit)
|
||||
const subjectName = municipality ?? identity?.name ?? '—'
|
||||
const highlights = ordered.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => ({
|
||||
eyebrow: `${fact.area ?? 'Atuação'} · ${dossierSphereLabel(fact.sphere, resolvedUnit)}`,
|
||||
number: fact.value,
|
||||
title: fact.brief?.title ?? fact.headline,
|
||||
note: fact.brief?.note ?? fact.detail ?? null,
|
||||
phase: fact.value ? (fact.phase ?? null) : null,
|
||||
phaseLabel: fact.value ? dossierPhaseLabel(fact.phase) : null,
|
||||
}))
|
||||
const highlights = ordered.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => {
|
||||
// Theme-only fallback: a fact without money value shows its year; without
|
||||
// either, the card is textual and prints no number slot at all (never zero).
|
||||
const number = resolvedUnit.bulletinNumberFallback
|
||||
? (fact.value ?? fact.year ?? null)
|
||||
: fact.value
|
||||
return {
|
||||
eyebrow: `${fact.area ?? 'Atuação'} · ${dossierSphereLabel(fact.sphere, resolvedUnit)}`,
|
||||
number,
|
||||
...(resolvedUnit.bulletinNumberFallback && !number ? { textual: true } : {}),
|
||||
title: fact.brief?.title ?? fact.headline,
|
||||
note: fact.brief?.note ?? fact.detail ?? null,
|
||||
phase: fact.value ? (fact.phase ?? null) : null,
|
||||
phaseLabel: fact.value ? dossierPhaseLabel(fact.phase) : null,
|
||||
}
|
||||
})
|
||||
const moreItems = ordered
|
||||
.slice(
|
||||
BULLETIN_HIGHLIGHT_LIMIT,
|
||||
|
|
@ -73,12 +81,14 @@ export const buildBulletin = ({
|
|||
sparse: ordered.length < 3,
|
||||
meta: {
|
||||
title: resolvedUnit.bulletinTitle,
|
||||
kicker: 'Boletim informativo · atuação pública',
|
||||
kicker: resolvedUnit.bulletinKicker ?? 'Boletim informativo · atuação pública',
|
||||
subjectName,
|
||||
municipality: subjectName,
|
||||
region,
|
||||
identity,
|
||||
identityBadges: identity?.badges ?? [],
|
||||
identityPills:
|
||||
resolvedUnit.bulletinPills && identity ? resolvedUnit.bulletinPills(identity) : null,
|
||||
generatedAt,
|
||||
generatedAtLabel: formatDateTimeBr(generatedAt),
|
||||
modelLabel: 'Modelo — insumo interno',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* as an internal model.
|
||||
*/
|
||||
|
||||
import { isInstitutionUnit } from './dossieUnit.mjs'
|
||||
import { isSubjectUnit } from './dossieUnit.mjs'
|
||||
import { htmlEscape, moreItemsLabel } from './reportText.mjs'
|
||||
|
||||
const ASSET = (title, lines) =>
|
||||
|
|
@ -21,9 +21,9 @@ const phaseModifier = (phase) =>
|
|||
: ''
|
||||
|
||||
const renderHighlight = (highlight, { showPhase = false } = {}) => `
|
||||
<article class="highlight-card">
|
||||
<article class="highlight-card${highlight.textual ? ' highlight-card--textual' : ''}">
|
||||
<p class="highlight-eyebrow">${htmlEscape(highlight.eyebrow)}</p>
|
||||
${highlight.number ? `<p class="highlight-number tabular">${htmlEscape(highlight.number)}</p>` : ''}
|
||||
${!highlight.textual && highlight.number ? `<p class="highlight-number tabular">${htmlEscape(highlight.number)}</p>` : ''}
|
||||
${
|
||||
showPhase && highlight.number && highlight.phaseLabel
|
||||
? `<p class="highlight-phase"><span class="phase ${phaseModifier(highlight.phase)}">${htmlEscape(highlight.phaseLabel)}</span></p>`
|
||||
|
|
@ -214,10 +214,11 @@ const renderMunicipalityBulletinHtml = (bulletin) => `
|
|||
|
||||
/* Institution boletim (C187) — ports the institutional boletim hi-fi. */
|
||||
|
||||
const INSTITUTION_BULLETIN_CSS = `
|
||||
const SUBJECT_BULLETIN_CSS = `
|
||||
${PRINT_CSS}
|
||||
.defeso-band { margin-inline: -10mm; padding: 1.4mm 10mm 1.35mm; display: flex; align-items: center; justify-content: space-between; gap: 5mm; color: #68420f; background: #fff7e8; border-top: .25mm solid #e7cc9d; border-bottom: .25mm solid #e7cc9d; font-size: 7.15pt; line-height: 1.2; font-weight: 700; }
|
||||
.identity-pill { display: inline-flex; align-items: center; min-height: 5mm; padding: .55mm 1.7mm; border: .25mm solid #cbd5dc; border-radius: 99px; color: #435264; background: #f3f6f8; font-size: 6.9pt; line-height: 1; font-weight: 700; letter-spacing: .045em; text-transform: uppercase; }
|
||||
.identity-pill--code { font-family: ui-monospace, 'DejaVu Sans Mono', monospace; letter-spacing: .02em; }
|
||||
.bulletin-head .subject { margin: 0; font-size: 11pt; font-weight: 800; }
|
||||
.bulletin-head .pills { margin-top: 1.3mm; display: flex; flex-wrap: wrap; gap: 1.2mm; }
|
||||
.lacuna-panel { border: .4mm dashed #b18549; color: #684819; background: #fff7e8; }
|
||||
|
|
@ -230,6 +231,8 @@ const INSTITUTION_BULLETIN_CSS = `
|
|||
.empty-box { min-height: 30mm; }
|
||||
.lacuna-panel .warn-icon { width: 5mm; height: 5mm; flex-shrink: 0; margin-top: .4mm; color: #8a5a18; }
|
||||
.highlight-phase { margin: 1.2mm 0 0; }
|
||||
.highlight-card--textual { border-top-color: #435264; }
|
||||
.highlight-card--textual .highlight-title { margin-top: 0; font-size: 11.6pt; }
|
||||
.phase { display: inline-block; padding: .5mm 1.4mm; border-radius: 1mm; background: #e9eef1; color: #334553; font-size: 7.3pt; font-weight: 700; text-transform: uppercase; letter-spacing: .035em; }
|
||||
.phase-paid { color: #285338; background: #e6f2e9; }
|
||||
.phase-pending { color: #6b4918; background: #fff2d9; }
|
||||
|
|
@ -238,18 +241,27 @@ const INSTITUTION_BULLETIN_CSS = `
|
|||
const INST_WARNING_ICON =
|
||||
'<svg class="warn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10.3 2.9 1.8 17a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 2.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/></svg>'
|
||||
|
||||
const renderInstitutionBulletinHtml = (bulletin) => {
|
||||
const renderSubjectBulletinHtml = (bulletin) => {
|
||||
const sparse = Boolean(bulletin.sparse)
|
||||
const pills = (bulletin.meta.identityBadges ?? [])
|
||||
.map((badge) => `<span class="identity-pill">${htmlEscape(badge)}</span>`)
|
||||
const copy = bulletin.unit.copy.bulletin
|
||||
// Theme pills come from the descriptor (`Área` + canonical token + taxonomy);
|
||||
// institution keeps the identity badges untouched.
|
||||
const pillList = bulletin.meta.identityPills
|
||||
? bulletin.meta.identityPills.filter((pill) => Boolean(pill.label))
|
||||
: (bulletin.meta.identityBadges ?? []).map((badge) => ({ label: badge }))
|
||||
const pills = pillList
|
||||
.map(
|
||||
(pill) =>
|
||||
`<span class="identity-pill${pill.code ? ' identity-pill--code' : ''}">${htmlEscape(pill.label)}</span>`,
|
||||
)
|
||||
.join('')
|
||||
const highlights = bulletin.highlights
|
||||
const more = bulletin.moreItems
|
||||
return `<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.subjectName)}</title><style>${INSTITUTION_BULLETIN_CSS}</style></head>
|
||||
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.subjectName)}</title><style>${SUBJECT_BULLETIN_CSS}</style></head>
|
||||
<body>
|
||||
<article class="sheet" data-page="boletim" aria-label="Modelo de boletim informativo institucional de uma página">
|
||||
<article class="sheet" data-page="boletim" aria-label="${htmlEscape(copy.aria ?? 'Modelo de boletim informativo institucional de uma página')}">
|
||||
<header class="bulletin-head">
|
||||
<div>
|
||||
<p class="kicker">${htmlEscape(bulletin.meta.kicker)}</p>
|
||||
|
|
@ -266,16 +278,19 @@ const renderInstitutionBulletinHtml = (bulletin) => {
|
|||
|
||||
<section class="opening" aria-label="Abertura do boletim">
|
||||
<div>
|
||||
<p class="section-label">${sparse ? 'Somente o que está documentado' : 'Uma trajetória de trabalho junto à instituição'}</p>
|
||||
<h1>O que Jorge Solla fez pela e na <span class="accent">${htmlEscape(bulletin.meta.subjectName)}</span></h1>
|
||||
<p class="lead">${
|
||||
sparse
|
||||
? 'Esta versão tem poucos registros confirmados. Em vez de preencher espaço, mostra só os fatos que o dossiê sustenta.'
|
||||
: 'Ações, recursos e articulações explicados de forma direta — somente o que já foi documentado no dossiê institucional.'
|
||||
}</p>
|
||||
<p class="section-label">${sparse ? 'Somente o que está documentado' : htmlEscape(copy.openingLabel)}</p>
|
||||
<h1>${(() => {
|
||||
const title = copy.openingTitle(bulletin.meta.subjectName)
|
||||
const name = htmlEscape(bulletin.meta.subjectName)
|
||||
const escaped = htmlEscape(title)
|
||||
return escaped.includes(name)
|
||||
? escaped.replace(name, `<span class="accent">${name}</span>`)
|
||||
: escaped
|
||||
})()}</h1>
|
||||
<p class="lead">${sparse ? htmlEscape(copy.leadSparse) : htmlEscape(copy.lead)}</p>
|
||||
</div>
|
||||
${ASSET(sparse ? 'NEEDS ASSET · foto ou clipping' : 'NEEDS ASSET · foto real', [
|
||||
sparse ? 'somente registro relacionado ao fato' : 'Solla na instituição ou na ação citada',
|
||||
sparse ? copy.assetSparse : copy.asset,
|
||||
'origem, autoria e licença',
|
||||
])}
|
||||
</section>
|
||||
|
|
@ -284,7 +299,7 @@ const renderInstitutionBulletinHtml = (bulletin) => {
|
|||
<div class="highlights-head">
|
||||
<div>
|
||||
<p class="section-label">Destaques</p>
|
||||
<h2>${sparse ? 'Poucos fatos, sem enchimento' : 'Resultados em leitura rápida'}</h2>
|
||||
<h2>${sparse ? htmlEscape(copy.highlightsTitleSparse) : htmlEscape(copy.highlightsTitle)}</h2>
|
||||
</div>
|
||||
<p class="side">${sparse ? 'não criar cards vazios para chegar a seis' : 'cada número mantém sua fase e seu alcance'}</p>
|
||||
</div>
|
||||
|
|
@ -297,8 +312,8 @@ const renderInstitutionBulletinHtml = (bulletin) => {
|
|||
|
||||
<section class="bulletin-timeline" aria-label="Trajetória">
|
||||
<div class="timeline-head">
|
||||
<h2>Uma trajetória de compromisso com o serviço público</h2>
|
||||
<p class="timeline-side">${sparse ? 'contexto de carreira · não substitui fato institucional' : 'quatro momentos · linguagem direta'}</p>
|
||||
<h2>${htmlEscape(copy.title)}</h2>
|
||||
<p class="timeline-side">${sparse ? htmlEscape(copy.titleSparse) : 'quatro momentos · linguagem direta'}</p>
|
||||
</div>
|
||||
<div class="timeline-grid">
|
||||
${bulletin.timeline
|
||||
|
|
@ -317,14 +332,14 @@ const renderInstitutionBulletinHtml = (bulletin) => {
|
|||
<div class="more-head">
|
||||
<div>
|
||||
<p class="section-label">E mais</p>
|
||||
<h2>${sparse ? 'Outros registros confirmados' : 'Outras ações confirmadas'}</h2>
|
||||
<h2>${sparse ? htmlEscape(copy.moreTitleSparse) : htmlEscape(copy.moreTitle)}</h2>
|
||||
</div>
|
||||
<p class="side">${sparse ? 'sem completar por inferência' : 'até 14 · sem completar por inferência'}</p>
|
||||
</div>
|
||||
${
|
||||
more.length
|
||||
? `<div class="more-grid">${more.map(renderMoreItem).join('')}</div>`
|
||||
: '<div class="empty-box"><p><strong class="uppercase">Sem itens adicionais com fonte além dos destaques.</strong></p><p>Conferir as lacunas explícitas no dossiê institucional antes de ampliar o boletim.</p></div>'
|
||||
: `<div class="empty-box"><p><strong class="uppercase">Sem itens adicionais com fonte além dos destaques.</strong></p><p>${htmlEscape(copy.moreEmpty)}</p></div>`
|
||||
}
|
||||
</div>
|
||||
${
|
||||
|
|
@ -332,12 +347,12 @@ const renderInstitutionBulletinHtml = (bulletin) => {
|
|||
? `<aside class="lacuna-panel" style="border-radius: .125rem; padding: 2.5mm" aria-label="Regra editorial para poucos fatos">
|
||||
${INST_WARNING_ICON}
|
||||
<p style="margin: 0; font-size: 10.5pt; font-weight: 800">A página termina com espaço — de propósito.</p>
|
||||
<p style="font-size: 8.7pt; line-height: 1.35">Não repetir item, não ampliar efeito e não preencher com ação de setor ou rede como se fosse entrega exclusiva da instituição.</p>
|
||||
<p style="font-size: 8.7pt; line-height: 1.35">${htmlEscape(copy.sparseRule)}</p>
|
||||
<p class="lacuna-rule">Poucos fatos com lastro > página cheia sem lastro</p>
|
||||
</aside>`
|
||||
: `<aside class="more-aside" aria-label="Ativos visuais pendentes">
|
||||
${ASSET('NEEDS ASSET · selo', ['marca autorizada da instituição', 'arquivo vetorial'])}
|
||||
${ASSET('NEEDS ASSET · clipping', ['registro real da ação', 'origem e licença'])}
|
||||
${ASSET('NEEDS ASSET · selo', copy.assetSelo ?? [])}
|
||||
${ASSET('NEEDS ASSET · clipping', copy.assetClipping ?? [])}
|
||||
</aside>`
|
||||
}
|
||||
</section>
|
||||
|
|
@ -349,7 +364,7 @@ const renderInstitutionBulletinHtml = (bulletin) => {
|
|||
<p class="section-label">Nota de produção</p>
|
||||
<p style="margin: 0; font-size: 9.2pt; line-height: 1.38; color: #435264">Se novos fatos forem documentados, eles entram por relevância — até seis destaques e até quatorze itens. A ausência nunca vira número zero, percentual ou afirmação genérica.</p>
|
||||
</div>
|
||||
${ASSET('NEEDS ASSET · selo', ['marca autorizada da instituição', 'arquivo vetorial'])}
|
||||
${ASSET('NEEDS ASSET · selo', copy.assetSelo ?? [])}
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
|
@ -357,8 +372,8 @@ const renderInstitutionBulletinHtml = (bulletin) => {
|
|||
<footer class="bulletin-foot">
|
||||
<div class="row">
|
||||
<div>
|
||||
<p class="orig">Conteúdo selecionado do dossiê institucional.</p>
|
||||
<p class="defeso"><strong>Controle editorial:</strong> fase acompanha cada valor · instituição, setor e rede não são somados · sem percentual estadual absoluto.</p>
|
||||
<p class="orig">${htmlEscape(copy.orig)}</p>
|
||||
<p class="defeso"><strong>Controle editorial:</strong> ${htmlEscape(copy.defeso)}</p>
|
||||
</div>
|
||||
<p class="stamp">MODELO · INSUMO INTERNO<br />A4 · página 1/1<br />${htmlEscape(bulletin.meta.generatedAtLabel)}</p>
|
||||
</div>
|
||||
|
|
@ -368,8 +383,8 @@ const renderInstitutionBulletinHtml = (bulletin) => {
|
|||
</html>`
|
||||
}
|
||||
|
||||
/** Dispatch: the institution unit renders the institutional composition. */
|
||||
/** Dispatch: subject-shaped units (institution/theme) render the shared subject composition. */
|
||||
export const renderBulletinHtml = (bulletin) =>
|
||||
isInstitutionUnit(bulletin?.unit)
|
||||
? renderInstitutionBulletinHtml(bulletin)
|
||||
isSubjectUnit(bulletin?.unit)
|
||||
? renderSubjectBulletinHtml(bulletin)
|
||||
: renderMunicipalityBulletinHtml(bulletin)
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@
|
|||
|
||||
import { formatDateBr, formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs'
|
||||
import { dossierPhaseLabel, dossierSphereBadgeClass, dossierSphereLabel } from './dossieBlocks.mjs'
|
||||
import { MUNICIPALITY_UNIT, isInstitutionUnit } from './dossieUnit.mjs'
|
||||
import { MUNICIPALITY_UNIT, isSubjectUnit, resolveDossierUnit } from './dossieUnit.mjs'
|
||||
import { htmlEscape, moreItemsLabel, showingLabel, stripInlineSources } from './reportText.mjs'
|
||||
|
||||
/** Markdown column headers capitalize the sphere label (sentence case, as C187 shipped). */
|
||||
const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1)
|
||||
|
||||
const SOURCE_ICON =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'
|
||||
|
||||
|
|
@ -742,6 +745,7 @@ const INSTITUTION_PRINT_CSS = `
|
|||
.vinc-source { margin: 1mm 0 0; font-size: 8pt; line-height: 1.2; color: #435264; }
|
||||
.scope-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3mm; margin-top: 4mm; }
|
||||
.scope-card { border: .25mm solid #cbd5dc; border-radius: .25rem; padding: 3mm; }
|
||||
.acervo-stat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 3mm; margin-top: 3mm; }
|
||||
.scope-card.card-sector { border-color: #c9b483; background: #fffcf6; }
|
||||
.scope-card.card-network { border-color: #b9acc3; background: #fcf9fd; }
|
||||
.scope-count { margin: 3mm 0 0; font-size: 16pt; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
|
|
@ -754,28 +758,34 @@ const INSTITUTION_PRINT_CSS = `
|
|||
* overflowing sheet back to its pack entry and shrinks that chunk (fallback of
|
||||
* the measured pack plan).
|
||||
*/
|
||||
const MUNICIPALITY_PACK_ANCHORS = {
|
||||
const SHARED_PACK_ANCHORS = {
|
||||
'era-a': 'era:A',
|
||||
'era-b': 'era:B',
|
||||
'era-c': 'era:C',
|
||||
'regiao-municipio': 'region:municipio',
|
||||
'regiao-regional': 'region:regional',
|
||||
lacunas: 'gaps',
|
||||
noticias: 'news',
|
||||
acervo: 'acervo',
|
||||
}
|
||||
|
||||
const MUNICIPALITY_PACK_ANCHORS = {
|
||||
...SHARED_PACK_ANCHORS,
|
||||
'regiao-municipio': 'region:municipio',
|
||||
'regiao-regional': 'region:regional',
|
||||
}
|
||||
|
||||
export const INSTITUTION_PACK_ANCHORS = {
|
||||
'era-a': 'era:A',
|
||||
'era-b': 'era:B',
|
||||
'era-c': 'era:C',
|
||||
...SHARED_PACK_ANCHORS,
|
||||
titulos: 'honors',
|
||||
'abrangencia-instituicao': 'scope:instituicao',
|
||||
'abrangencia-setor': 'scope:setor',
|
||||
'abrangencia-rede': 'scope:rede',
|
||||
lacunas: 'gaps',
|
||||
noticias: 'news',
|
||||
acervo: 'acervo',
|
||||
}
|
||||
|
||||
const THEME_PACK_ANCHORS = {
|
||||
...SHARED_PACK_ANCHORS,
|
||||
'abrangencia-area': 'scope:area',
|
||||
'abrangencia-segmento': 'scope:segmento',
|
||||
'abrangencia-rede': 'scope:rede',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -798,7 +808,7 @@ const INSTITUTION_PROBE_CSS = `
|
|||
const instHeader = (report, title, subtitle, pageNo) => `
|
||||
<header class="report-header">
|
||||
<div>
|
||||
<p class="report-kicker">Dossiê institucional · ${htmlEscape(report.meta.subjectName)}</p>
|
||||
<p class="report-kicker">${htmlEscape(report.unit.docLabel)} · ${htmlEscape(report.meta.subjectName)}</p>
|
||||
<h2 class="sheet-title">${htmlEscape(title)}</h2>
|
||||
${subtitle ? `<p class="sheet-sub">${htmlEscape(subtitle)}</p>` : ''}
|
||||
</div>
|
||||
|
|
@ -811,18 +821,16 @@ const instHeader = (report, title, subtitle, pageNo) => `
|
|||
|
||||
/** Shared sheets (síntese, gráficos, carta, eras correntes) use the unit's shell. */
|
||||
const unitHeader = (report, title, subtitle, pageNo) =>
|
||||
isInstitutionUnit(report.unit)
|
||||
isSubjectUnit(report.unit)
|
||||
? instHeader(report, title, subtitle, pageNo)
|
||||
: sheetHeader(report, title, subtitle, pageNo)
|
||||
|
||||
const unitFooter = (report, left, pageNo) =>
|
||||
isInstitutionUnit(report.unit)
|
||||
? instFooter(report, left, pageNo)
|
||||
: sheetFooter(report, left, pageNo)
|
||||
isSubjectUnit(report.unit) ? instFooter(report, left, pageNo) : sheetFooter(report, left, pageNo)
|
||||
|
||||
const instFooter = (report, left, pageNo) => `
|
||||
<footer class="report-footer">
|
||||
<span>Dossiê institucional · ${htmlEscape(report.meta.subjectName)} · ${htmlEscape(left)}</span>
|
||||
<span>${htmlEscape(report.unit.docLabel)} · ${htmlEscape(report.meta.subjectName)} · ${htmlEscape(left)}</span>
|
||||
<span>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))} · pág. ${pageNo}/${report.meta.pageTotal}</span>
|
||||
</footer>`
|
||||
|
||||
|
|
@ -944,8 +952,17 @@ const valueList = ({ rows, format }) => {
|
|||
</ul>`
|
||||
}
|
||||
|
||||
/** Vertical columns with the value label above and the year label below. */
|
||||
const columnChart = ({ rows, width = 330, height = 132, format = (row) => String(row.count) }) => {
|
||||
/**
|
||||
* Vertical columns with the value label above and the year label below. `fullYearLabel`
|
||||
* prints the whole year (theme axis); the institution keeps the 2-digit label.
|
||||
*/
|
||||
const columnChart = ({
|
||||
rows,
|
||||
width = 330,
|
||||
height = 132,
|
||||
format = (row) => String(row.count),
|
||||
fullYearLabel = false,
|
||||
}) => {
|
||||
const max = Math.max(1, ...rows.map((row) => row.value ?? row.count))
|
||||
const baseY = height - 18
|
||||
const plotHeight = baseY - 16
|
||||
|
|
@ -959,14 +976,14 @@ const columnChart = ({ rows, width = 330, height = 132, format = (row) => String
|
|||
const y = baseY - barHeight
|
||||
return `<rect x="${x + 2}" y="${y}" width="${Math.max(2, columnWidth - 5)}" height="${barHeight}" fill="${row.color ?? CHART_COLORS.default}" />
|
||||
<text x="${x + columnWidth / 2}" y="${y - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
|
||||
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(String(row.key).slice(-2))}</text>`
|
||||
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
|
||||
})
|
||||
.join('')}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
/** Vertical columns stacked by phase (money by year), total label above. */
|
||||
const stackedColumnChart = ({ rows, width = 330, height = 132, format }) => {
|
||||
const stackedColumnChart = ({ rows, width = 330, height = 132, format, fullYearLabel = false }) => {
|
||||
const max = Math.max(1, ...rows.map((row) => row.value))
|
||||
const baseY = height - 18
|
||||
const plotHeight = baseY - 16
|
||||
|
|
@ -986,7 +1003,7 @@ const stackedColumnChart = ({ rows, width = 330, height = 132, format }) => {
|
|||
const top = baseY - Math.round((row.value / max) * plotHeight)
|
||||
return `${bars}
|
||||
<text x="${x + columnWidth / 2}" y="${top - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
|
||||
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(String(row.key).slice(-2))}</text>`
|
||||
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
|
||||
})
|
||||
.join('')}
|
||||
</svg>`
|
||||
|
|
@ -997,8 +1014,25 @@ const identityBadgeList = (report) =>
|
|||
.map((badge) => `<span class="identity-badge">${htmlEscape(badge)}</span>`)
|
||||
.join(' ')
|
||||
|
||||
const renderInstitutionCover = (report) => `
|
||||
<article class="sheet sheet-cover" data-page="capa" aria-label="Capa do dossiê institucional">
|
||||
const coverRowValue = (report, row) => {
|
||||
if (row.badges) return `<dd>${identityBadgeList(report)}</dd>`
|
||||
if (row.code) {
|
||||
return `<dd><code>${htmlEscape(row.code)}</code>${row.note ? ` · ${htmlEscape(row.note)}` : ''}</dd>`
|
||||
}
|
||||
return `<dd${row.tabular ? ' class="tabular"' : ''}>${htmlEscape(row.text)}</dd>`
|
||||
}
|
||||
|
||||
const renderSubjectCover = (report) => {
|
||||
const rows = [
|
||||
...report.unit.coverRows(report),
|
||||
{ label: 'Data de geração', text: formatDateTimeBr(report.meta.generatedAt), tabular: true },
|
||||
{
|
||||
label: 'Escopo e versão',
|
||||
text: `Eras A / B / C · ${report.unit.copy.coverScopeWords} · versão 01`,
|
||||
},
|
||||
]
|
||||
return `
|
||||
<article class="sheet sheet-cover" data-page="capa" aria-label="Capa do ${htmlEscape(report.unit.docLabel.toLowerCase())}">
|
||||
<div class="accent-bar"></div>
|
||||
<header class="cover-head">
|
||||
<div>
|
||||
|
|
@ -1015,14 +1049,12 @@ const renderInstitutionCover = (report) => `
|
|||
</div>
|
||||
|
||||
<dl class="cover-dl">
|
||||
<dt>Identificação</dt><dd>${identityBadgeList(report)}</dd>
|
||||
<dt>Data de geração</dt><dd class="tabular">${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</dd>
|
||||
<dt>Escopo e versão</dt><dd>Eras A / B / C · instituição, setor e rede separados · versão 01</dd>
|
||||
${rows.map((row) => `<dt>${htmlEscape(row.label)}</dt>${coverRowValue(report, row)}`).join('\n ')}
|
||||
</dl>
|
||||
|
||||
<div class="cover-grid">
|
||||
<div class="how-to"><p class="how-to-title">Como usar</p><p>${htmlEscape(report.cover.howToUse)}</p></div>
|
||||
${assetBox('NEEDS ASSET', ['selo da instituição', 'uso autorizado e origem'])}
|
||||
${assetBox('NEEDS ASSET', report.unit.copy.coverAsset)}
|
||||
</div>
|
||||
|
||||
<div class="cover-foot">
|
||||
|
|
@ -1033,26 +1065,20 @@ const renderInstitutionCover = (report) => `
|
|||
</div>
|
||||
</div>
|
||||
</article>`
|
||||
}
|
||||
|
||||
const renderInstitutionIdentity = (report) => {
|
||||
const identity = report.meta.identity ?? {}
|
||||
const rows = [
|
||||
['Nome', identity.name],
|
||||
['Tipo', identity.kindLabel],
|
||||
['Esfera', identity.sphereLabel],
|
||||
['Alcance', identity.scopeLabel],
|
||||
['Alias', (identity.aliases ?? []).join(' · ') || '—'],
|
||||
].filter(([, value]) => Boolean(value))
|
||||
const renderSubjectIdentity = (report) => {
|
||||
const rows = report.unit.identityRows(report.meta.identity ?? {})
|
||||
return `<div class="id-card">
|
||||
<p class="eyebrow">Identificação</p>
|
||||
<dl>${rows.map(([key, value]) => `<dt>${htmlEscape(key)}</dt><dd>${htmlEscape(value)}</dd>`).join('')}</dl>
|
||||
</div>`
|
||||
}
|
||||
|
||||
const renderInstitutionVincTimeline = (report) => {
|
||||
const renderSubjectVincTimeline = (report) => {
|
||||
const timeline = report.page1.timeline
|
||||
if (timeline.items.length === 0) {
|
||||
return '<p class="muted small">Nenhum vínculo datado com fonte — ver lacunas explícitas.</p>'
|
||||
return `<p class="muted small">${htmlEscape(report.unit.copy.timelineEmpty)}</p>`
|
||||
}
|
||||
return `
|
||||
<div class="vinc-timeline">
|
||||
|
|
@ -1069,7 +1095,7 @@ const renderInstitutionVincTimeline = (report) => {
|
|||
${moreNote(timeline, 'vínculos', 'com fonte — lista completa nas seções por era.')}`
|
||||
}
|
||||
|
||||
const renderInstitutionDeliveries = (report) => {
|
||||
const renderSubjectDeliveries = (report) => {
|
||||
const deliveries = report.page1.deliveries
|
||||
if (deliveries.items.length === 0) return ''
|
||||
return `
|
||||
|
|
@ -1101,7 +1127,7 @@ const renderInstitutionDeliveries = (report) => {
|
|||
</section>`
|
||||
}
|
||||
|
||||
const renderInstitutionHooksAndPending = (report) => {
|
||||
const renderSubjectHooksAndPending = (report) => {
|
||||
const hooks = report.page1.hooks
|
||||
const pending = report.page1.pending
|
||||
if (hooks.items.length === 0 && pending.items.length === 0) return ''
|
||||
|
|
@ -1132,23 +1158,25 @@ const renderInstitutionHooksAndPending = (report) => {
|
|||
</section>`
|
||||
}
|
||||
|
||||
const renderInstitutionSummary = (report, pageNo) => `
|
||||
<article class="sheet" data-page="resumo" aria-label="Resumo de uma olhada do dossiê institucional">
|
||||
const renderSubjectSummary = (report, pageNo) => `
|
||||
<article class="sheet" data-page="resumo" aria-label="Resumo de uma olhada do ${htmlEscape(report.unit.docLabel.toLowerCase())}">
|
||||
${instHeader(report, 'Resumo de uma olhada', 'Informação datada e pronta para conferência', pageNo)}
|
||||
<section class="summary-top">
|
||||
${renderInstitutionIdentity(report)}
|
||||
${renderSubjectIdentity(report)}
|
||||
<div>
|
||||
<p class="eyebrow">01 · vínculo com a instituição</p>
|
||||
<p class="eyebrow">${htmlEscape(report.unit.summaryLinkEyebrow)}</p>
|
||||
<h3 class="section-title">Linha do tempo documentada</h3>
|
||||
${renderInstitutionVincTimeline(report)}
|
||||
${renderSubjectVincTimeline(report)}
|
||||
</div>
|
||||
</section>
|
||||
${renderInstitutionDeliveries(report)}
|
||||
${renderInstitutionHooksAndPending(report)}
|
||||
${renderSubjectDeliveries(report)}
|
||||
${renderSubjectHooksAndPending(report)}
|
||||
${instFooter(report, 'Resumo · INSUMO INTERNO', pageNo)}
|
||||
</article>`
|
||||
|
||||
const renderEraEmptySheet = (report, era, pageNo) => `
|
||||
const renderEraEmptySheet = (report, era, pageNo) => {
|
||||
const empty = report.unit.copy.eraEmpty
|
||||
return `
|
||||
<article class="sheet" data-page="era-${era.id.toLowerCase()}" aria-label="${htmlEscape(era.label)}">
|
||||
${unitHeader(report, `${era.label} · Pesquisa sem evidência suficiente`, era.period, pageNo)}
|
||||
<div class="era-method">
|
||||
|
|
@ -1162,25 +1190,19 @@ const renderEraEmptySheet = (report, era, pageNo) => `
|
|||
${WARNING_ICON}
|
||||
<p class="eyebrow warning-text">Lacuna explícita · não é “zero”</p>
|
||||
<h3 class="rule-title">Nenhuma fonte suficiente foi localizada para esta era.</h3>
|
||||
<p class="muted">A pesquisa não encontrou documento que sustente uma afirmação institucional. Isso não prova que não houve vínculo ou atuação; prova apenas que o dossiê ainda não pode afirmá-los.</p>
|
||||
<p class="muted">${htmlEscape(empty.body)}</p>
|
||||
</section>
|
||||
<section class="page1-grid">
|
||||
<div class="panel">
|
||||
<p class="eyebrow">O que foi feito</p>
|
||||
<ul class="tight-list">
|
||||
<li>Busca nominal por instituição e aliases.</li>
|
||||
<li>Consulta a fontes oficiais e acervos datados.</li>
|
||||
<li>Triagem de resultados por vínculo e abrangência.</li>
|
||||
<li>Descarte de menções sem lastro suficiente.</li>
|
||||
${empty.work.map((item) => `<li>${htmlEscape(item)}</li>`).join('\n ')}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<p class="eyebrow">O que falta fazer</p>
|
||||
<ul class="tight-list">
|
||||
<li>Solicitar consulta ao arquivo físico.</li>
|
||||
<li>Validar nome histórico e unidade da instituição.</li>
|
||||
<li>Recuperar ato, ata ou documento contemporâneo.</li>
|
||||
<li>Repetir busca com alias confirmado.</li>
|
||||
${empty.todo.map((item) => `<li>${htmlEscape(item)}</li>`).join('\n ')}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1188,11 +1210,18 @@ const renderEraEmptySheet = (report, era, pageNo) => `
|
|||
${WARNING_ICON}
|
||||
<div>
|
||||
<p class="eyebrow warning-text">Regra editorial</p>
|
||||
<p><strong>Não preencher a seção por memória, cargo provável ou texto de outra era.</strong> Registrar “${htmlEscape(era.label)}: evidência institucional não localizada nas fontes consultadas”.</p>
|
||||
<p>${(() => {
|
||||
const rule = empty.rule(era.label)
|
||||
const splitAt = rule.indexOf('. ')
|
||||
return splitAt === -1
|
||||
? htmlEscape(rule)
|
||||
: `<strong>${htmlEscape(rule.slice(0, splitAt + 1))}</strong> ${htmlEscape(rule.slice(splitAt + 2))}`
|
||||
})()}</p>
|
||||
</div>
|
||||
</aside>
|
||||
${unitFooter(report, `${era.label} · lacuna explícita`, pageNo)}
|
||||
</article>`
|
||||
}
|
||||
|
||||
/** Accepts the institution's capped objects and the city's plain arrays. */
|
||||
const listOf = (value) => (Array.isArray(value) ? value : (value?.items ?? []))
|
||||
|
|
@ -1325,15 +1354,15 @@ const renderEraSheets = ({ report, era, pack, probe, nextPage, includeHonors = f
|
|||
}).map((entry) => renderEraSheet({ report, era, entry, probe, pageNo: nextPage() }))
|
||||
}
|
||||
|
||||
const PACK_ANCHORS_BY_UNIT = {
|
||||
municipality: MUNICIPALITY_PACK_ANCHORS,
|
||||
institution: INSTITUTION_PACK_ANCHORS,
|
||||
theme: THEME_PACK_ANCHORS,
|
||||
}
|
||||
|
||||
/** Anchor map of the unit's packed sections (the builder inverts it). */
|
||||
export const dossierPackAnchors = (unit) =>
|
||||
isInstitutionUnit(unit) ? INSTITUTION_PACK_ANCHORS : MUNICIPALITY_PACK_ANCHORS
|
||||
|
||||
const INSTITUTION_SCOPE_WORDS = {
|
||||
institution: 'instituicao',
|
||||
sector: 'setor',
|
||||
network: 'rede',
|
||||
}
|
||||
PACK_ANCHORS_BY_UNIT[resolveDossierUnit(unit).id] ?? MUNICIPALITY_PACK_ANCHORS
|
||||
|
||||
const listYearRange = (items) => {
|
||||
const years = items
|
||||
|
|
@ -1343,12 +1372,14 @@ const listYearRange = (items) => {
|
|||
return years.length ? `${years[0]}–${years[years.length - 1]}` : null
|
||||
}
|
||||
|
||||
const renderInstitutionScope = (report, pageNo) => {
|
||||
const renderSubjectScope = (report, pageNo) => {
|
||||
const reach = report.reach
|
||||
const cardClass = { institution: '', sector: 'card-sector', network: 'card-network' }
|
||||
const sphereTitle = report.unit.spheres
|
||||
.map((sphere) => report.unit.sphereLabels[sphere])
|
||||
.join(' × ')
|
||||
return `
|
||||
<article class="sheet" data-page="abrangencia" aria-label="Painel de abrangência institucional">
|
||||
${instHeader(report, 'Abrangência: instituição × setor × rede', 'Três recortes, três leituras, nenhuma soma combinada', pageNo)}
|
||||
<article class="sheet" data-page="abrangencia" aria-label="${htmlEscape(report.unit.aria?.scope ?? 'Painel de abrangência institucional')}">
|
||||
${instHeader(report, `Abrangência: ${sphereTitle}`, 'Três recortes, três leituras, nenhuma soma combinada', pageNo)}
|
||||
|
||||
<section class="rule-warning">
|
||||
${WARNING_ICON}
|
||||
|
|
@ -1364,8 +1395,8 @@ const renderInstitutionScope = (report, pageNo) => {
|
|||
<div class="scope-cards">
|
||||
${reach.lists
|
||||
.map(
|
||||
(list) => `<article class="scope-card ${cardClass[list.key] ?? ''}">
|
||||
<div class="action-head">${scopeBadge(list.sphere, null, report.unit)}<span class="meta strong">${list.sphere === 'instituicao' ? 'DIRETO' : 'NÃO SOMAR'}</span></div>
|
||||
(list) => `<article class="scope-card ${report.unit.scopeCardClass[list.sphere] ?? ''}">
|
||||
<div class="action-head">${scopeBadge(list.sphere, null, report.unit)}<span class="meta strong">${list.sphere === report.unit.defaultSphere ? 'DIRETO' : 'NÃO SOMAR'}</span></div>
|
||||
<p class="scope-count tabular">${list.total} ${list.total === 1 ? 'item' : 'itens'}</p>
|
||||
<p class="meta">com fonte${listYearRange(list.items) ? ` · ${listYearRange(list.items)}` : ''} · lista completa nas páginas seguintes</p>
|
||||
<ul class="tight-list">${list.items
|
||||
|
|
@ -1405,27 +1436,50 @@ const renderInstitutionScope = (report, pageNo) => {
|
|||
}
|
||||
|
||||
/** Sphere lists are uncapped: each one flows across as many sheets as it needs. */
|
||||
const institutionScopeUnits = (report, list, probe) =>
|
||||
list.items.map((item, index) => ({
|
||||
layout: 'tr',
|
||||
html: `<tr${unitAttrs(probe, index, 'tr')}>
|
||||
const subjectScopeUnits = (report, list, probe) =>
|
||||
list.items.map((item, index) => {
|
||||
const attrs = unitAttrs(probe, index, 'tr')
|
||||
if (!report.unit.reachScene) {
|
||||
return {
|
||||
layout: 'tr',
|
||||
html: `<tr${attrs}>
|
||||
<td>${copyHtml(briefOr(item.brief, item.title))}</td>
|
||||
<td>${scopeBadge(item.sphere, null, report.unit)}</td>
|
||||
<td>${copyHtml(noteOr(item.brief, item.evidence))}</td>
|
||||
<td>${sourceLink(item.sourceUrl)}</td>
|
||||
</tr>`,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return {
|
||||
layout: 'tr',
|
||||
html: `<tr${attrs}>
|
||||
<td>${copyHtml(briefOr(item.brief, item.title))}</td>
|
||||
<td class="nowrap">Era ${htmlEscape(item.era ?? '—')}${item.year ? ` · ${htmlEscape(item.year)}` : ''}</td>
|
||||
<td>${valueCell(item.value)}${item.phase ? ` ${phaseBadge(item.phase)}` : ''}</td>
|
||||
<td>${copyHtml(noteOr(item.brief, item.evidence))}</td>
|
||||
<td>${sourceLink(item.sourceUrl)}</td>
|
||||
</tr>`,
|
||||
}
|
||||
})
|
||||
|
||||
const renderInstitutionScopeListSheet = ({ report, list, entry, probe, pageNo }) => {
|
||||
const word = INSTITUTION_SCOPE_WORDS[list.key] ?? list.key
|
||||
const notSummable = list.sphere !== 'instituicao'
|
||||
const scopeListWord = (report, list) => report.unit.scopeListWords[list.key] ?? list.key
|
||||
|
||||
const renderSubjectScopeListSheet = ({ report, list, entry, probe, pageNo }) => {
|
||||
const scene = report.unit.reachScene
|
||||
const word = scopeListWord(report, list)
|
||||
const notSummable = list.sphere !== report.unit.defaultSphere
|
||||
const columns = scene ? scene.columns : ['Item', 'Abrangência', 'Evidência de alcance', 'Fonte']
|
||||
const tableClass = scene ? `document-table ${scene.tableClass}` : 'document-table'
|
||||
const colgroup = scene
|
||||
? '<colgroup><col style="width:24%" /><col style="width:13%" /><col style="width:16%" /><col style="width:29%" /><col style="width:18%" /></colgroup>'
|
||||
: '<colgroup><col style="width:26%" /><col style="width:14%" /><col style="width:40%" /><col style="width:20%" /></colgroup>'
|
||||
return `
|
||||
<article class="sheet" data-page="${entry.anchor}"${probe ? ` data-pack-section="scope:${word}"` : ''} aria-label="${htmlEscape(`Abrangência · ${list.label}`)}">
|
||||
${instHeader(
|
||||
report,
|
||||
`Abrangência · ${list.label}`,
|
||||
entry.index === 0
|
||||
? `${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte · ${notSummable ? 'não somar à instituição' : 'recorte direto'}`
|
||||
? `${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte · ${notSummable ? `não somar ${report.unit.notSummedTo}` : 'recorte direto'}`
|
||||
: `continuação ${entry.index + 1} · ${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte`,
|
||||
pageNo,
|
||||
)}
|
||||
|
|
@ -1435,16 +1489,16 @@ const renderInstitutionScopeListSheet = ({ report, list, entry, probe, pageNo })
|
|||
${WARNING_ICON}
|
||||
<div>
|
||||
<p class="rule-title">${htmlEscape(report.reach.ruleTitle)}</p>
|
||||
<p class="muted">Cada linha informa sua abrangência; setor e rede não são somados à instituição.</p>
|
||||
<p class="muted">Cada linha informa sua abrangência; ${htmlEscape(report.unit.sumGuardNote)}</p>
|
||||
</div>
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
<section class="block">
|
||||
<table class="document-table">
|
||||
<table class="${tableClass}">
|
||||
<caption class="sr-only">Itens do recorte com evidência de alcance e fonte</caption>
|
||||
<colgroup><col style="width:26%" /><col style="width:14%" /><col style="width:40%" /><col style="width:20%" /></colgroup>
|
||||
${tableHead(['Item', 'Abrangência', 'Evidência de alcance', 'Fonte'])}
|
||||
${colgroup}
|
||||
${tableHead(columns)}
|
||||
<tbody>${entry.chunk.map((unit) => unit.html).join('')}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
|
@ -1452,19 +1506,17 @@ const renderInstitutionScopeListSheet = ({ report, list, entry, probe, pageNo })
|
|||
</article>`
|
||||
}
|
||||
|
||||
const renderInstitutionScopeListSheets = ({ report, list, pack, probe, nextPage }) => {
|
||||
const renderSubjectScopeListSheets = ({ report, list, pack, probe, nextPage }) => {
|
||||
if (list.items.length === 0) return []
|
||||
const word = INSTITUTION_SCOPE_WORDS[list.key] ?? list.key
|
||||
const units = institutionScopeUnits(report, list, probe)
|
||||
const word = scopeListWord(report, list)
|
||||
const units = subjectScopeUnits(report, list, probe)
|
||||
return packedSection({
|
||||
key: `scope:${word}`,
|
||||
anchor: `abrangencia-${word}`,
|
||||
units,
|
||||
pack,
|
||||
probe,
|
||||
}).map((entry) =>
|
||||
renderInstitutionScopeListSheet({ report, list, entry, probe, pageNo: nextPage() }),
|
||||
)
|
||||
}).map((entry) => renderSubjectScopeListSheet({ report, list, entry, probe, pageNo: nextPage() }))
|
||||
}
|
||||
|
||||
const honorDate = (honor) => {
|
||||
|
|
@ -1473,10 +1525,10 @@ const honorDate = (honor) => {
|
|||
return '—'
|
||||
}
|
||||
|
||||
const institutionHonors = (report) => report.eras.find((era) => era.id === 'C')?.honors?.items ?? []
|
||||
const subjectHonors = (report) => report.eras.find((era) => era.id === 'C')?.honors?.items ?? []
|
||||
|
||||
const institutionHonorUnits = (probe) =>
|
||||
institutionHonors(probe.report).map((honor, index) => ({
|
||||
const subjectHonorUnits = (probe) =>
|
||||
subjectHonors(probe.report).map((honor, index) => ({
|
||||
layout: 'tr',
|
||||
html: `<tr${unitAttrs(probe.probe, index, 'tr')}>
|
||||
<td class="numeric">${htmlEscape(honorDate(honor))}</td>
|
||||
|
|
@ -1486,7 +1538,7 @@ const institutionHonorUnits = (probe) =>
|
|||
</tr>`,
|
||||
}))
|
||||
|
||||
const renderInstitutionHonorSheet = ({ report, entry, probe, pageNo }) => {
|
||||
const renderSubjectHonorSheet = ({ report, entry, probe, pageNo }) => {
|
||||
const gap = report.gaps?.[0] ?? null
|
||||
const hook = report.reach.hook ?? report.page1.hooks.items[0] ?? null
|
||||
return `
|
||||
|
|
@ -1543,11 +1595,11 @@ const renderInstitutionHonorSheet = ({ report, entry, probe, pageNo }) => {
|
|||
</article>`
|
||||
}
|
||||
|
||||
const renderInstitutionHonorSheets = ({ report, pack, probe, nextPage }) => {
|
||||
const units = institutionHonorUnits({ report, probe })
|
||||
const renderSubjectHonorSheets = ({ report, pack, probe, nextPage }) => {
|
||||
const units = subjectHonorUnits({ report, probe })
|
||||
if (units.length === 0) return []
|
||||
return packedSection({ key: 'honors', anchor: 'titulos', units, pack, probe }).map((entry) =>
|
||||
renderInstitutionHonorSheet({ report, entry, probe, pageNo: nextPage() }),
|
||||
renderSubjectHonorSheet({ report, entry, probe, pageNo: nextPage() }),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1556,34 +1608,64 @@ const renderInstitutionHonorSheets = ({ report, pack, probe, nextPage }) => {
|
|||
* the sources sheet keeps only the acervo, limits and defeso.
|
||||
*/
|
||||
const gapUnits = (report, probe) =>
|
||||
report.gaps.map((gap, index) => ({
|
||||
layout: 'tr',
|
||||
html: `<tr${unitAttrs(probe, index, 'tr')}>
|
||||
report.gaps.map((gap, index) => {
|
||||
const attrs = unitAttrs(probe, index, 'tr')
|
||||
if (!report.unit.gapsScene) {
|
||||
return {
|
||||
layout: 'tr',
|
||||
html: `<tr${attrs}>
|
||||
<td><strong>${htmlEscape(gap.label)}</strong></td>
|
||||
<td>${htmlEscape(gap.reason)}</td>
|
||||
<td>Não completar por inferência.</td>
|
||||
<td>${htmlEscape(gap.nextStep)}</td>
|
||||
</tr>`,
|
||||
}))
|
||||
}
|
||||
}
|
||||
const era = gap.era && gap.era !== 'Acervo' ? `Era ${gap.era}` : 'Acervo'
|
||||
const status =
|
||||
index === 0
|
||||
? '<span class="phase phase-pending">prioridade</span>'
|
||||
: '<span class="phase">aberta</span>'
|
||||
return {
|
||||
layout: 'tr',
|
||||
html: `<tr${attrs}>
|
||||
<td class="nowrap">${htmlEscape(era)}</td>
|
||||
<td><strong>${htmlEscape(gap.label)}</strong></td>
|
||||
<td>${htmlEscape(gap.reason)}</td>
|
||||
<td>${htmlEscape(gap.nextStep)}</td>
|
||||
<td>${status}</td>
|
||||
</tr>`,
|
||||
}
|
||||
})
|
||||
|
||||
const renderGapSheet = ({ report, entry, probe, pageNo }) => `
|
||||
const renderGapSheet = ({ report, entry, probe, pageNo, isLast = false }) => {
|
||||
const scene = report.unit.gapsScene
|
||||
const columns = scene
|
||||
? scene.columns
|
||||
: ['Lacuna', 'Situação da busca', 'Limite editorial', 'Próximo passo']
|
||||
const colgroup = scene
|
||||
? '<colgroup><col style="width:12%" /><col style="width:26%" /><col style="width:24%" /><col style="width:22%" /><col style="width:16%" /></colgroup>'
|
||||
: '<colgroup><col style="width:26%" /><col style="width:30%" /><col style="width:24%" /><col style="width:20%" /></colgroup>'
|
||||
return `
|
||||
<article class="sheet" data-page="${entry.anchor}"${probe ? ' data-pack-section="gaps"' : ''} aria-label="Lacunas explícitas">
|
||||
${unitHeader(report, 'Lacunas explícitas', `O que não foi encontrado${entry.index > 0 ? ` · continuação ${entry.index + 1}` : ''}`, pageNo)}
|
||||
<section class="block">
|
||||
<div class="block-head"><div><p class="eyebrow">O que não foi encontrado</p><h3 class="section-title">Lacunas explícitas</h3></div><p class="warning-text meta strong">Não completar por memória ou inferência</p></div>
|
||||
<table class="document-table">
|
||||
<caption class="sr-only">Tabela de lacunas explícitas e próximos passos de apuração</caption>
|
||||
<colgroup><col style="width:26%" /><col style="width:30%" /><col style="width:24%" /><col style="width:20%" /></colgroup>
|
||||
${tableHead(['Lacuna', 'Situação da busca', 'Limite editorial', 'Próximo passo'])}
|
||||
${colgroup}
|
||||
${tableHead(columns)}
|
||||
<tbody>${
|
||||
entry.chunk.length
|
||||
? entry.chunk.map((unit) => unit.html).join('')
|
||||
: '<tr><td colspan="4" class="muted">Nenhuma lacuna declarada — confira a cobertura das eras.</td></tr>'
|
||||
: `<tr><td colspan="${columns.length}" class="muted">Nenhuma lacuna declarada — confira a cobertura das eras.</td></tr>`
|
||||
}</tbody>
|
||||
</table>
|
||||
${scene && isLast ? `<p class="warning-text small strong">${htmlEscape(scene.alert)}</p>` : ''}
|
||||
</section>
|
||||
${unitFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const renderGapSheets = ({ report, pack, probe, nextPage }) => {
|
||||
const units = gapUnits(report, probe)
|
||||
|
|
@ -1594,11 +1676,19 @@ const renderGapSheets = ({ report, pack, probe, nextPage }) => {
|
|||
entry: { anchor: 'lacunas', chunk: [], index: 0 },
|
||||
probe: false,
|
||||
pageNo: nextPage(),
|
||||
isLast: true,
|
||||
}),
|
||||
]
|
||||
}
|
||||
return packedSection({ key: 'gaps', anchor: 'lacunas', units, pack, probe }).map((entry) =>
|
||||
renderGapSheet({ report, entry, probe, pageNo: nextPage() }),
|
||||
const entries = packedSection({ key: 'gaps', anchor: 'lacunas', units, pack, probe })
|
||||
return entries.map((entry, index) =>
|
||||
renderGapSheet({
|
||||
report,
|
||||
entry,
|
||||
probe,
|
||||
pageNo: nextPage(),
|
||||
isLast: index === entries.length - 1,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1639,12 +1729,39 @@ const renderNewsSheets = ({ report, pack, probe, nextPage }) => {
|
|||
|
||||
/** Acervo sample (source panel): declared sample with the total carried by the header. */
|
||||
const acervoUnits = (report, probe) =>
|
||||
report.acervo.items.map((row, index) => ({
|
||||
layout: 'tr',
|
||||
html: `<li${unitAttrs(probe, index, 'tr')}><strong>${htmlEscape(row.period)}</strong> — ${copyHtml(row.text)} ${sourceLink(row.sourceUrl)}</li>`,
|
||||
}))
|
||||
report.acervo.items.map((row, index) => {
|
||||
const attrs = unitAttrs(probe, index, 'tr')
|
||||
if (!report.unit.acervoScene) {
|
||||
return {
|
||||
layout: 'tr',
|
||||
html: `<li${attrs}><strong>${htmlEscape(row.period)}</strong> — ${copyHtml(row.text)} ${sourceLink(row.sourceUrl)}</li>`,
|
||||
}
|
||||
}
|
||||
const identity = report.meta.identity ?? {}
|
||||
const canonical = [
|
||||
identity.label,
|
||||
identity.value ? `<code>${htmlEscape(identity.value)}</code>` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
return {
|
||||
layout: 'tr',
|
||||
html: `<tr${attrs}>
|
||||
<td class="numeric nowrap">${htmlEscape(row.period)}</td>
|
||||
<td>${canonical}</td>
|
||||
<td>${copyHtml(row.text)}${row.excerpt ? `<br /><span class="meta">${copyHtml(row.excerpt)}</span>` : ''}</td>
|
||||
<td>${scopeBadge('area', null, report.unit)}</td>
|
||||
<td>${sourceLink(row.sourceUrl)}</td>
|
||||
</tr>`,
|
||||
}
|
||||
})
|
||||
|
||||
const renderAcervoSheet = ({ report, entry, probe, pageNo, isLast }) => `
|
||||
const acervoStatLabel = (scene, key, fallback) =>
|
||||
scene.stats?.find((stat) => stat.key === key)?.label ?? fallback
|
||||
|
||||
const renderAcervoSheet = ({ report, entry, probe, pageNo, isLast }) => {
|
||||
const scene = report.unit.acervoScene
|
||||
return `
|
||||
<article class="sheet" data-page="${entry.anchor}"${probe ? ' data-pack-section="acervo"' : ''} aria-label="Acervo interno de falas (amostra)">
|
||||
${unitHeader(
|
||||
report,
|
||||
|
|
@ -1654,18 +1771,45 @@ const renderAcervoSheet = ({ report, entry, probe, pageNo, isLast }) => `
|
|||
: `continuação ${entry.index + 1}`,
|
||||
pageNo,
|
||||
)}
|
||||
${
|
||||
scene && entry.index === 0
|
||||
? `<section class="acervo-stat-grid">
|
||||
<div class="scope-card">
|
||||
<p class="eyebrow">${htmlEscape(acervoStatLabel(scene, 'universe', 'Universo recuperado'))}</p>
|
||||
<p class="scope-count tabular">${report.acervo.recorte}</p>
|
||||
<p class="meta">Falas classificadas no tema canônico do recorte (2011+).</p>
|
||||
</div>
|
||||
<div class="scope-card card-sector">
|
||||
<p class="eyebrow warning-text">${htmlEscape(acervoStatLabel(scene, 'sample', 'Amostra exibida'))}</p>
|
||||
<p class="scope-count tabular">${report.acervo.items.length} de ${report.acervo.recorte}</p>
|
||||
<p class="meta">Quantidade e critério explícitos; a amostra não finge exaustividade.</p>
|
||||
</div>
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
<section class="block">
|
||||
<p class="eyebrow">Falas do mandato sobre o recorte</p>
|
||||
<h3 class="section-title">Amostra com link direto</h3>
|
||||
<ul class="tight-list">${entry.chunk.map((unit) => unit.html).join('')}</ul>
|
||||
${
|
||||
scene
|
||||
? `<table class="document-table">
|
||||
<caption class="sr-only">Amostra do acervo por data, tema canônico, trecho e abrangência</caption>
|
||||
<colgroup><col style="width:11%" /><col style="width:20%" /><col style="width:37%" /><col style="width:12%" /><col style="width:20%" /></colgroup>
|
||||
${tableHead(scene.columns)}
|
||||
<tbody>${entry.chunk.map((unit) => unit.html).join('')}</tbody>
|
||||
</table>`
|
||||
: `<ul class="tight-list">${entry.chunk.map((unit) => unit.html).join('')}</ul>`
|
||||
}
|
||||
${
|
||||
isLast
|
||||
? `<p class="more-note">Amostra das falas mais recentes do recorte (${report.acervo.items.length} de ${report.acervo.recorte}); o acervo completo fica na base Teqo. ${htmlEscape(report.unit.acervoNote ?? '')}</p>`
|
||||
: ''
|
||||
}
|
||||
${scene && isLast ? `<p class="warning-text small strong">${htmlEscape(scene.alert)}</p>` : ''}
|
||||
</section>
|
||||
${unitFooter(report, 'Acervo interno · amostra', pageNo)}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const renderAcervoSheets = ({ report, pack, probe, nextPage }) => {
|
||||
const units = acervoUnits(report, probe)
|
||||
|
|
@ -1683,9 +1827,29 @@ const renderAcervoSheets = ({ report, pack, probe, nextPage }) => {
|
|||
}
|
||||
|
||||
const renderUnitSourcesPage = (report, pageNo) => `
|
||||
<article class="sheet" data-page="fontes" aria-label="Fontes e limites do dossiê institucional">
|
||||
<article class="sheet" data-page="fontes" aria-label="${htmlEscape(report.unit.aria?.sources ?? 'Fontes e limites do dossiê institucional')}">
|
||||
${unitHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)}
|
||||
|
||||
${
|
||||
report.unit.sourcesHierarchy
|
||||
? `<section class="block">
|
||||
<p class="eyebrow">Hierarquia de fontes</p>
|
||||
<h3 class="section-title">Lastro visível, item a item</h3>
|
||||
<table class="document-table">
|
||||
<caption class="sr-only">Camadas de fonte, seu uso e seu limite</caption>
|
||||
<colgroup><col style="width:24%" /><col style="width:38%" /><col style="width:38%" /></colgroup>
|
||||
${tableHead(['Camada', 'Uso', 'Limite'])}
|
||||
<tbody>${report.unit.sourcesHierarchy
|
||||
.map(
|
||||
(row) =>
|
||||
`<tr><td><strong>${htmlEscape(row.layer)}</strong></td><td>${htmlEscape(row.use)}</td><td>${htmlEscape(row.limit)}</td></tr>`,
|
||||
)
|
||||
.join('')}</tbody>
|
||||
</table>
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
report.acervo?.recorte
|
||||
? `<section class="rule-warning">
|
||||
|
|
@ -1720,7 +1884,7 @@ const renderUnitSourcesPage = (report, pageNo) => `
|
|||
</article>`
|
||||
|
||||
const renderUnitSynthesisPage = (report, pageNo) => `
|
||||
<article class="sheet" data-page="sintese" aria-label="Síntese da atuação institucional">
|
||||
<article class="sheet" data-page="sintese" aria-label="${htmlEscape(report.unit.aria?.synthesis ?? 'Síntese da atuação institucional')}">
|
||||
${unitHeader(report, 'Síntese do que foi localizado', 'Leitura dos números com lastro — contagens por recorte, nunca soma entre eles', pageNo)}
|
||||
<section class="block">
|
||||
<p class="eyebrow">Leitura rápida</p>
|
||||
|
|
@ -1736,7 +1900,9 @@ const renderUnitSynthesisPage = (report, pageNo) => `
|
|||
`<li><strong>${htmlEscape(era.label)}</strong> — ${
|
||||
era.empty
|
||||
? 'sem evidência suficiente (lacuna explícita)'
|
||||
: `${listOf(era.numbers).length + listOf(era.actions).length} pontos nesta seção`
|
||||
: report.unit.eraCoverageFromLedger
|
||||
? `${report.synthesis.byEra.find((row) => row.key === era.id)?.count ?? 0} pontos com fonte`
|
||||
: `${listOf(era.numbers).length + listOf(era.actions).length} pontos nesta seção`
|
||||
}</li>`,
|
||||
)
|
||||
.join('')}</ul>
|
||||
|
|
@ -1744,7 +1910,7 @@ const renderUnitSynthesisPage = (report, pageNo) => `
|
|||
<div class="panel panel-warning">
|
||||
<p class="eyebrow warning-text">Guardas de leitura</p>
|
||||
<ul class="tight-list">
|
||||
<li>Contagens por recorte: setor e rede nunca somados à instituição.</li>
|
||||
<li>Contagens por recorte: ${htmlEscape(report.unit.copy?.synthesisGuard ?? 'setor e rede nunca somados à instituição.')}</li>
|
||||
<li>Cada valor mantém a fase — empenho ≠ pagamento.</li>
|
||||
<li>Sem fonte, não publica: ausência vira lacuna explícita.</li>
|
||||
</ul>
|
||||
|
|
@ -1781,8 +1947,8 @@ const renderUnitChartsPage = (report, pageNo) => {
|
|||
row.segments.some((segment) => executedPhases.has(segment.key)),
|
||||
)
|
||||
return `
|
||||
<article class="sheet" data-page="graficos" aria-label="Gráficos consolidados da atuação institucional">
|
||||
${unitHeader(report, 'Gráficos consolidados', 'Como os recursos e as entregas chegaram à instituição — valores sempre com a fase', pageNo)}
|
||||
<article class="sheet" data-page="graficos" aria-label="${htmlEscape(report.unit.aria?.charts ?? 'Gráficos consolidados da atuação institucional')}">
|
||||
${unitHeader(report, 'Gráficos consolidados', `Como os recursos e as entregas chegaram ${report.unit.notSummedTo ?? 'à instituição'} — valores sempre com a fase`, pageNo)}
|
||||
<div class="chart-grid">
|
||||
<div class="chart-card">
|
||||
<p class="chart-title">Recursos com execução por ano (R$)</p>
|
||||
|
|
@ -1792,6 +1958,7 @@ const renderUnitChartsPage = (report, pageNo) => {
|
|||
rows: executedYears.map((row) => ({ ...row, value: row.amount })),
|
||||
height: 136,
|
||||
format: (row) => formatMoneyCompact(row.value),
|
||||
fullYearLabel: Boolean(report.unit.fullYearAxis),
|
||||
})
|
||||
: '<p class="muted small">Nenhum valor com fase de execução informada.</p>'
|
||||
}
|
||||
|
|
@ -1826,11 +1993,15 @@ const renderUnitChartsPage = (report, pageNo) => {
|
|||
<p class="chart-legend">${htmlEscape(report.unit.sumGuardNote ?? '')}</p>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<p class="chart-title">Trajetória: pontos com fonte por ano (20xx)</p>
|
||||
${columnChart({ rows: synthesis.byYear, height: 156 })}
|
||||
<p class="chart-title">${htmlEscape(report.unit.byYearTitle ?? 'Trajetória: pontos com fonte por ano (20xx)')}</p>
|
||||
${columnChart({
|
||||
rows: synthesis.byYear,
|
||||
height: 156,
|
||||
fullYearLabel: Boolean(report.unit.fullYearAxis),
|
||||
})}
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<p class="chart-title">Áreas com mais registros</p>
|
||||
<p class="chart-title">${htmlEscape(report.unit.byAreaTitle ?? 'Áreas com mais registros')}</p>
|
||||
${barChart({
|
||||
rows: synthesis.byArea.slice(0, 6).map((row) => ({ label: row.key, count: row.count })),
|
||||
})}
|
||||
|
|
@ -1860,7 +2031,7 @@ const renderUnitOpeningPage = (report, pageNo) => {
|
|||
const opening = report.opening
|
||||
if (!opening?.paragraphs?.length) return ''
|
||||
return `
|
||||
<article class="sheet" data-page="carta" aria-label="A contribuição de Solla para a instituição">
|
||||
<article class="sheet" data-page="carta" aria-label="A contribuição de Solla para a ${htmlEscape(report.unit.contributionLabel ?? 'instituição')}">
|
||||
${unitHeader(report, opening.title, 'Redação de síntese sobre os pontos com fonte — nada aqui acrescenta fato novo', pageNo)}
|
||||
<section class="block">
|
||||
${opening.paragraphs.map((paragraph) => `<p class="opening-paragraph">${copyHtml(paragraph)}</p>`).join('')}
|
||||
|
|
@ -1880,29 +2051,40 @@ const renderUnitOpeningPage = (report, pageNo) => {
|
|||
</article>`
|
||||
}
|
||||
|
||||
const renderInstitutionDossierHtml = (report, { pack = null, probe = false } = {}) => {
|
||||
const renderSubjectDossierHtml = (report, { pack = null, probe = false } = {}) => {
|
||||
const buildParts = () => {
|
||||
let page = 0
|
||||
const nextPage = () => ++page
|
||||
const parts = []
|
||||
parts.push(renderInstitutionCover(report))
|
||||
parts.push(renderSubjectCover(report))
|
||||
page += 1
|
||||
const opening = renderUnitOpeningPage(report, page + 1)
|
||||
if (opening) {
|
||||
parts.push(opening)
|
||||
page += 1
|
||||
}
|
||||
parts.push(renderInstitutionSummary(report, ++page))
|
||||
parts.push(renderSubjectSummary(report, ++page))
|
||||
parts.push(renderUnitSynthesisPage(report, nextPage()))
|
||||
parts.push(renderUnitChartsPage(report, nextPage()))
|
||||
for (const era of report.eras) {
|
||||
parts.push(...renderEraSheets({ report, era, pack, probe, nextPage, includeHonors: false }))
|
||||
parts.push(
|
||||
...renderEraSheets({
|
||||
report,
|
||||
era,
|
||||
pack,
|
||||
probe,
|
||||
nextPage,
|
||||
includeHonors: !report.unit.honorsSheet,
|
||||
}),
|
||||
)
|
||||
}
|
||||
parts.push(renderInstitutionScope(report, nextPage()))
|
||||
parts.push(renderSubjectScope(report, nextPage()))
|
||||
for (const list of report.reach.lists) {
|
||||
parts.push(...renderInstitutionScopeListSheets({ report, list, pack, probe, nextPage }))
|
||||
parts.push(...renderSubjectScopeListSheets({ report, list, pack, probe, nextPage }))
|
||||
}
|
||||
if (report.unit.honorsSheet) {
|
||||
parts.push(...renderSubjectHonorSheets({ report, pack, probe, nextPage }))
|
||||
}
|
||||
parts.push(...renderInstitutionHonorSheets({ report, pack, probe, nextPage }))
|
||||
parts.push(...renderGapSheets({ report, pack, probe, nextPage }))
|
||||
parts.push(...renderNewsSheets({ report, pack, probe, nextPage }))
|
||||
parts.push(...renderAcervoSheets({ report, pack, probe, nextPage }))
|
||||
|
|
@ -1926,7 +2108,7 @@ ${parts.join('\n')}
|
|||
</html>`
|
||||
}
|
||||
|
||||
const institutionMdEra = (era) => {
|
||||
const subjectMdEra = (era) => {
|
||||
const lines = [
|
||||
`## ${era.label} (${era.period})`,
|
||||
'',
|
||||
|
|
@ -1973,7 +2155,7 @@ const institutionMdEra = (era) => {
|
|||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const renderInstitutionDossierMd = (report) => {
|
||||
const renderSubjectDossierMd = (report) => {
|
||||
const lines = [
|
||||
`# ${report.meta.title} — ${report.meta.subjectName}`,
|
||||
'',
|
||||
|
|
@ -1984,11 +2166,9 @@ const renderInstitutionDossierMd = (report) => {
|
|||
'',
|
||||
`## Identificação`,
|
||||
'',
|
||||
`- **Nome:** ${report.meta.identity?.name ?? '—'}`,
|
||||
`- **Tipo:** ${report.meta.identity?.kindLabel ?? '—'}`,
|
||||
`- **Esfera:** ${report.meta.identity?.sphereLabel ?? '—'}`,
|
||||
`- **Alcance:** ${report.meta.identity?.scopeLabel ?? '—'}`,
|
||||
`- **Alias:** ${(report.meta.identity?.aliases ?? []).join(' · ') || '—'}`,
|
||||
...report.unit
|
||||
.identityRows(report.meta.identity ?? {})
|
||||
.map(([key, value]) => `- **${key}:** ${value}`),
|
||||
'',
|
||||
]
|
||||
if (report.opening?.paragraphs?.length) {
|
||||
|
|
@ -2025,17 +2205,20 @@ const renderInstitutionDossierMd = (report) => {
|
|||
lines.push('', '_Autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor._')
|
||||
}
|
||||
|
||||
for (const era of report.eras) lines.push('', institutionMdEra(era))
|
||||
for (const era of report.eras) lines.push('', subjectMdEra(era))
|
||||
|
||||
if (report.synthesis?.lines?.length) {
|
||||
lines.push('', '## Síntese do que foi localizado', '')
|
||||
for (const line of report.synthesis.lines) lines.push(`- ${line}`)
|
||||
lines.push('', '### Números por recorte', '')
|
||||
lines.push('| Era | Instituição | Setor | Rede | Total |', '| --- | --- | --- | --- | --- |')
|
||||
lines.push(
|
||||
`| Era | ${report.unit.spheres.map((sphere) => capitalize(report.unit.sphereLabels[sphere])).join(' | ')} | Total |`,
|
||||
`| --- | ${report.unit.spheres.map(() => '---').join(' | ')} | --- |`,
|
||||
)
|
||||
for (const row of report.synthesis.byEraSphere) {
|
||||
const countFor = (key) => row.segments.find((segment) => segment.key === key)?.count ?? 0
|
||||
lines.push(
|
||||
`| ${row.label} | ${countFor('instituicao')} | ${countFor('setor')} | ${countFor('rede')} | ${row.segments.reduce((sum, segment) => sum + segment.count, 0)} |`,
|
||||
`| ${row.label} | ${report.unit.spheres.map((sphere) => countFor(sphere)).join(' | ')} | ${row.segments.reduce((sum, segment) => sum + segment.count, 0)} |`,
|
||||
)
|
||||
}
|
||||
if (report.synthesis.byPhase.length) {
|
||||
|
|
@ -2052,7 +2235,11 @@ const renderInstitutionDossierMd = (report) => {
|
|||
}
|
||||
}
|
||||
|
||||
lines.push('', '## Abrangência: instituição × setor × rede', '')
|
||||
lines.push(
|
||||
'',
|
||||
`## Abrangência: ${report.unit.spheres.map((sphere) => report.unit.sphereLabels[sphere]).join(' × ')}`,
|
||||
'',
|
||||
)
|
||||
lines.push(`> ${report.reach.ruleTitle}`, `> ${report.reach.ruleBody}`, '')
|
||||
for (const list of report.reach.lists) {
|
||||
lines.push(`### ${list.label}`, '')
|
||||
|
|
@ -2071,7 +2258,7 @@ const renderInstitutionDossierMd = (report) => {
|
|||
'',
|
||||
`## Acervo interno (read-only)`,
|
||||
'',
|
||||
`_Amostra de ${report.acervo.items.length} de ${report.acervo.recorte} falas do recorte temático, com link; o acervo completo fica na base Teqo. Recorte por tema, não nominal._`,
|
||||
`_Amostra de ${report.acervo.items.length} de ${report.acervo.recorte} falas do recorte temático, com link; o acervo completo fica na base Teqo. ${report.unit.copy.mdAcervoNote}_`,
|
||||
'',
|
||||
)
|
||||
for (const row of report.acervo.items) {
|
||||
|
|
@ -2115,13 +2302,11 @@ const renderInstitutionDossierMd = (report) => {
|
|||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
/** Dispatch: institution reports render with the institutional sections. */
|
||||
/** Dispatch: subject-shaped reports (institution/theme) render with the shared subject sections. */
|
||||
export const renderDossierHtml = (report, options) =>
|
||||
isInstitutionUnit(report.unit)
|
||||
? renderInstitutionDossierHtml(report, options)
|
||||
isSubjectUnit(report.unit)
|
||||
? renderSubjectDossierHtml(report, options)
|
||||
: renderMunicipalityDossierHtml(report, options)
|
||||
|
||||
export const renderDossierMd = (report) =>
|
||||
isInstitutionUnit(report.unit)
|
||||
? renderInstitutionDossierMd(report)
|
||||
: renderMunicipalityDossierMd(report)
|
||||
isSubjectUnit(report.unit) ? renderSubjectDossierMd(report) : renderMunicipalityDossierMd(report)
|
||||
|
|
|
|||
|
|
@ -215,10 +215,123 @@ const INSTITUTION_RESEARCH_CHECKLIST = [
|
|||
},
|
||||
]
|
||||
|
||||
/** Theme/area checklist (C190) — same eras, area-oriented. */
|
||||
const THEME_RESEARCH_CHECKLIST = [
|
||||
{
|
||||
id: 'era_a_formacao',
|
||||
era: 'A',
|
||||
area: 'Formação',
|
||||
label: 'Formação, residência e pesquisa com interface na área',
|
||||
},
|
||||
{
|
||||
id: 'era_a_sesab',
|
||||
era: 'A',
|
||||
area: 'Gestão pública',
|
||||
label: 'Atuação na gestão pública com efeito ou menção à área',
|
||||
},
|
||||
{
|
||||
id: 'era_a_consultor_ms',
|
||||
era: 'A',
|
||||
area: 'Ministério da Saúde',
|
||||
label: 'Consultoria no Ministério da Saúde na área',
|
||||
},
|
||||
{
|
||||
id: 'era_a_conquista',
|
||||
era: 'A',
|
||||
area: 'Gestão municipal',
|
||||
label: 'Gestão municipal de saúde com ação na área',
|
||||
},
|
||||
{
|
||||
id: 'era_a_sas_ms',
|
||||
era: 'A',
|
||||
area: 'Ministério da Saúde',
|
||||
label: 'Secretaria de Atenção à Saúde do MS com interface na área',
|
||||
},
|
||||
{
|
||||
id: 'era_a_vinculo',
|
||||
era: 'A',
|
||||
area: 'Vínculo',
|
||||
label: 'Atuação documentada na área (pesquisa, conselho, docência)',
|
||||
},
|
||||
{
|
||||
id: 'era_b_sesab',
|
||||
era: 'B',
|
||||
area: 'Gestão estadual',
|
||||
label: 'Gestão da SESAB (2007–2014) com a área',
|
||||
},
|
||||
{
|
||||
id: 'era_b_politicas',
|
||||
era: 'B',
|
||||
area: 'Políticas',
|
||||
label: 'Políticas e programas estaduais de interesse da área',
|
||||
},
|
||||
{
|
||||
id: 'era_b_investimentos',
|
||||
era: 'B',
|
||||
area: 'Investimentos',
|
||||
label: 'Investimentos e equipamentos ligados à área',
|
||||
},
|
||||
{
|
||||
id: 'era_b_convenios',
|
||||
era: 'B',
|
||||
area: 'Convênios',
|
||||
label: 'Convênios, termos e parcerias formais na área',
|
||||
},
|
||||
{
|
||||
id: 'era_b_articulacao',
|
||||
era: 'B',
|
||||
area: 'Articulação',
|
||||
label: 'Articulação regional e setorial da área',
|
||||
},
|
||||
{
|
||||
id: 'era_c_discursos',
|
||||
era: 'C',
|
||||
area: 'Mandato',
|
||||
label: 'Pronunciamentos e falas sobre a área',
|
||||
},
|
||||
{
|
||||
id: 'era_c_proposicoes',
|
||||
era: 'C',
|
||||
area: 'Mandato',
|
||||
label: 'Proposições e relatorias de interesse da área',
|
||||
},
|
||||
{
|
||||
id: 'era_c_relatorias',
|
||||
era: 'C',
|
||||
area: 'Relatorias',
|
||||
label: 'Relatorias e pareceres ligados à área',
|
||||
},
|
||||
{
|
||||
id: 'era_c_emendas',
|
||||
era: 'C',
|
||||
area: 'Emendas',
|
||||
label: 'Emendas e recursos destinados à área',
|
||||
},
|
||||
{
|
||||
id: 'era_c_programas',
|
||||
era: 'C',
|
||||
area: 'Programas',
|
||||
label: 'Programas federais e ações na área',
|
||||
},
|
||||
{
|
||||
id: 'era_c_audiencias',
|
||||
era: 'C',
|
||||
area: 'Audiências',
|
||||
label: 'Audiências e articulação formal com a área',
|
||||
},
|
||||
{
|
||||
id: 'era_c_titulos',
|
||||
era: 'C',
|
||||
area: 'Reconhecimento',
|
||||
label: 'Títulos, honrarias e homenagens ligadas à área',
|
||||
},
|
||||
]
|
||||
|
||||
/** @type {Record<string, Array<{ id: string, era: string, area: string, label: string }>>} */
|
||||
const CHECKLIST_BY_UNIT = {
|
||||
municipality: MUNICIPALITY_RESEARCH_CHECKLIST,
|
||||
institution: INSTITUTION_RESEARCH_CHECKLIST,
|
||||
theme: THEME_RESEARCH_CHECKLIST,
|
||||
}
|
||||
|
||||
const checklistFor = (unit) => CHECKLIST_BY_UNIT[unit.id] ?? MUNICIPALITY_RESEARCH_CHECKLIST
|
||||
|
|
@ -491,7 +604,7 @@ export const mergeDossierResearch = (researches, { unit } = {}) => {
|
|||
const slug = list[0][slugField]
|
||||
for (const research of list) {
|
||||
if (research[slugField] !== slug) {
|
||||
const noun = resolvedUnit.id === 'institution' ? 'instituições' : 'municípios'
|
||||
const noun = resolvedUnit.nounPlural ?? 'municípios'
|
||||
throw new Error(
|
||||
`Pesquisas de ${noun} diferentes ("${slug}" e "${research[slugField]}") — pare e regenere.`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,16 +2,50 @@
|
|||
* Unit seam for the Solla dossiê pipeline (C187, owner: the C186 dossiê).
|
||||
*
|
||||
* The dossiê was born municipality-shaped (C186). C187 adds an INSTITUTION
|
||||
* recorte that must not fork the pipeline, so the shared mechanics
|
||||
* (research contract, print shell, PDF emit, bulletin ledger) take a `unit`
|
||||
* descriptor and default to `MUNICIPALITY_UNIT` — the C186 behaviour stays
|
||||
* byte-identical when no unit is passed.
|
||||
* recorte and C190 a THEME/AREA recorte; neither may fork the pipeline, so the
|
||||
* shared mechanics (research contract, print shell, PDF emit, bulletin ledger)
|
||||
* take a `unit` descriptor and default to `MUNICIPALITY_UNIT` — the C186
|
||||
* behaviour stays byte-identical when no unit is passed. Institution and theme
|
||||
* share the same `subject` shape (three scope lists, per-era flowing sheets,
|
||||
* honor handling, bulletin copy) and differ only in vocabulary/copy, which the
|
||||
* descriptor carries.
|
||||
*
|
||||
* Vocabulary note: a unit's `spheres` are the item ABRANGÊNCIA values
|
||||
* (`municipio|regiao|polo` × `instituicao|setor|rede`). The catalog `sphere`
|
||||
* (federal/estadual/…) is a different object and never enters an item row.
|
||||
* (`municipio|regiao|polo` × `instituicao|setor|rede` × `area|segmento|rede`).
|
||||
* The catalog `sphere` (federal/estadual/…) is a different object and never
|
||||
* enters an item row.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Theme/area copy uses the preposition the canonical label asks for
|
||||
* ("pela Educação", "pelo Esporte", "pelos Direitos Humanos…"), with an explicit
|
||||
* map for the 18 `SPEECH_TOPICS` labels and a neutral fallback for any label
|
||||
* outside the taxonomy — never a guessed gender.
|
||||
*/
|
||||
const THEME_SUBJECT_PHRASES = {
|
||||
Saúde: 'pela Saúde',
|
||||
Educação: 'pela Educação',
|
||||
Cultura: 'pela Cultura',
|
||||
Esporte: 'pelo Esporte',
|
||||
'Segurança Pública': 'pela Segurança Pública',
|
||||
'Meio Ambiente': 'pelo Meio Ambiente',
|
||||
'Economia e Trabalho': 'pela Economia e Trabalho',
|
||||
'Direitos Humanos e Assistência Social': 'pelos Direitos Humanos e Assistência Social',
|
||||
'Infraestrutura e Transporte': 'pela Infraestrutura e Transporte',
|
||||
'Ciência e Tecnologia': 'pela Ciência e Tecnologia',
|
||||
'Política e Instituições': 'pela Política e Instituições',
|
||||
'Agricultura e Agropecuária': 'pela Agricultura e Agropecuária',
|
||||
'Habitação e Cidades': 'pela Habitação e Cidades',
|
||||
'Comunicação e Mídia': 'pela Comunicação e Mídia',
|
||||
'Igualdade Racial': 'pela Igualdade Racial',
|
||||
'Mulheres e Gênero': 'pelas Mulheres e Gênero',
|
||||
Juventude: 'pela Juventude',
|
||||
'Pessoa com Deficiência': 'pela Pessoa com Deficiência',
|
||||
}
|
||||
|
||||
/** @param {string} label @returns {string} */
|
||||
export const themeSubjectPhrase = (label) => THEME_SUBJECT_PHRASES[label] ?? `pela área de ${label}`
|
||||
|
||||
export const MUNICIPALITY_UNIT = {
|
||||
id: 'municipality',
|
||||
/** Key that carries the resolved unit slug in research/snapshot files. */
|
||||
|
|
@ -36,11 +70,16 @@ export const MUNICIPALITY_UNIT = {
|
|||
eraNumbersTitle: 'Recursos e entregas quantificáveis',
|
||||
eraActionsTitle: 'O que fez — item, alcance e lastro',
|
||||
honorsTitle: 'Títulos, honrarias e vínculos locais',
|
||||
/** C186 literal kept verbatim (byte-parity); subject units override it. */
|
||||
synthesisSumGuard: 'Setor e rede não são somados à instituição.',
|
||||
}
|
||||
|
||||
export const INSTITUTION_UNIT = {
|
||||
id: 'institution',
|
||||
shape: 'subject',
|
||||
/** Key that holds the recorte identity in research/snapshot files. */
|
||||
slugField: 'institutionSlug',
|
||||
snapshotField: 'institution',
|
||||
spheres: ['instituicao', 'setor', 'rede'],
|
||||
defaultSphere: 'instituicao',
|
||||
sphereLabels: { instituicao: 'instituição', setor: 'setor', rede: 'rede' },
|
||||
|
|
@ -58,11 +97,336 @@ export const INSTITUTION_UNIT = {
|
|||
eraNumbersTitle: 'Objeto, valor, fase e alcance',
|
||||
eraActionsTitle: 'Papéis com evidência visível',
|
||||
honorsTitle: 'Títulos, honrarias e vínculos institucionais',
|
||||
synthesisSumGuard: 'Setor e rede não são somados à instituição.',
|
||||
aria: {
|
||||
synthesis: 'Síntese da atuação institucional',
|
||||
charts: 'Gráficos consolidados da atuação institucional',
|
||||
sources: 'Fontes e limites do dossiê institucional',
|
||||
scope: 'Painel de abrangência institucional',
|
||||
},
|
||||
/** Subject-shape fields (shared with THEME_UNIT). */
|
||||
nounPlural: 'instituições',
|
||||
subjectNoun: 'instituição',
|
||||
docLabel: 'Dossiê institucional',
|
||||
contributionLabel: 'instituição',
|
||||
scopeLabel: 'no recorte institucional',
|
||||
summaryLinkEyebrow: '01 · vínculo com a instituição',
|
||||
honorsSheet: true,
|
||||
notSummedTo: 'à instituição',
|
||||
scopeLists: [
|
||||
{ key: 'institution', sphere: 'instituicao' },
|
||||
{ key: 'sector', sphere: 'setor' },
|
||||
{ key: 'network', sphere: 'rede' },
|
||||
],
|
||||
scopeListWords: { institution: 'instituicao', sector: 'setor', network: 'rede' },
|
||||
scopeCardClass: { instituicao: '', setor: 'card-sector', rede: 'card-network' },
|
||||
eraMethod: {
|
||||
A: 'Registros anteriores a 2007 que citem nominalmente a instituição ou comprovem vínculo. Cargo geral não prova ação institucional.',
|
||||
B: 'A gestão estadual é lida por equipamento, programa, convênio e obra; cada linha informa abrangência e fonte. Setor e rede não somam à instituição.',
|
||||
C: 'Mandato, relatorias, parcerias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase.',
|
||||
},
|
||||
eraRecovery: {
|
||||
A: 'biografias oficiais · atos e diários · acervos institucionais · busca web datada',
|
||||
B: 'DOE-BA (DOOL) · notícias SESAB/instituição · Transparência Bahia · busca web datada',
|
||||
C: 'API Câmara · Portal da Transparência · acervo interno read-only · busca web datada',
|
||||
},
|
||||
identityBadges: (identity) =>
|
||||
[identity.kindLabel, identity.sphereLabel, identity.scope ? identity.scopeLabel : null].filter(
|
||||
Boolean,
|
||||
),
|
||||
identityRows: (identity) =>
|
||||
[
|
||||
['Nome', identity.name],
|
||||
['Tipo', identity.kindLabel],
|
||||
['Esfera', identity.sphereLabel],
|
||||
['Alcance', identity.scopeLabel],
|
||||
['Alias', (identity.aliases ?? []).join(' · ') || '—'],
|
||||
].filter(([, value]) => Boolean(value)),
|
||||
coverRows: (report) => [{ label: 'Identificação', badges: report.meta.identityBadges }],
|
||||
copy: {
|
||||
coverSubtitle: () => 'O que Jorge Solla fez por, na e com a instituição ao longo da carreira',
|
||||
coverHowToUse:
|
||||
'Localize o vínculo ou a entrega, confira fonte, data, fase e abrangência. Transforme toda lacuna em tarefa de apuração. Este arquivo não é fala pronta nem peça pública.',
|
||||
coverScope:
|
||||
'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara dos Deputados 2015–2027. Instituição, setor e rede separados; sem fonte, não entra como entrega.',
|
||||
coverScopeWords: 'instituição, setor e rede separados',
|
||||
coverAsset: ['selo da instituição', 'uso autorizado e origem'],
|
||||
openingTitle: (subjectName) => `O que Jorge Solla fez pela e na ${subjectName}`,
|
||||
reachRuleTitle: 'Setor/rede não é a instituição. Não some os recortes.',
|
||||
reachRuleBody:
|
||||
'Uma política para uma categoria ou uma articulação com entidades correlatas pode alcançar a instituição sem constituir entrega exclusiva para ela.',
|
||||
synthesisGuard: 'setor e rede nunca somados à instituição.',
|
||||
coverageNote: 'Instituição, setor e rede separados; sem fonte, não entra como entrega.',
|
||||
timelineEmpty: 'Nenhum vínculo datado com fonte — ver lacunas explícitas.',
|
||||
eraEmpty: {
|
||||
body: 'A pesquisa não encontrou documento que sustenta uma afirmação institucional. Isso não prova que não houve vínculo ou atuação; prova apenas que o dossiê ainda não pode afirmá-los.',
|
||||
work: [
|
||||
'Busca nominal por instituição e aliases.',
|
||||
'Consulta a fontes oficiais e acervos datados.',
|
||||
'Triagem de resultados por vínculo e abrangência.',
|
||||
'Descarte de menções sem lastro suficiente.',
|
||||
],
|
||||
todo: [
|
||||
'Solicitar consulta ao arquivo físico.',
|
||||
'Validar nome histórico e unidade da instituição.',
|
||||
'Recuperar ato, ata ou documento contemporâneo.',
|
||||
'Repetir busca com alias confirmado.',
|
||||
],
|
||||
rule: (eraLabel) =>
|
||||
`Não preencher a seção por memória, cargo provável ou texto de outra era. Registrar “${eraLabel}: evidência institucional não localizada nas fontes consultadas”.`,
|
||||
},
|
||||
limits: {
|
||||
coverage: [
|
||||
'O acervo interno de falas cobre 2011+; períodos anteriores dependem de fontes externas.',
|
||||
'Resultado de busca não prova ausência histórica.',
|
||||
'Emenda de bancada ou relator só recebe autoria quando a fonte a confirma.',
|
||||
'Menção a entidade correlata (setor/rede) não é atribuição à instituição.',
|
||||
],
|
||||
editorial: [
|
||||
'Sem fonte, não publica.',
|
||||
'URL e data acompanham cada afirmação não trivial.',
|
||||
'Instituição, setor e rede permanecem separados.',
|
||||
'Valor sempre informa a fase de execução.',
|
||||
],
|
||||
},
|
||||
mdAcervoNote: 'Recorte por tema, não nominal.',
|
||||
bulletin: {
|
||||
aria: 'Modelo de boletim informativo institucional de uma página',
|
||||
openingLabel: 'Uma trajetória de trabalho junto à instituição',
|
||||
openingTitle: (subjectName) => `O que Jorge Solla fez pela e na ${subjectName}`,
|
||||
lead: 'Ações, recursos e articulações explicados de forma direta — somente o que já foi documentado no dossiê institucional.',
|
||||
leadSparse:
|
||||
'Esta versão tem poucos registros confirmados. Em vez de preencher espaço, mostra só os fatos que o dossiê sustenta.',
|
||||
asset: 'Solla na instituição ou na ação citada',
|
||||
assetSparse: 'somente registro relacionado ao fato',
|
||||
assetSelo: ['marca autorizada da instituição', 'arquivo vetorial'],
|
||||
assetClipping: ['registro real da ação', 'origem e licença'],
|
||||
moreEmpty:
|
||||
'Conferir as lacunas explícitas no dossiê institucional antes de ampliar o boletim.',
|
||||
defeso:
|
||||
'fase acompanha cada valor · instituição, setor e rede não são somados · sem percentual estadual absoluto.',
|
||||
orig: 'Conteúdo selecionado do dossiê institucional.',
|
||||
sparseRule:
|
||||
'Não repetir item, não ampliar efeito e não preencher com ação de setor ou rede como se fosse entrega exclusiva da instituição.',
|
||||
title: 'Uma trajetória de compromisso com o serviço público',
|
||||
titleSparse: 'contexto de carreira · não substitui fato institucional',
|
||||
highlightsTitle: 'Resultados em leitura rápida',
|
||||
highlightsTitleSparse: 'Poucos fatos, sem enchimento',
|
||||
moreTitle: 'Outras ações confirmadas',
|
||||
moreTitleSparse: 'Outros registros confirmados',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const THEME_UNIT = {
|
||||
id: 'theme',
|
||||
shape: 'subject',
|
||||
slugField: 'themeSlug',
|
||||
snapshotField: 'theme',
|
||||
spheres: ['area', 'segmento', 'rede'],
|
||||
defaultSphere: 'area',
|
||||
sphereLabels: { area: 'área', segmento: 'segmento', rede: 'rede' },
|
||||
sphereBadgeClass: { area: '', segmento: 'scope-sector', rede: 'scope-network' },
|
||||
breadthRank: { area: 0, segmento: 1, rede: 2 },
|
||||
researchDir: 'data/dossie-solla-tema',
|
||||
series: 'Série temática · por área',
|
||||
title: 'Dossiê Solla por tema/área',
|
||||
bulletinTitle: 'Boletim informativo modelo',
|
||||
bulletinMoreLimit: 14,
|
||||
bulletinKicker: 'Boletim informativo · série temática',
|
||||
sphereColumnLabel: 'Abrangência',
|
||||
notSummedInline: 'não somar à área',
|
||||
sumGuardNote: 'segmento e rede não são somados à área.',
|
||||
acervoNote: 'recorte pela tag do acervo (Speech.topics); segmento e rede não entram nesta conta.',
|
||||
eraNumbersTitle: 'Objeto, valor, ano, fase e abrangência',
|
||||
eraActionsTitle: 'Papéis e iniciativas com evidência visível',
|
||||
honorsTitle: 'Títulos, honrarias e vínculos',
|
||||
synthesisSumGuard: 'Segmento e rede não são somados à área.',
|
||||
aria: {
|
||||
synthesis: 'Síntese da atuação por área',
|
||||
charts: 'Gráficos consolidados da atuação por área',
|
||||
sources: 'Fontes e limites do dossiê temático',
|
||||
scope: 'Painel de abrangência por área',
|
||||
},
|
||||
nounPlural: 'temas',
|
||||
subjectNoun: 'área',
|
||||
docLabel: 'Dossiê temático',
|
||||
contributionLabel: 'área',
|
||||
scopeLabel: 'no recorte da área',
|
||||
summaryLinkEyebrow: '01 · vínculo com a área',
|
||||
honorsSheet: false,
|
||||
notSummedTo: 'à área',
|
||||
scopeLists: [
|
||||
{ key: 'area', sphere: 'area' },
|
||||
{ key: 'segment', sphere: 'segmento' },
|
||||
{ key: 'network', sphere: 'rede' },
|
||||
],
|
||||
scopeListWords: { area: 'area', segment: 'segmento', network: 'rede' },
|
||||
scopeCardClass: { area: '', segmento: 'card-sector', rede: 'card-network' },
|
||||
/** Theme-only scene overrides (the institution keeps the shared defaults). */
|
||||
acervoScene: {
|
||||
stats: [
|
||||
{ key: 'universe', label: 'Universo recuperado' },
|
||||
{ key: 'sample', label: 'Amostra exibida' },
|
||||
],
|
||||
columns: ['Data', 'Tema canônico', 'Trecho/contexto', 'Abrangência', 'Link'],
|
||||
alert:
|
||||
'O tema prova pertinência ao recorte do acervo; não prova por si só entrega, autoria material ou execução financeira.',
|
||||
},
|
||||
reachScene: {
|
||||
columns: ['Item', 'Era / ano', 'Valor / fase', 'Evidência temática', 'Fonte'],
|
||||
tableClass: 'document-table--reach',
|
||||
},
|
||||
gapsScene: {
|
||||
columns: ['Era', 'Lacuna', 'Já consultado', 'Próxima busca', 'Status'],
|
||||
alert:
|
||||
'Portais que não filtram por área não autorizam atribuição. Sem fonte temática, a linha permanece lacuna — nunca zero.',
|
||||
},
|
||||
sourcesHierarchy: [
|
||||
{
|
||||
layer: 'Fontes primárias',
|
||||
use: 'Atos, diários oficiais e documentos contemporâneos',
|
||||
limit: 'Sem documento, não entra',
|
||||
},
|
||||
{
|
||||
layer: 'Fontes institucionais',
|
||||
use: 'Portais oficiais e de transparência',
|
||||
limit: 'Não filtram por área',
|
||||
},
|
||||
{
|
||||
layer: 'Acervo interno',
|
||||
use: 'Falas classificadas por tema (2011+)',
|
||||
limit: 'Prova pertinência, não entrega',
|
||||
},
|
||||
{
|
||||
layer: 'Busca web datada',
|
||||
use: 'Notícias e publicações com data',
|
||||
limit: 'Resultado não prova ausência',
|
||||
},
|
||||
],
|
||||
/** Síntese/rótulos temáticos (institution/município keep the shared literals). */
|
||||
byAreaTitle: 'Tipos de atuação com mais registros',
|
||||
byYearTitle: 'Pontos com fonte por ano',
|
||||
fullYearAxis: true,
|
||||
eraCoverageFromLedger: true,
|
||||
moneyPhaseStyle: 'separate',
|
||||
/**
|
||||
* Theme bulletins read the year when a fact has no money value, and mark the
|
||||
* card textual when neither exists (never a fake zero).
|
||||
*/
|
||||
bulletinNumberFallback: true,
|
||||
eraMethod: {
|
||||
A: 'Registros anteriores a 2007 que tratem diretamente da área ou comprovem atuação relacionada. Cargo geral não prova ação na política.',
|
||||
B: 'A gestão estadual é lida por programa, projeto, convênio e obra ligados à área; cada linha informa abrangência e fonte. Segmento e rede não somam à área.',
|
||||
C: 'Mandato, relatorias e execução financeira têm datas e estágios distintos. Empenho não é pagamento; o valor sempre acompanha a fase.',
|
||||
},
|
||||
eraRecovery: {
|
||||
A: 'biografias oficiais · atos e diários · acervos de políticas públicas · busca web datada',
|
||||
B: 'DOE-BA (DOOL) · notícias SESAB · Transparência Bahia · SIOPS/DATASUS · busca web datada',
|
||||
C: 'API Câmara · Portal da Transparência · acervo interno read-only · busca web datada',
|
||||
},
|
||||
identityBadges: (identity) => [identity.label].filter(Boolean),
|
||||
/** Theme bulletin pills: recorte label, canonical token (mono) and taxonomy origin. */
|
||||
bulletinPills: (identity) => [
|
||||
{ label: 'Área' },
|
||||
{ label: identity.value, code: true },
|
||||
{ label: identity.taxonomyNote ?? 'Taxonomia do acervo' },
|
||||
],
|
||||
identityRows: (identity) =>
|
||||
[
|
||||
['Área', identity.label],
|
||||
['Token', identity.value],
|
||||
['Origem', identity.taxonomyNote ?? 'Taxonomia do acervo'],
|
||||
['Filtro', 'Speech.topics'],
|
||||
].filter(([, value]) => Boolean(value)),
|
||||
coverRows: (report) => [
|
||||
{ label: 'Área', badges: report.meta.identityBadges },
|
||||
{
|
||||
label: 'Valor canônico',
|
||||
code: report.meta.identity?.value,
|
||||
note: 'taxonomia do acervo / Speech.topics',
|
||||
},
|
||||
],
|
||||
copy: {
|
||||
coverSubtitle: (subjectName) =>
|
||||
`O que Jorge Solla fez ${themeSubjectPhrase(subjectName)} ao longo da carreira`,
|
||||
coverHowToUse:
|
||||
'Localize a entrega, confira fonte, data, fase e abrangência. A área vem da taxonomia canônica; segmento e rede não são somados a ela.',
|
||||
coverScope:
|
||||
'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara 2015–2027. Área, segmento e rede separados; sem fonte, não entra como entrega.',
|
||||
coverScopeWords: 'área, segmento e rede separados',
|
||||
coverAsset: ['selo autorizado', 'e origem'],
|
||||
openingTitle: (subjectName) => `O que Jorge Solla fez ${themeSubjectPhrase(subjectName)}`,
|
||||
reachRuleTitle: 'Segmento/rede não é a área. Não some os recortes.',
|
||||
reachRuleBody:
|
||||
'Uma ação dirigida a um segmento ou a uma rede pode alcançar a política de área sem constituir entrega exclusiva dela.',
|
||||
synthesisGuard: 'segmento e rede nunca somados à área.',
|
||||
coverageNote: 'Área, segmento e rede separados; sem fonte, não entra como entrega.',
|
||||
timelineEmpty: 'Nenhum vínculo datado com fonte — ver lacunas explícitas.',
|
||||
eraEmpty: {
|
||||
body: 'A pesquisa não encontrou documento que sustente uma afirmação sobre a área. Isso não prova ausência de atuação; prova apenas que o dossiê ainda não pode afirmá-la.',
|
||||
work: [
|
||||
'Busca pelo tema canônico e termos correlatos.',
|
||||
'Consulta a fontes oficiais e acervos datados.',
|
||||
'Triagem de resultados por atribuição à área.',
|
||||
'Descarte de menções sem lastro suficiente.',
|
||||
],
|
||||
todo: [
|
||||
'Solicitar consulta a acervo especializado.',
|
||||
'Validar a taxonomia e o recorte do tema.',
|
||||
'Recuperar ato, publicação ou documento contemporâneo.',
|
||||
'Repetir busca com termos correlatos.',
|
||||
],
|
||||
rule: (eraLabel) =>
|
||||
`Não preencher a seção por memória, cargo provável ou tema vizinho. Registrar “${eraLabel}: evidência da área não localizada nas fontes consultadas”.`,
|
||||
},
|
||||
limits: {
|
||||
coverage: [
|
||||
'O acervo interno de falas cobre 2011+; períodos anteriores dependem de fontes externas.',
|
||||
'Resultado de busca não prova ausência histórica.',
|
||||
'Emenda de bancada ou relator só recebe autoria quando a fonte a confirma.',
|
||||
'Menção a segmento ou rede não é atribuição exclusiva da área.',
|
||||
],
|
||||
editorial: [
|
||||
'Sem fonte, não publica.',
|
||||
'URL e data acompanham cada afirmação não trivial.',
|
||||
'Área, segmento e rede permanecem separados.',
|
||||
'Valor sempre informa a fase de execução.',
|
||||
'Atribuição temática a emenda ou proposição só entra com fonte; sem fonte, vira lacuna.',
|
||||
],
|
||||
},
|
||||
mdAcervoNote: 'Recorte pela tag do acervo, não nominal.',
|
||||
bulletin: {
|
||||
aria: 'Modelo de boletim informativo temático de uma página',
|
||||
openingLabel: 'Uma trajetória de trabalho pela área',
|
||||
openingTitle: (subjectName) => `O que Jorge Solla fez ${themeSubjectPhrase(subjectName)}`,
|
||||
lead: 'Ações, recursos e articulações explicados de forma direta — somente o que já foi documentado no dossiê da área.',
|
||||
leadSparse:
|
||||
'Esta versão tem poucos registros confirmados. Em vez de preencher espaço, mostra só os fatos que o dossiê sustenta.',
|
||||
asset: 'Solla na ação citada',
|
||||
assetSparse: 'somente registro relacionado ao fato',
|
||||
assetSelo: ['selo autorizado', 'arquivo vetorial'],
|
||||
assetClipping: ['registro real da ação', 'origem e licença'],
|
||||
moreEmpty: 'Conferir as lacunas explícitas no dossiê da área antes de ampliar o boletim.',
|
||||
defeso:
|
||||
'fase acompanha cada valor · área, segmento e rede não são somados · sem percentual estadual absoluto.',
|
||||
orig: 'Conteúdo selecionado do dossiê da área.',
|
||||
sparseRule:
|
||||
'Não repetir item, não ampliar efeito e não preencher com ação de segmento ou rede como se fosse entrega exclusiva da área.',
|
||||
title: 'Uma trajetória de compromisso com o serviço público',
|
||||
titleSparse: 'contexto de carreira · não substitui fato da área',
|
||||
highlightsTitle: 'Resultados em leitura rápida',
|
||||
highlightsTitleSparse: 'Poucos fatos, sem enchimento',
|
||||
moreTitle: 'Outras ações confirmadas',
|
||||
moreTitleSparse: 'Outros registros confirmados',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const DOSSIER_UNITS = {
|
||||
municipality: MUNICIPALITY_UNIT,
|
||||
institution: INSTITUTION_UNIT,
|
||||
theme: THEME_UNIT,
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -81,5 +445,5 @@ export const resolveDossierUnit = (unit) => {
|
|||
return unit
|
||||
}
|
||||
|
||||
/** Central predicate for the dispatch points (dossiê, boletim, blocks). */
|
||||
export const isInstitutionUnit = (unit) => resolveDossierUnit(unit).id === 'institution'
|
||||
/** Institution and theme share the `subject` shape (one builder/renderer). */
|
||||
export const isSubjectUnit = (unit) => resolveDossierUnit(unit).shape === 'subject'
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const SCRIPTS_SPEC_PINNED = [
|
|||
'scripts/check-test-locations.mjs',
|
||||
'scripts/cityReportSnapshot.mjs',
|
||||
'scripts/institutionSnapshot.mjs',
|
||||
'scripts/themeSnapshot.mjs',
|
||||
'scripts/lib/agent-forgejo.mjs',
|
||||
'scripts/lib/agent-plan-lifecycle.mjs',
|
||||
'scripts/lib/agent-pool-cursor.mjs',
|
||||
|
|
|
|||
106
scripts/themeSnapshot.mjs
Normal file
106
scripts/themeSnapshot.mjs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Snapshot composer for the theme/area dossiê (C190).
|
||||
*
|
||||
* Runs on the homeserver against production, read-only: the recorte IS the
|
||||
* acervo tag (`Speech.topics`), so there is no institution-style bridge and no
|
||||
* nominal match — the theme itself is the filter. Reuses the C163 speech
|
||||
* loaders (no parallel source of truth), projects out PII and returns plain
|
||||
* JSON. The render step never touches the database again.
|
||||
*
|
||||
* Integration layer, not a pure lib: it imports Payload-coupled utilities and
|
||||
* is exercised by `tests/int/themeSnapshot.int.spec.ts`.
|
||||
*/
|
||||
|
||||
import { excerptOffsetSeconds } from '../src/lib/speechVod.ts'
|
||||
import { buildSpeechListWhere } from '../src/utilities/speech/speechListFilters.ts'
|
||||
|
||||
const SPEECH_LIMIT = 15
|
||||
|
||||
const SPEECH_SELECT = {
|
||||
speechAt: true,
|
||||
type: true,
|
||||
phase: true,
|
||||
summary: true,
|
||||
officialTextUrl: true,
|
||||
youtubeUrl: true,
|
||||
vodPlaybackUrl: true,
|
||||
excerptTMs: true,
|
||||
eventStartAt: true,
|
||||
topics: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects one speech: the official summary says what it is. There is no
|
||||
* `mentionExcerpt` here — the acervo tag is the recorte, not a literal mention.
|
||||
*/
|
||||
const projectSpeechRow = (doc) => ({
|
||||
id: doc.id,
|
||||
speechAt: doc.speechAt ?? null,
|
||||
type: doc.type ?? null,
|
||||
phase: doc.phase ?? null,
|
||||
summary: doc.summary ?? null,
|
||||
officialTextUrl: doc.officialTextUrl ?? null,
|
||||
youtubeUrl: doc.youtubeUrl ?? null,
|
||||
vodPlaybackUrl: doc.vodPlaybackUrl ?? null,
|
||||
youtubeExcerptStartSeconds: excerptOffsetSeconds(doc.excerptTMs, doc.eventStartAt),
|
||||
topics: Array.isArray(doc.topics) ? doc.topics : [],
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* payload: any,
|
||||
* actor: any,
|
||||
* identity: { slug: string, value: string, label: string, taxonomyNote?: string | null },
|
||||
* readAt: string,
|
||||
* codeSha?: string | null,
|
||||
* database?: string | null,
|
||||
* }} params
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export const composeThemeSnapshot = async ({
|
||||
payload,
|
||||
actor,
|
||||
identity,
|
||||
readAt,
|
||||
codeSha = null,
|
||||
database = null,
|
||||
}) => {
|
||||
const topics = [identity.value]
|
||||
const gaps = []
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'speech',
|
||||
where: buildSpeechListWhere({ page: 1, topics }),
|
||||
depth: 0,
|
||||
limit: SPEECH_LIMIT,
|
||||
sort: '-speechAt',
|
||||
select: SPEECH_SELECT,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const docs = result.docs
|
||||
const totalCount = result.totalDocs
|
||||
if (docs.length === 0) {
|
||||
gaps.push({
|
||||
id: 'acervo_sem_falas',
|
||||
reason: `Nenhuma fala do acervo no tema ${identity.value} — o acervo cobre 2011+.`,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
meta: { readAt, codeSha, database, kind: 'theme' },
|
||||
theme: {
|
||||
slug: identity.slug,
|
||||
value: identity.value,
|
||||
label: identity.label,
|
||||
taxonomyNote: identity.taxonomyNote ?? 'Taxonomia do acervo',
|
||||
topics,
|
||||
},
|
||||
speeches: {
|
||||
topics,
|
||||
totalCount,
|
||||
rows: docs.map((doc) => projectSpeechRow(doc)),
|
||||
},
|
||||
gaps,
|
||||
}
|
||||
}
|
||||
|
|
@ -75,6 +75,18 @@ const scopeByKey = new Map(
|
|||
]),
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolves a canonical topic token (value or pt-BR label, accent/case
|
||||
* insensitive) to its taxonomy entry. Fail-closed: unknown tokens return null —
|
||||
* callers never invent a slug outside `SPEECH_TOPICS` (C190).
|
||||
*/
|
||||
export const resolveSpeechTopic = (token: string): (typeof SPEECH_TOPICS)[number] | null => {
|
||||
const key = facetKey(token ?? '')
|
||||
if (!key) return null
|
||||
const value = topicByKey.get(key)
|
||||
return SPEECH_TOPICS.find((topic) => topic.value === value) ?? null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM response parsing and merge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
124
tests/int/themeSnapshot.int.spec.ts
Normal file
124
tests/int/themeSnapshot.int.spec.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { composeThemeSnapshot } from '../../scripts/themeSnapshot.mjs'
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
// C190: the theme/area snapshot recorte IS the acervo tag (`Speech.topics`), so
|
||||
// there is no nominal bridge. Pins the read projection (no PII), the topic
|
||||
// filter and the explicit gap when the recorte is empty.
|
||||
|
||||
let payload: Payload
|
||||
const createdSpeechIds: number[] = []
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const identity = {
|
||||
slug: 'educacao',
|
||||
value: 'educacao',
|
||||
label: 'Educação',
|
||||
taxonomyNote: 'Taxonomia do acervo',
|
||||
}
|
||||
|
||||
describe('theme snapshot (C190)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdSpeechIds) {
|
||||
try {
|
||||
await payload.delete({ collection: 'speech', id, depth: 0, overrideAccess: true })
|
||||
} catch {
|
||||
// already deleted
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it(
|
||||
'projects the topic-matched speeches without PII and records the recorte',
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const candidate = await fixtures.createCampaignUser('candidate')
|
||||
|
||||
const speech = await payload.create({
|
||||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: `test-theme-${randomUUID()}`,
|
||||
speechAt: '2024-05-10T17:28',
|
||||
type: 'DISCURSO',
|
||||
phase: 'Discurso',
|
||||
summary: 'Defesa do financiamento da educação básica.',
|
||||
officialTranscript: 'O SR. JORGE SOLLA (Bloco/PT - BA) - Sr. Presidente...',
|
||||
keywords: ['escola', 'educação'],
|
||||
topics: ['educacao'],
|
||||
classifiedBy: 'gazetteer',
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
createdSpeechIds.push(speech.id)
|
||||
|
||||
const actor = { ...candidate, collection: 'campaignUser' as const }
|
||||
const snapshot = await composeThemeSnapshot({
|
||||
payload,
|
||||
actor,
|
||||
identity,
|
||||
readAt: '2026-09-18T11:00:00.000Z',
|
||||
codeSha: 'test-sha',
|
||||
database: 'localhost:5432/teqo_test',
|
||||
})
|
||||
|
||||
expect(snapshot.meta.kind).toBe('theme')
|
||||
expect(snapshot.theme).toMatchObject({
|
||||
slug: 'educacao',
|
||||
value: 'educacao',
|
||||
label: 'Educação',
|
||||
})
|
||||
expect(snapshot.theme.topics).toEqual(['educacao'])
|
||||
expect(snapshot.speeches.rows.some((row: { id: number }) => row.id === speech.id)).toBe(true)
|
||||
const row = snapshot.speeches.rows.find(
|
||||
(candidateRow: { id: number }) => candidateRow.id === speech.id,
|
||||
)!
|
||||
expect('officialTranscript' in row).toBe(false)
|
||||
expect('mentionExcerpt' in row).toBe(false)
|
||||
expect(JSON.stringify(snapshot)).not.toContain('@')
|
||||
},
|
||||
)
|
||||
|
||||
it('declares an explicit gap when the theme has no speech', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const candidate = await fixtures.createCampaignUser('candidate')
|
||||
const actor = { ...candidate, collection: 'campaignUser' as const }
|
||||
|
||||
const snapshot = await composeThemeSnapshot({
|
||||
payload,
|
||||
actor,
|
||||
identity: {
|
||||
slug: 'pessoa-deficiencia',
|
||||
value: 'pessoa-deficiencia',
|
||||
label: 'Pessoa com Deficiência',
|
||||
taxonomyNote: 'Taxonomia do acervo',
|
||||
},
|
||||
readAt: '2026-09-18T11:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(snapshot.speeches.rows).toEqual([])
|
||||
expect(snapshot.speeches.totalCount).toBe(0)
|
||||
expect(snapshot.gaps).toContainEqual(expect.objectContaining({ id: 'acervo_sem_falas' }))
|
||||
})
|
||||
})
|
||||
518
tests/unit/dossieTheme.unit.spec.ts
Normal file
518
tests/unit/dossieTheme.unit.spec.ts
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { SPEECH_TOPICS } from '@/lib/speechFacets'
|
||||
|
||||
import { buildDossierReport } from '../../scripts/lib/dossieBlocks.mjs'
|
||||
import { buildBulletin } from '../../scripts/lib/dossieBulletin.mjs'
|
||||
import { renderBulletinHtml } from '../../scripts/lib/dossieBulletinRender.mjs'
|
||||
import { renderDossierHtml, renderDossierMd } from '../../scripts/lib/dossieRender.mjs'
|
||||
import {
|
||||
mergeDossierResearch,
|
||||
normalizeDossierResearchInput,
|
||||
} from '../../scripts/lib/dossieResearch.mjs'
|
||||
import { THEME_UNIT, themeSubjectPhrase } from '../../scripts/lib/dossieUnit.mjs'
|
||||
|
||||
// C190: theme/area content model + render. Grounds the third recorte: area
|
||||
// identity (label + canonical value), `area|segmento|rede` never summed, per-row
|
||||
// phase/source, empty-era state instead of silent omission, and a one-page
|
||||
// boletim that inherits only sourced facts.
|
||||
|
||||
const generatedAt = new Date('2026-09-18T12:00:00.000Z')
|
||||
|
||||
const snapshot = {
|
||||
meta: { readAt: '2026-09-18T11:00:00.000Z' },
|
||||
theme: {
|
||||
slug: 'educacao',
|
||||
value: 'educacao',
|
||||
label: 'Educação',
|
||||
taxonomyNote: 'Taxonomia do acervo',
|
||||
topics: ['educacao'],
|
||||
},
|
||||
speeches: {
|
||||
topics: ['educacao'],
|
||||
totalCount: 1,
|
||||
rows: [
|
||||
{
|
||||
id: 21,
|
||||
speechAt: '2024-05-10T00:00:00.000Z',
|
||||
summary: 'Defesa do financiamento da educação básica.',
|
||||
officialTextUrl: 'https://camara.test/discurso-educacao',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const item = (id: string, era: 'A' | 'B' | 'C', overrides: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
answer: `Ação ${id} documentada`,
|
||||
sphere: 'area',
|
||||
sourceUrl: `https://educacao.test/${id}`,
|
||||
sourceDate: '2024-06-01',
|
||||
era,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const eraFile = (era: 'A' | 'B' | 'C', items: unknown[]) => ({
|
||||
themeSlug: 'educacao',
|
||||
era,
|
||||
researchedAt: '2026-09-18T10:00:00.000Z',
|
||||
items,
|
||||
news: [
|
||||
{
|
||||
title: 'Notícia sobre educação',
|
||||
outlet: 'Educação Hoje',
|
||||
publishedAt: '2024-05-01',
|
||||
url: 'https://educacao.test/noticia',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const research = mergeDossierResearch(
|
||||
[
|
||||
normalizeDossierResearchInput(
|
||||
eraFile('A', [
|
||||
item('era_a_formacao', 'A'),
|
||||
item('era_a_vinculo', 'A', { sphere: 'segmento' }),
|
||||
item('era_a_sesab', 'A', { sphere: 'rede' }),
|
||||
]),
|
||||
{ unit: THEME_UNIT },
|
||||
),
|
||||
normalizeDossierResearchInput(
|
||||
eraFile('B', [
|
||||
item('era_b_sesab', 'B', {
|
||||
numbers: [{ label: 'Investimento', value: 'R$ 2,0 mi', year: '2012', phase: 'pago' }],
|
||||
}),
|
||||
]),
|
||||
{ unit: THEME_UNIT },
|
||||
),
|
||||
normalizeDossierResearchInput(
|
||||
eraFile('C', [
|
||||
item('era_c_emendas', 'C', {
|
||||
numbers: [{ label: 'Emenda', value: 'R$ 5,0 mi', year: '2024', phase: 'empenhado' }],
|
||||
}),
|
||||
item('era_c_titulos', 'C', { answer: 'Homenagem da área' }),
|
||||
]),
|
||||
{ unit: THEME_UNIT },
|
||||
),
|
||||
],
|
||||
{ unit: THEME_UNIT },
|
||||
)
|
||||
|
||||
const report = buildDossierReport({
|
||||
snapshot,
|
||||
research,
|
||||
generatedAt,
|
||||
unit: THEME_UNIT,
|
||||
})
|
||||
|
||||
describe('theme era without evidence', () => {
|
||||
const emptyReport = buildDossierReport({
|
||||
snapshot,
|
||||
research: mergeDossierResearch(
|
||||
(['A', 'B', 'C'] as const).map((era) =>
|
||||
normalizeDossierResearchInput(eraFile(era, []), { unit: THEME_UNIT }),
|
||||
),
|
||||
{ unit: THEME_UNIT },
|
||||
),
|
||||
generatedAt,
|
||||
unit: THEME_UNIT,
|
||||
})
|
||||
|
||||
it('marks an evidence-less era as an explicit gap, never a silent blank', () => {
|
||||
expect(emptyReport.eras.every((era) => era.empty)).toBe(true)
|
||||
const html = renderDossierHtml(emptyReport)
|
||||
expect(html).toContain('Lacuna explícita · não é “zero”')
|
||||
expect(html).toContain('Nenhuma fonte suficiente foi localizada para esta era.')
|
||||
expect(html).toContain('evidência da área não localizada')
|
||||
expect(html).toContain('empty-panel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDossierReport (theme unit)', () => {
|
||||
it('maps the canonical area labels to the right subject preposition', () => {
|
||||
expect(themeSubjectPhrase('Saúde')).toBe('pela Saúde')
|
||||
expect(themeSubjectPhrase('Esporte')).toBe('pelo Esporte')
|
||||
expect(themeSubjectPhrase('Direitos Humanos e Assistência Social')).toBe(
|
||||
'pelos Direitos Humanos e Assistência Social',
|
||||
)
|
||||
expect(themeSubjectPhrase('Mulheres e Gênero')).toBe('pelas Mulheres e Gênero')
|
||||
expect(themeSubjectPhrase('Fora da taxonomia')).toBe('pela área de Fora da taxonomia')
|
||||
})
|
||||
|
||||
it('has a mapped phrase for every canonical topic label (no silent fallback)', () => {
|
||||
for (const { label } of SPEECH_TOPICS) {
|
||||
expect(themeSubjectPhrase(label), label).not.toBe(`pela área de ${label}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries the area identity and canonical value', () => {
|
||||
expect(report.unit.id).toBe('theme')
|
||||
expect(report.meta.subjectName).toBe('Educação')
|
||||
expect(report.meta.identity?.value).toBe('educacao')
|
||||
expect(report.meta.identityBadges).toEqual(['Educação'])
|
||||
})
|
||||
|
||||
it('keeps all three eras, marking evidence-less ones as explicit gaps', () => {
|
||||
expect(report.eras.map((era) => era.id)).toEqual(['A', 'B', 'C'])
|
||||
expect(report.eras.every((era) => typeof era.empty === 'boolean')).toBe(true)
|
||||
})
|
||||
|
||||
it('never sums segmento/rede into the area list', () => {
|
||||
const listFor = (key: string) => report.reach.lists.find((list) => list.key === key)
|
||||
expect(listFor('area')?.items.every((row) => row.sphere === 'area')).toBe(true)
|
||||
expect(listFor('area')?.items.some((row) => row.id === 'era_a_vinculo')).toBe(false)
|
||||
expect(listFor('segment')?.total).toBe(1)
|
||||
expect(listFor('network')?.total).toBe(1)
|
||||
})
|
||||
|
||||
it('declares overflow instead of truncating silently (C188 rule)', () => {
|
||||
for (const list of report.reach.lists) {
|
||||
expect(list).toMatchObject({ total: expect.any(Number), omitted: expect.any(Number) })
|
||||
}
|
||||
expect(report.page1.deliveries.omitted).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('shows every sourced item — theme lists are never capped', () => {
|
||||
const manyItems = [
|
||||
item('era_c_emendas', 'C', { sphere: 'segmento' }),
|
||||
item('era_c_programas', 'C', { sphere: 'segmento' }),
|
||||
item('era_c_relatorias', 'C', { sphere: 'segmento' }),
|
||||
]
|
||||
const fullReport = buildDossierReport({
|
||||
snapshot,
|
||||
research: mergeDossierResearch(
|
||||
[normalizeDossierResearchInput(eraFile('C', manyItems), { unit: THEME_UNIT })],
|
||||
{ unit: THEME_UNIT },
|
||||
),
|
||||
generatedAt,
|
||||
unit: THEME_UNIT,
|
||||
})
|
||||
const list = fullReport.reach.lists.find((row) => row.key === 'segment')
|
||||
expect(list).toMatchObject({ total: 3, omitted: 0 })
|
||||
expect(list?.items).toHaveLength(3)
|
||||
const fullHtml = renderDossierHtml(fullReport)
|
||||
expect(fullHtml).not.toContain('e mais')
|
||||
for (const row of manyItems) expect(fullHtml).toContain(row.answer)
|
||||
})
|
||||
|
||||
it('consolidates money by phase and never counts non-money numbers as money', () => {
|
||||
const synthesis = report.synthesis
|
||||
expect(synthesis).toBeTruthy()
|
||||
if (!synthesis) return
|
||||
const moneyItem = item('era_b_investimentos', 'B', {
|
||||
numbers: [{ label: 'Convênio', value: 'R$ 1,2 mi', year: '2012', phase: 'pago' }],
|
||||
})
|
||||
const countItem = item('era_b_politicas', 'B', {
|
||||
numbers: [{ label: 'Escolas', value: '175', year: '2010', phase: 'nao_informado' }],
|
||||
})
|
||||
const moneyReport = buildDossierReport({
|
||||
snapshot,
|
||||
research: mergeDossierResearch(
|
||||
[normalizeDossierResearchInput(eraFile('B', [moneyItem, countItem]), { unit: THEME_UNIT })],
|
||||
{ unit: THEME_UNIT },
|
||||
),
|
||||
generatedAt,
|
||||
unit: THEME_UNIT,
|
||||
})
|
||||
const moneySynthesis = moneyReport.synthesis
|
||||
expect(moneySynthesis).toBeTruthy()
|
||||
if (!moneySynthesis) return
|
||||
expect(moneySynthesis.totals.money).toBe(1)
|
||||
expect(moneySynthesis.moneyTotals.executed).toBe(1_200_000)
|
||||
const paid = moneySynthesis.moneyByPhase.find((row: { key: string }) => row.key === 'pago')
|
||||
expect(paid).toMatchObject({ count: 1, amount: 1_200_000 })
|
||||
expect(moneySynthesis.lines.join(' ')).toContain('R$ 1,2 mi pago')
|
||||
const html = renderDossierHtml(moneyReport)
|
||||
expect(html).toContain('R$ 1,2 mi')
|
||||
expect(html).toContain('Recursos com execução por ano')
|
||||
})
|
||||
|
||||
it('derives bulletinFacts only from sourced items', () => {
|
||||
expect(report.bulletinFacts.length).toBeGreaterThan(0)
|
||||
expect(report.bulletinFacts.every((fact) => Boolean(fact.sourceUrl))).toBe(true)
|
||||
})
|
||||
|
||||
it('consumes the read-only acervo rows as sourced evidence (never silent)', () => {
|
||||
expect(report.acervo.items.length).toBe(1)
|
||||
expect(report.acervo.items[0]).toMatchObject({ period: '2024', sourceUrl: expect.any(String) })
|
||||
expect(report.bulletinFacts.some((fact) => fact.id === 'fala-21')).toBe(true)
|
||||
expect(renderDossierMd(report)).toContain('## Acervo interno (read-only)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderDossierHtml/Md (theme unit)', () => {
|
||||
const html = renderDossierHtml(report)
|
||||
const md = renderDossierMd(report)
|
||||
|
||||
it('anchors every theme page', () => {
|
||||
for (const anchor of [
|
||||
'capa',
|
||||
'resumo',
|
||||
'sintese',
|
||||
'graficos',
|
||||
'era-a',
|
||||
'era-b',
|
||||
'era-c',
|
||||
'abrangencia',
|
||||
'abrangencia-area',
|
||||
'abrangencia-segmento',
|
||||
'abrangencia-rede',
|
||||
'lacunas',
|
||||
'fontes',
|
||||
]) {
|
||||
expect(html).toContain(`data-page="${anchor}"`)
|
||||
}
|
||||
// Theme has no separate honors sheet: títulos render inline in the era.
|
||||
expect(html).not.toContain('data-page="titulos"')
|
||||
expect(html).toContain('Homenagem da área')
|
||||
})
|
||||
|
||||
it('prints the theme acervo scene with stats, canonical columns and the scope alert', () => {
|
||||
expect(html).toContain('acervo-stat-grid')
|
||||
expect(html).toContain('Universo recuperado')
|
||||
expect(html).toContain('Amostra exibida')
|
||||
expect(html).toContain('Tema canônico')
|
||||
expect(html).toContain('Trecho/contexto')
|
||||
expect(html).toContain('<code>educacao</code>')
|
||||
expect(html).toContain(
|
||||
'O tema prova pertinência ao recorte do acervo; não prova por si só entrega',
|
||||
)
|
||||
})
|
||||
|
||||
it('prints the theme reach scene with the five columns and the ledger year', () => {
|
||||
expect(html).toContain('document-table--reach')
|
||||
expect(html).toContain('Era / ano')
|
||||
expect(html).toContain('Valor / fase')
|
||||
expect(html).toContain('Evidência temática')
|
||||
expect(html).toContain('Era A')
|
||||
})
|
||||
|
||||
it('prints the theme gap scene with era, status and the attribution alert', () => {
|
||||
expect(html).toContain('Já consultado')
|
||||
expect(html).toContain('Próxima busca')
|
||||
expect(html).toContain('prioridade')
|
||||
expect(html).toContain('<span class="phase">aberta</span>')
|
||||
expect(html).toContain(
|
||||
'Portais que não filtram por área não autorizam atribuição. Sem fonte temática, a linha permanece lacuna — nunca zero.',
|
||||
)
|
||||
})
|
||||
|
||||
it('prints the theme source hierarchy above the limits', () => {
|
||||
expect(html).toContain('Hierarquia de fontes')
|
||||
expect(html).toContain('Fontes primárias')
|
||||
expect(html).toContain('Atos, diários oficiais e documentos contemporâneos')
|
||||
expect(html).toContain('Sem documento, não entra')
|
||||
expect(html).toContain('Não filtram por área')
|
||||
})
|
||||
|
||||
it('uses the theme synthesis labels and the pela/pelo subject phrase', () => {
|
||||
expect(html).toContain('Tipos de atuação com mais registros')
|
||||
expect(html).toContain('Pontos com fonte por ano')
|
||||
expect(html).toContain('O que Jorge Solla fez pela Educação')
|
||||
expect(html).toContain('pela Educação ao longo da carreira')
|
||||
})
|
||||
|
||||
it('prints the synthesis lines and the consolidated charts', () => {
|
||||
const synthesis = report.synthesis
|
||||
expect(synthesis).toBeTruthy()
|
||||
if (!synthesis) return
|
||||
expect(synthesis.totals.items).toBeGreaterThan(0)
|
||||
const eraTotal = synthesis.byEra.reduce(
|
||||
(sum: number, row: { count: number }) => sum + row.count,
|
||||
0,
|
||||
)
|
||||
expect(eraTotal).toBe(synthesis.totals.items)
|
||||
const sphereTotal = synthesis.bySphere.reduce(
|
||||
(sum: number, row: { count: number }) => sum + row.count,
|
||||
0,
|
||||
)
|
||||
expect(sphereTotal).toBe(synthesis.totals.items)
|
||||
expect(html).toContain('data-page="sintese"')
|
||||
expect(html).toContain('data-page="graficos"')
|
||||
expect(html).toContain('Síntese do que foi localizado')
|
||||
expect(html).toContain('Gráficos consolidados')
|
||||
expect(html).toContain('chart-grid')
|
||||
expect(md).toContain('## Síntese do que foi localizado')
|
||||
})
|
||||
|
||||
it('flows across continuation sheets when the pack plan splits a section', () => {
|
||||
const bigReport = buildDossierReport({
|
||||
snapshot,
|
||||
research: mergeDossierResearch(
|
||||
[
|
||||
normalizeDossierResearchInput(
|
||||
{
|
||||
themeSlug: 'educacao',
|
||||
era: 'C',
|
||||
researchedAt: '2026-09-18T10:00:00.000Z',
|
||||
items: [
|
||||
'era_c_discursos',
|
||||
'era_c_proposicoes',
|
||||
'era_c_relatorias',
|
||||
'era_c_emendas',
|
||||
'era_c_programas',
|
||||
'era_c_audiencias',
|
||||
].map((id) => item(id, 'C', { sphere: 'segmento' })),
|
||||
news: Array.from({ length: 12 }, (_, index) => ({
|
||||
title: `Notícia ${index + 1}`,
|
||||
outlet: 'Educação Hoje',
|
||||
publishedAt: '2024-05-01',
|
||||
url: `https://educacao.test/noticia-${index + 1}`,
|
||||
})),
|
||||
},
|
||||
{ unit: THEME_UNIT },
|
||||
),
|
||||
],
|
||||
{ unit: THEME_UNIT },
|
||||
),
|
||||
generatedAt,
|
||||
unit: THEME_UNIT,
|
||||
})
|
||||
const bigHtml = renderDossierHtml(bigReport, {
|
||||
pack: { 'era:C': [1], 'scope:segmento': [1], news: [1] },
|
||||
})
|
||||
expect(bigHtml).toContain('data-page="era-c"')
|
||||
expect(bigHtml).toContain('data-page="era-c-2"')
|
||||
expect(bigHtml).toContain('data-page="abrangencia-segmento"')
|
||||
expect(bigHtml).toContain('data-page="abrangencia-segmento-2"')
|
||||
expect(bigHtml).toContain('data-page="noticias"')
|
||||
expect(bigHtml).toContain('data-page="noticias-2"')
|
||||
expect(bigHtml).toContain('continuação')
|
||||
const anchors = [...bigHtml.matchAll(/data-page="([^"]+)"/g)].map((match) => match[1])
|
||||
expect(new Set(anchors).size, 'every sheet anchor must be unique').toBe(anchors.length)
|
||||
expect(anchors.length).toBe(bigReport.meta.pageTotal)
|
||||
})
|
||||
|
||||
it('ports the theme vocabulary and identity badges', () => {
|
||||
expect(html).toContain('Dossiê temático')
|
||||
expect(html).toContain('identity-badge')
|
||||
expect(html).toContain('scope-sector')
|
||||
expect(html).toContain('scope-network')
|
||||
expect(html).toContain('phase-paid')
|
||||
expect(html).toContain('phase-pending')
|
||||
expect(html).toContain('Segmento/rede não é a área')
|
||||
expect(html).toContain('Segmento e rede não são somados à área')
|
||||
})
|
||||
|
||||
it('prints the defeso note', () => {
|
||||
expect(html).toContain('Nota de defeso eleitoral 2026')
|
||||
})
|
||||
|
||||
it('escapes untrusted research text', () => {
|
||||
const hostile = buildDossierReport({
|
||||
snapshot,
|
||||
research: mergeDossierResearch(
|
||||
[
|
||||
normalizeDossierResearchInput(
|
||||
eraFile('C', [item('era_c_emendas', 'C', { answer: '<script>alert(1)</script>' })]),
|
||||
{ unit: THEME_UNIT },
|
||||
),
|
||||
],
|
||||
{ unit: THEME_UNIT },
|
||||
),
|
||||
generatedAt,
|
||||
unit: THEME_UNIT,
|
||||
})
|
||||
const hostileHtml = renderDossierHtml(hostile)
|
||||
expect(hostileHtml).not.toContain('<script>alert(1)</script>')
|
||||
expect(hostileHtml).toContain('<script>')
|
||||
})
|
||||
|
||||
it('mirrors the theme sections in markdown', () => {
|
||||
expect(md).toContain('# Dossiê Solla por tema/área — Educação')
|
||||
expect(md).toContain('## Abrangência: área × segmento × rede')
|
||||
expect(md).toContain('## Lacunas explícitas')
|
||||
expect(md).toContain('[fonte](https://educacao.test/era_a_formacao)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildBulletin (theme unit)', () => {
|
||||
const identity = {
|
||||
name: report.meta.subjectName,
|
||||
badges: report.meta.identityBadges,
|
||||
value: report.meta.identity?.value ?? null,
|
||||
taxonomyNote: report.meta.identity?.taxonomyNote ?? null,
|
||||
}
|
||||
const bulletin = buildBulletin({
|
||||
facts: report.bulletinFacts,
|
||||
identity,
|
||||
unit: THEME_UNIT,
|
||||
generatedAt,
|
||||
})
|
||||
const html = renderBulletinHtml(bulletin)
|
||||
|
||||
it('is a single A4 page with the model label and identity pills', () => {
|
||||
expect(html).toContain('data-page="boletim"')
|
||||
expect((html.match(/data-page="boletim"/g) ?? []).length).toBe(1)
|
||||
expect(html).toContain('aria-label="Modelo de boletim informativo temático de uma página"')
|
||||
expect(html).toContain('Modelo — insumo interno')
|
||||
expect(html).toContain('identity-pill')
|
||||
expect(html).toContain('Educação')
|
||||
expect(html).toContain('O que Jorge Solla fez pela <span class="accent">Educação</span>')
|
||||
})
|
||||
|
||||
it('renders the theme pills with the canonical token in mono and the taxonomy note', () => {
|
||||
expect(html).toContain('<span class="identity-pill">Área</span>')
|
||||
expect(html).toContain('<span class="identity-pill identity-pill--code">educacao</span>')
|
||||
expect(html).toContain('<span class="identity-pill">Taxonomia do acervo</span>')
|
||||
expect(html).toContain('.identity-pill--code')
|
||||
})
|
||||
|
||||
it('falls back to the year and then to a textual card (never a fake zero)', () => {
|
||||
const fact = (id: string, overrides: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
era: 'C',
|
||||
sphere: 'area',
|
||||
area: 'Educação',
|
||||
headline: `Fato ${id}`,
|
||||
detail: null,
|
||||
value: null,
|
||||
brief: null,
|
||||
year: null,
|
||||
phase: null,
|
||||
sourceUrl: `https://educacao.test/${id}`,
|
||||
sourceDate: '2024-06-01',
|
||||
...overrides,
|
||||
})
|
||||
const fallback = buildBulletin({
|
||||
facts: [fact('a-ano', { year: '2019' }), fact('b-texto')],
|
||||
identity,
|
||||
unit: THEME_UNIT,
|
||||
generatedAt,
|
||||
})
|
||||
const fallbackHtml = renderBulletinHtml(fallback)
|
||||
expect(fallback.highlights[0]).toMatchObject({ number: '2019' })
|
||||
expect(fallback.highlights[1]).toMatchObject({ number: null, textual: true })
|
||||
expect(fallbackHtml).toContain('highlight-card--textual')
|
||||
})
|
||||
|
||||
it('declares no sources and carries the defeso band', () => {
|
||||
expect(html).not.toContain('source-link')
|
||||
expect(html).not.toContain('Fonte:')
|
||||
expect(html).toContain('defeso-band')
|
||||
expect(html).toContain('sem CTA')
|
||||
expect(html).toContain('área, segmento e rede não são somados')
|
||||
})
|
||||
|
||||
it('keeps the phase next to a valued number (empenho ≠ pagamento)', () => {
|
||||
expect(html).toContain('highlight-phase')
|
||||
expect(html).toContain('phase-pending')
|
||||
expect(html).toContain('empenhado')
|
||||
expect(html).toContain('phase-paid')
|
||||
expect(html).toContain('pago')
|
||||
})
|
||||
|
||||
it('renders the sparse variant when there are fewer than three facts', () => {
|
||||
const sparse = buildBulletin({
|
||||
facts: [report.bulletinFacts[0]],
|
||||
identity,
|
||||
unit: THEME_UNIT,
|
||||
generatedAt,
|
||||
})
|
||||
const sparseHtml = renderBulletinHtml(sparse)
|
||||
expect(sparse.sparse).toBe(true)
|
||||
expect(sparseHtml).toContain('lacuna-panel')
|
||||
expect(sparseHtml).toContain('A página termina com espaço')
|
||||
expect(sparseHtml).toContain('entrega exclusiva da área')
|
||||
})
|
||||
})
|
||||
138
tests/unit/dossieThemeBatchSkill.unit.spec.ts
Normal file
138
tests/unit/dossieThemeBatchSkill.unit.spec.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// C190: pins the batch contract of the dossie-solla-tema skill, the per-era
|
||||
// fan-out and the researcher-only role of its subagent. The batch entry and the
|
||||
// era briefing are prompt text, so the contract lives in the SKILL.md prose —
|
||||
// this guard fails the build if the batch/resolver/receipt/fail-closed wording
|
||||
// is dropped, or if the subagent regains the extract/build pipeline.
|
||||
|
||||
const repoRoot = process.cwd()
|
||||
|
||||
const read = (path: string): string => readFileSync(resolve(repoRoot, path), 'utf8')
|
||||
|
||||
const skill = read('.agents/skills/dossie-solla-tema/SKILL.md')
|
||||
const researcher = read('.opencode/agent/dossie-solla-tema.md')
|
||||
const writer = read('.opencode/agent/dossie-solla-tema-redacao.md')
|
||||
|
||||
describe('skill /dossie-solla-tema documents the batch contract', () => {
|
||||
it('keeps the comma-separated batch entry and the single-area case', () => {
|
||||
expect(skill).toContain('## Lote (várias áreas)')
|
||||
expect(skill, 'batch example must be literal').toContain('/dossie-solla-tema Educação, Saúde')
|
||||
expect(skill, 'single-area must remain valid').toContain('/dossie-solla-tema Educação')
|
||||
expect(skill, 'comma is the separator').toContain('vírgula')
|
||||
expect(skill, 'N=1 is the preserved path').toContain('N=1')
|
||||
})
|
||||
|
||||
it('pins the fail-closed taxonomy resolution (no invented slug)', () => {
|
||||
expect(skill).toContain('resolveSpeechTopic')
|
||||
expect(skill).toContain('SPEECH_TOPICS')
|
||||
expect(skill, 'unknown token must fail closed').toContain('falha fechado')
|
||||
expect(skill, 'must never invent a slug').toContain('nunca')
|
||||
expect(skill, 'the taxonomy is closed (no one-off escape)').toContain('taxonomia é fechada')
|
||||
})
|
||||
|
||||
it('documents the staged pipeline with a serialized extraction', () => {
|
||||
expect(skill).toContain('## Pipeline (etapas)')
|
||||
expect(skill).toContain('serializada')
|
||||
expect(skill, 'isolated failure must be visible as partial success').toContain(
|
||||
'sucesso parcial',
|
||||
)
|
||||
expect(skill, 'summary lists status per entry').toContain('(ok|failed)')
|
||||
})
|
||||
|
||||
it('pins the per-era research fan-out and the concurrency guard', () => {
|
||||
expect(skill).toContain('## Briefing por era (A/B/C)')
|
||||
expect(skill, 'the three eras are the recorte').toContain('Era A')
|
||||
expect(skill, 'the three eras are the recorte').toContain('Era B')
|
||||
expect(skill, 'the three eras are the recorte').toContain('Era C')
|
||||
expect(skill, 'the fan-out must be bounded').toContain('MAX_RESEARCHERS_IN_FLIGHT')
|
||||
})
|
||||
|
||||
it('documents the dossiê and the boletim outputs', () => {
|
||||
expect(skill).toContain('-dossie.pdf')
|
||||
expect(skill).toContain('-boletim.pdf')
|
||||
expect(skill, 'the boletim has no source list').toContain('Sem declaração de fontes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('finite researcher receipt (C190)', () => {
|
||||
it('declares the short receipt and its fields', () => {
|
||||
expect(skill).toContain('## Recibo do researcher')
|
||||
for (const field of ['itemCount', 'gapCount', 'newsCount', 'failureReason']) {
|
||||
expect(skill, `receipt must declare ${field}`).toContain(field)
|
||||
}
|
||||
expect(skill, 'receipt must never carry the body').toMatch(/corpo\s+do\s+`research\.json`/)
|
||||
})
|
||||
|
||||
it('declares the per-era JSON contract', () => {
|
||||
expect(skill).toContain('## Contrato dos JSONs')
|
||||
expect(skill, 'era is required in the file').toContain('"era": "C"')
|
||||
expect(skill, 'the unit slug field is themeSlug').toContain('"themeSlug": "educacao"')
|
||||
for (const id of ['era_a_vinculo', 'era_b_politicas', 'era_c_relatorias', 'era_c_audiencias']) {
|
||||
expect(skill, `checklist must list ${id}`).toContain(id)
|
||||
}
|
||||
expect(skill, 'segmento/rede is never summed').toMatch(/nunca\s+(\*\*)?são?\s+somados/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent .opencode/agent/dossie-solla-tema.md is researcher-only', () => {
|
||||
it('stays a subagent that writes the era research.json and returns the receipt', () => {
|
||||
expect(researcher).toMatch(/^---\n[\s\S]*mode: subagent[\s\S]*\n---/)
|
||||
expect(researcher).toContain('researcher')
|
||||
expect(researcher).toContain('.c.research.json')
|
||||
expect(researcher).toContain('Recibo do researcher')
|
||||
})
|
||||
|
||||
it('does not run the deterministic extract/build stages', () => {
|
||||
expect(researcher, 'researcher must not run the extractor CLI').not.toContain('--theme=')
|
||||
expect(researcher, 'researcher must not run the builder CLI').not.toContain('--snapshot=')
|
||||
})
|
||||
|
||||
it('leaves the narrative to the redator step', () => {
|
||||
expect(researcher, 'the researcher does not write the narrative file').not.toContain(
|
||||
'narrative.json',
|
||||
)
|
||||
expect(researcher).toContain('redator')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dossiê temático sem caps + redação (revisão 2026-09-18)', () => {
|
||||
it('documents the flowing sheets and the measured packing', () => {
|
||||
expect(skill, 'no caps in the theme dossiê').toContain('não usa caps')
|
||||
expect(skill).toContain('grow/shrink')
|
||||
expect(skill).toContain('dossiePack.mjs')
|
||||
expect(skill, 'continuation sheets carry the header').toContain('continuação N')
|
||||
})
|
||||
|
||||
it('documents the opening letter, the synthesis and the charts', () => {
|
||||
expect(skill).toContain('A contribuição (carta)')
|
||||
expect(skill).toContain('**Síntese**')
|
||||
expect(skill).toContain('Gráficos consolidados')
|
||||
expect(skill, 'the era opens with a consolidation paragraph').toMatch(
|
||||
/O que esta era\s+entrega/,
|
||||
)
|
||||
})
|
||||
|
||||
it('pins the narrative contract and the writer receipt', () => {
|
||||
expect(skill).toContain('## Recibo do redator')
|
||||
expect(skill).toContain('"themeSlug": "educacao"')
|
||||
expect(skill, 'the narrative file carries the era paragraphs').toContain('"eras": {')
|
||||
expect(skill, 'the orchestrator audits the citations').toMatch(/audita/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent .opencode/agent/dossie-solla-tema-redacao.md is writer-only', () => {
|
||||
it('stays a subagent that writes the narrative and returns the receipt', () => {
|
||||
expect(writer).toMatch(/^---\n[\s\S]*mode: subagent[\s\S]*\n---/)
|
||||
expect(writer).toContain('narrative.json')
|
||||
expect(writer).toContain('Recibo do redator')
|
||||
})
|
||||
|
||||
it('does not run the deterministic extract/build stages', () => {
|
||||
expect(writer, 'writer must not run the extractor CLI').not.toContain('--theme=')
|
||||
expect(writer, 'writer must not run the builder CLI').not.toContain('--snapshot=')
|
||||
expect(writer, 'writer must not edit the research files').toMatch(/não\*\* edite os/)
|
||||
})
|
||||
})
|
||||
127
tests/unit/dossieThemeResearch.unit.spec.ts
Normal file
127
tests/unit/dossieThemeResearch.unit.spec.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DOSSIER_RESEARCH_CHECKLIST_IDS,
|
||||
dossierChecklistForEra,
|
||||
dossierResearchReceipt,
|
||||
mergeDossierResearch,
|
||||
normalizeDossierResearchInput,
|
||||
} from '../../scripts/lib/dossieResearch.mjs'
|
||||
import { THEME_UNIT } from '../../scripts/lib/dossieUnit.mjs'
|
||||
|
||||
// C190: the theme/area branch of the shared research contract. Same eras,
|
||||
// `themeSlug` and `area|segmento|rede` spheres; the C186 municipality and C187
|
||||
// institution behaviours are untouched.
|
||||
|
||||
const validItem = (id: string, overrides: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
answer: 'Entrega documentada.',
|
||||
sphere: 'area',
|
||||
sourceUrl: 'https://educacao.test/ato',
|
||||
sourceDate: '2024-02-01',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const eraResearch = (era: 'A' | 'B' | 'C', overrides: Record<string, unknown> = {}) => ({
|
||||
themeSlug: 'educacao',
|
||||
era,
|
||||
researchedAt: '2026-09-18T10:00:00.000Z',
|
||||
items: dossierChecklistForEra(era, THEME_UNIT).map((item) => validItem(item.id)),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('theme checklist', () => {
|
||||
it('exposes a theme checklist id per era (distinct ids)', () => {
|
||||
const ids = [
|
||||
...dossierChecklistForEra('A', THEME_UNIT),
|
||||
...dossierChecklistForEra('B', THEME_UNIT),
|
||||
...dossierChecklistForEra('C', THEME_UNIT),
|
||||
].map((row) => row.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
expect(ids).toContain('era_b_politicas')
|
||||
expect(ids).toContain('era_c_relatorias')
|
||||
expect(ids).toContain('era_c_audiencias')
|
||||
// Municipality ids remain the C186 public surface.
|
||||
expect(DOSSIER_RESEARCH_CHECKLIST_IDS).toContain('era_a_formacao')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeDossierResearchInput (theme unit)', () => {
|
||||
it('requires themeSlug and keeps theme items', () => {
|
||||
const research = normalizeDossierResearchInput(eraResearch('C'), { unit: THEME_UNIT })
|
||||
expect(research.themeSlug).toBe('educacao')
|
||||
expect(research.items.length).toBe(dossierChecklistForEra('C', THEME_UNIT).length)
|
||||
expect(research.items.every((item) => item.sphere === 'area')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects the municipality slug field', () => {
|
||||
expect(() =>
|
||||
normalizeDossierResearchInput(
|
||||
{ municipalitySlug: 'ilheus', era: 'C', researchedAt: '2026-09-18T10:00:00.000Z' },
|
||||
{ unit: THEME_UNIT },
|
||||
),
|
||||
).toThrow(/themeSlug/)
|
||||
})
|
||||
|
||||
it('turns an invalid sphere into a gap with the theme vocabulary', () => {
|
||||
const items = dossierChecklistForEra('A', THEME_UNIT).map((item) => validItem(item.id))
|
||||
items[0] = validItem(items[0].id, { sphere: 'instituicao' })
|
||||
const research = normalizeDossierResearchInput(eraResearch('A', { items }), {
|
||||
unit: THEME_UNIT,
|
||||
})
|
||||
expect(research.gaps).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: items[0].id,
|
||||
reason: expect.stringMatching(/area\/segmento\/rede/),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the sphere to area and keeps segmento/rede', () => {
|
||||
const research = normalizeDossierResearchInput(
|
||||
eraResearch('B', {
|
||||
items: [
|
||||
validItem('era_b_sesab', { sphere: undefined }),
|
||||
validItem('era_b_politicas', { sphere: 'segmento' }),
|
||||
validItem('era_b_investimentos', { sphere: 'rede' }),
|
||||
],
|
||||
}),
|
||||
{ unit: THEME_UNIT },
|
||||
)
|
||||
const byId = Object.fromEntries(research.items.map((item) => [item.id, item.sphere]))
|
||||
expect(byId.era_b_sesab).toBe('area')
|
||||
expect(byId.era_b_politicas).toBe('segmento')
|
||||
expect(byId.era_b_investimentos).toBe('rede')
|
||||
})
|
||||
|
||||
it('builds a theme receipt with the theme research path', () => {
|
||||
const receipt = dossierResearchReceipt(
|
||||
normalizeDossierResearchInput(eraResearch('A'), { unit: THEME_UNIT }),
|
||||
{ unit: THEME_UNIT },
|
||||
)
|
||||
expect(receipt.slug).toBe('educacao')
|
||||
expect(receipt.researchPath).toBe('data/dossie-solla-tema/educacao.a.research.json')
|
||||
expect(receipt.itemCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeDossierResearch (theme unit)', () => {
|
||||
it('merges the theme eras', () => {
|
||||
const merged = mergeDossierResearch(
|
||||
(['A', 'B', 'C'] as const).map((era) =>
|
||||
normalizeDossierResearchInput(eraResearch(era), { unit: THEME_UNIT }),
|
||||
),
|
||||
{ unit: THEME_UNIT },
|
||||
)
|
||||
expect(merged.themeSlug).toBe('educacao')
|
||||
expect(merged.eras).toEqual(['A', 'B', 'C'])
|
||||
})
|
||||
|
||||
it('fails closed when theme slugs disagree', () => {
|
||||
const a = normalizeDossierResearchInput(eraResearch('A'), { unit: THEME_UNIT })
|
||||
const b = normalizeDossierResearchInput(eraResearch('B', { themeSlug: 'saude' }), {
|
||||
unit: THEME_UNIT,
|
||||
})
|
||||
expect(() => mergeDossierResearch([a, b], { unit: THEME_UNIT })).toThrow(/temas diferentes/)
|
||||
})
|
||||
})
|
||||
|
|
@ -18,6 +18,7 @@ const commands = [
|
|||
'relatorio-cidade',
|
||||
'dossie-solla-cidade',
|
||||
'dossie-solla-instituicao',
|
||||
'dossie-solla-tema',
|
||||
] as const
|
||||
|
||||
describe('opencode commands couple to their skill by exact name', () => {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,31 @@
|
|||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mergeFacetClassification, parseLlmFacetResponse } from '@/lib/speechFacets'
|
||||
import {
|
||||
mergeFacetClassification,
|
||||
parseLlmFacetResponse,
|
||||
resolveSpeechTopic,
|
||||
} from '@/lib/speechFacets'
|
||||
import { classifySpeechByGazetteer, matchMunicipalityMentions } from '@/lib/speechGazetteer'
|
||||
|
||||
describe('resolveSpeechTopic (C190)', () => {
|
||||
it('resolves the canonical value and the pt-BR label (accent/case insensitive)', () => {
|
||||
expect(resolveSpeechTopic('educacao')?.value).toBe('educacao')
|
||||
expect(resolveSpeechTopic('Educação')?.value).toBe('educacao')
|
||||
expect(resolveSpeechTopic(' EDUCAÇÃO ')?.value).toBe('educacao')
|
||||
expect(resolveSpeechTopic('Seguranca Publica')?.value).toBe('seguranca-publica')
|
||||
expect(resolveSpeechTopic('Direitos Humanos e Assistência Social')?.value).toBe(
|
||||
'direitos-humanos',
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed on an unknown or empty token (never an invented slug)', () => {
|
||||
expect(resolveSpeechTopic('educacao-infantil')).toBeNull()
|
||||
expect(resolveSpeechTopic('')).toBeNull()
|
||||
expect(resolveSpeechTopic(' ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifySpeechByGazetteer', () => {
|
||||
it('detects topics, explicit scopes and municipality mentions', () => {
|
||||
const result = classifySpeechByGazetteer({
|
||||
|
|
|
|||
Loading…
Reference in a new issue