diff --git a/.agents/skills/dossie-solla-tema/SKILL.md b/.agents/skills/dossie-solla-tema/SKILL.md new file mode 100644 index 00000000..2399ab73 --- /dev/null +++ b/.agents/skills/dossie-solla-tema/SKILL.md @@ -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/..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/.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/.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/.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/--dossie.pdf` + + `-dossie.md` + `-boletim.pdf`. Intermediários gitignored em + `data/dossie-solla-tema/` (HTML, JSONs, logs). Flags úteis: + `--generated-at=`, `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>` + ``; 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 + +`..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. + +`.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 = `) 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. diff --git a/.gitignore b/.gitignore index 639b17b0..61598904 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.opencode/agent/dossie-solla-tema-redacao.md b/.opencode/agent/dossie-solla-tema-redacao.md new file mode 100644 index 00000000..cddc6b3f --- /dev/null +++ b/.opencode/agent/dossie-solla-tema-redacao.md @@ -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/..research.json` + (`items[]` com `answer`/`details`/`brief`/`numbers`/`sphere`/`sourceUrl`/ + `sourceDate`; `gaps[]` são lacunas explícitas). +2. Escrever `data/dossie-solla-tema/.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). diff --git a/.opencode/agent/dossie-solla-tema.md b/.opencode/agent/dossie-solla-tema.md new file mode 100644 index 00000000..aa0b58e0 --- /dev/null +++ b/.opencode/agent/dossie-solla-tema.md @@ -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/..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". diff --git a/.opencode/commands/dossie-solla-tema.md b/.opencode/commands/dossie-solla-tema.md new file mode 100644 index 00000000..00064eb0 --- /dev/null +++ b/.opencode/commands/dossie-solla-tema.md @@ -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ê (`--dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). Token fora da taxonomia dos 18 temas falha fechado, listando as áreas canônicas; nunca invente slug. + +$ARGUMENTS diff --git a/docs/changelog/2026-09-18-c190.md b/docs/changelog/2026-09-18-c190.md new file mode 100644 index 00000000..37bc8bd7 --- /dev/null +++ b/docs/changelog/2026-09-18-c190.md @@ -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). diff --git a/docs/plans/dossie-solla-tema-impl.md b/docs/plans/dossie-solla-tema-impl.md new file mode 100644 index 00000000..f4f54a19 --- /dev/null +++ b/docs/plans/dossie-solla-tema-impl.md @@ -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
resolveSpeechTopic (18 canônicos)
fail-closed + dedup"] + B --> C["N researchers por era (A|B|C × tema)
paralelo · MAX_RESEARCHERS_IN_FLIGHT=6"] + C --> R["..research.json
+ recibo curto (status/paths/gaps)"] + B --> E["extração read-only, 1 tema por vez
extract-theme-snapshot.mjs · THEME_REPORT_CONFIRM=1
composeThemeSnapshot (Speech.topics direto)"] + E --> S[".theme.snapshot.json"] + R --> G["build-dossie-solla-tema.mjs"] + S --> G + G --> I["buildDossierReport(unit=THEME):
capa+resumo+eras+abrangência+acervo+fontes
→ bulletinFacts ledger (só fato com fonte)"] + I --> J["renderDossierHtml/Md → PDF A4 multi-folha + .md
(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/--*.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= --out=`; 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/.theme.snapshot.json`. +- **`scripts/build-dossie-solla-tema.mjs`** (novo) — CLI `--snapshot= --research-dir= --out-dir= [--generated-at=] --[--theme=]`; exige `snapshot.theme.slug`; lê `.{a,b,c}.research.json` (recusa slug divergente) e `.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= --name=""` 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`. diff --git a/scripts/build-dossie-solla-tema.mjs b/scripts/build-dossie-solla-tema.mjs new file mode 100644 index 00000000..bb82e4fe --- /dev/null +++ b/scripts/build-dossie-solla-tema.mjs @@ -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= [--research-dir=] [--out-dir=] [--generated-at=]') +} +if (Number.isNaN(generatedAt.getTime())) die(`--generated-at inválida: ${flags['generated-at']}`) + +const readJson = async (relativePath, label) => { + try { + return JSON.parse(await readFile(resolve(ROOT, relativePath), 'utf8')) + } catch (error) { + die( + `Falha ao ler ${label} (${relativePath}): ${error instanceof Error ? error.message : error}`, + ) + } +} + +const snapshot = await readJson(snapshotPath, 'snapshot') +const slug = snapshot.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.`, + ) +} diff --git a/scripts/extract-theme-snapshot.mjs b/scripts/extract-theme-snapshot.mjs new file mode 100644 index 00000000..809527e2 --- /dev/null +++ b/scripts/extract-theme-snapshot.mjs @@ -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= --out=\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= --out=.') +} + +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) diff --git a/scripts/lib/dossieBlocks.mjs b/scripts/lib/dossieBlocks.mjs index 1698b325..da9e87da 100644 --- a/scripts/lib/dossieBlocks.mjs +++ b/scripts/lib/dossieBlocks.mjs @@ -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, } diff --git a/scripts/lib/dossieBulletin.mjs b/scripts/lib/dossieBulletin.mjs index 964be594..96fd2a94 100644 --- a/scripts/lib/dossieBulletin.mjs +++ b/scripts/lib/dossieBulletin.mjs @@ -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', diff --git a/scripts/lib/dossieBulletinRender.mjs b/scripts/lib/dossieBulletinRender.mjs index 7623aecd..163df737 100644 --- a/scripts/lib/dossieBulletinRender.mjs +++ b/scripts/lib/dossieBulletinRender.mjs @@ -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 } = {}) => ` -
+

${htmlEscape(highlight.eyebrow)}

- ${highlight.number ? `

${htmlEscape(highlight.number)}

` : ''} + ${!highlight.textual && highlight.number ? `

${htmlEscape(highlight.number)}

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

${htmlEscape(highlight.phaseLabel)}

` @@ -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 = '' -const renderInstitutionBulletinHtml = (bulletin) => { +const renderSubjectBulletinHtml = (bulletin) => { const sparse = Boolean(bulletin.sparse) - const pills = (bulletin.meta.identityBadges ?? []) - .map((badge) => `${htmlEscape(badge)}`) + 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) => + `${htmlEscape(pill.label)}`, + ) .join('') const highlights = bulletin.highlights const more = bulletin.moreItems return ` -${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.subjectName)} +${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.subjectName)} -
+

${htmlEscape(bulletin.meta.kicker)}

@@ -266,16 +278,19 @@ const renderInstitutionBulletinHtml = (bulletin) => {
- -

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

-

${ - sparse - ? 'Esta versão tem poucos registros confirmados. Em vez de preencher espaço, mostra só os fatos que o dossiê sustenta.' - : 'Ações, recursos e articulações explicados de forma direta — somente o que já foi documentado no dossiê institucional.' - }

+ +

${(() => { + const title = copy.openingTitle(bulletin.meta.subjectName) + const name = htmlEscape(bulletin.meta.subjectName) + const escaped = htmlEscape(title) + return escaped.includes(name) + ? escaped.replace(name, `${name}`) + : escaped + })()}

+

${sparse ? htmlEscape(copy.leadSparse) : htmlEscape(copy.lead)}

${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', ])}
@@ -284,7 +299,7 @@ const renderInstitutionBulletinHtml = (bulletin) => {
-

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

+

${sparse ? htmlEscape(copy.highlightsTitleSparse) : htmlEscape(copy.highlightsTitle)}

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

@@ -297,8 +312,8 @@ const renderInstitutionBulletinHtml = (bulletin) => {
-

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

-

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

+

${htmlEscape(copy.title)}

+

${sparse ? htmlEscape(copy.titleSparse) : 'quatro momentos · linguagem direta'}

${bulletin.timeline @@ -317,14 +332,14 @@ const renderInstitutionBulletinHtml = (bulletin) => {
-

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

+

${sparse ? htmlEscape(copy.moreTitleSparse) : htmlEscape(copy.moreTitle)}

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

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

Sem itens adicionais com fonte além dos destaques.

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

' + : `

Sem itens adicionais com fonte além dos destaques.

${htmlEscape(copy.moreEmpty)}

` }
${ @@ -332,12 +347,12 @@ const renderInstitutionBulletinHtml = (bulletin) => { ? `` : `` }
@@ -349,7 +364,7 @@ const renderInstitutionBulletinHtml = (bulletin) => {

Se novos fatos forem documentados, eles entram por relevância — até seis destaques e até quatorze itens. A ausência nunca vira número zero, percentual ou afirmação genérica.

- ${ASSET('NEEDS ASSET · selo', ['marca autorizada da instituição', 'arquivo vetorial'])} + ${ASSET('NEEDS ASSET · selo', copy.assetSelo ?? [])} ` : '' } @@ -357,8 +372,8 @@ const renderInstitutionBulletinHtml = (bulletin) => {