feat(C186/C187): dossiê corrente sem caps + redação, síntese e gráficos (paridade cidade × instituição)

Revisão de produto a partir do uso real (UFBA): as seções deixam de ser
capadas e fluem por quantas folhas precisarem, com packing medido
(probe → grow/shrink por folha, scripts/lib/dossiePack.mjs) e guarda de A4
no emit; o dossiê ganha redação de abertura (narrative.json escrito por
sub-agente e auditado pelo orquestrador), parágrafo de consolidação por
era, páginas de síntese e gráficos (recursos com fase, abrangência,
trajetória) e acervo como amostra declarada. O dono (dossieBlocks/
dossieRender/dossieUnit/buildPdf) foi generalizado e o ramo cidade adotou
as mesmas decisões; skills, comandos, agentes redatores, specs e designs
hi-fi re-sincronizados.
This commit is contained in:
Francisco Solla 2026-09-17 22:29:13 -03:00
parent 4b00a8778a
commit f9f23bf64e
27 changed files with 5495 additions and 3091 deletions

View file

@ -19,7 +19,8 @@ aprovados pelo `designer`.
- A comunicação pede "o dossiê de <cidade>" e/ou o "boletim modelo de <cidade>"
— ou várias numa invocação (`/dossie-solla-cidade Ilheus, Itacare, Una`).
- Quem executa são o **orquestrador** (agente principal) + um sub-agente
**researcher por era, por cidade**, em paralelo + os scripts (`extract` no
**researcher por era, por cidade**, em paralelo + um sub-agente **redator**
(redação de abertura e parágrafos por era) + os scripts (`extract` no
homeserver, `build` local). O detalhe está em "Pipeline (etapas)".
## Lote (várias cidades)
@ -62,30 +63,41 @@ parcial** explícito).
`sourceUrl` + `sourceDate`; sem fonte, **não escreva o item** — ele vira
lacuna). Devolve **apenas o recibo curto** (seção "Recibo do researcher").
Não roda ssh nem build.
3. **Extração read-only no homeserver — serializada, 1 cidade por vez.** Reusa
3. **Redação (sub-agente, 1 por cidade).**
`.opencode/agent/dossie-solla-cidade-redacao.md` — depois das eras
pesquisadas, escreve `data/dossie-solla-cidade/<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 cidade por vez.** Reusa
o extrator do relatório de cidade (`scripts/extract-city-report-snapshot.mjs`,
`CITY_REPORT_CONFIRM=1`, `withReadOnlyDatabaseUrl`) e a mesma receita do
runbook `docs/ops/teqo-1313-deploy.md` §"C163 — relatório de cidade"
(`~/teqo-report`). O recorte do dossiê que vive na base Teqo é o **acervo de
falas (2011+)**; o resto é API pública em tempo de build.
4. **Fontes oficiais em tempo de build (por cidade).** O builder busca emendas
5. **Fontes oficiais em tempo de build (por cidade).** O builder busca emendas
do autor (`--emendas=` para replay), a atividade da Câmara (proposições e
discursos — janela sempre explícita) e o contexto IBGE/SIDRA; qualquer falha
**degrada para lacuna datada**, nunca zero silencioso.
5. **Render local (por cidade):**
6. **Render local (por cidade):**
```bash
NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-cidade.mjs \
--snapshot=data/dossie-solla-cidade/<slug>.snapshot.json \
--research-dir=data/dossie-solla-cidade \
--out-dir=docs/research/dossie-solla-cidade
```
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-cidade/<slug>-<YYYY-MM-DD>-dossie.pdf` +
`-dossie.md` + `-boletim.pdf`. Intermediários gitignored em
`data/dossie-solla-cidade/` (HTML, JSONs, emendas, logs). Flags úteis:
`--emendas=<json>` (replay sem rede), `--author=<nome>` (default
`JORGE SOLLA`), `--generated-at=<ISO>`, `DOSSIER_STRICT=1` (falha o run se
houver lacuna — conferência, não entrega).
6. **Summary final.** Uma linha por entrada, na ordem da lista: `entrada · status
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)
@ -138,6 +150,28 @@ do `research.json` (`items[].answer/details`):
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": "ilheus",
"status": "ok", // ou "failed"
"narrativePath": "data/dossie-solla-cidade/ilheus.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`):
@ -201,6 +235,26 @@ o `brief` é a copy reformulada que o PDF imprime nas páginas de era/cartões;
`summary` alimenta as superfícies de resumo do dossiê e o ledger do boletim.
**`brief` (copy reformulada, sem reticências).** As páginas A4 têm altura fixa:
**`<slug>.narrative.json` (opcional; escrito pelo redator, auditado pelo
orquestrador):
```jsonc
{
"municipalitySlug": "ilheus", // obrigatório
"generatedAt": "2026-09-18T00:26:19.000Z", // obrigatório
"title": "O que Jorge Solla fez por Ilhéus",
"opening": ["parágrafo", "parágrafo", "parágrafo"],
"eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" }
}
```
Sem o arquivo (ou com `municipalitySlug` 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.
`brief` (copy reformulada, sem reticências).** As páginas A4 têm altura fixa:
em vez de cortar o texto com `…`, cada item publicado traz um `brief` **reescrito
para caber** (`title` ≤80 / `note` ≤120) preservando o essencial (o quê, onde,
valor + fase) — nunca inventa fato, nunca sugere exclusividade municipal para
@ -214,20 +268,45 @@ continuação com o texto inteiro.
## Conteúdo do dossiê
- **Capa** — série municipal, "INSUMO INTERNO", território, região/polo
adotado, data, "como usar", escopo/versão.
- **Resumo de uma olhada** — linha do tempo da carreira; principais entregas
localizadas (badge de esfera + valor + badge de fase + fonte); ganchos para o
boletim; o que falta. Guarda: **autorizado ≠ empenhado ≠ liquidado ≠ pago**.
- **Seção por era (A/B/C)** — recorte e método + trilha de recuperação; tabela
**Objeto/Valor/Ano/Fase/Esfera/Fonte**; cards "Atuação registrada"; títulos,
honrarias e vínculos locais. Seções vazias são omitidas.
- **Região / polo** — painel "Região não é cidade. Não some os dois recortes.";
duas listas lado a lado (município ≠ região/polo); itens regionais com
evidência de alcance; gancho e lacuna prioritária.
- **Fontes e limites** — tabela de lacunas; notícias/documentos consultados
(larguras fixas 12/13/47/28%); limites de cobertura; regras para uso editorial;
nota de defeso eleitoral 2026.
- **Capa** — série municipal, "INSUMO INTERNO", território, região/polo adotado,
data, "como usar", escopo/versão.
- **A contribuição (carta)** — redação de abertura sobre o que Solla fez pela
cidade e pela região 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).
- **Trajetória completa** — a linha do tempo da carreira.
- **Resumo de uma olhada** — identificação + entregas localizadas (badge de
esfera + valor + badge de fase + fonte); ganchos; 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 esfera,
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** (lista com barra e valor),
abrangência (município × região × polo), 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", do `narrative.json` ou determinístico) + recorte e método + trilha de
recuperação; tabela **Objeto/Valor/Ano/Fase/Esfera/Fonte**; cards "O que fez —
item, alcance e lastro"; títulos/honrarias locais. **Sem cap**: a era flui por
quantas folhas precisar (continuações com cabeçalho "continuação N"). Uma era
sem evidência é omitida (nunca inventada).
- **Região / polo** — painel "Região não é cidade. Não some os dois recortes."
com contagem por recorte (**nenhum total combinado**) e prévia; em seguida
**as duas listas completas** em tabela (item/esfera/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** — tabelas/listas completas, também correntes (sem cap). O acervo é
**amostra declarada** (as falas mais recentes com link, de N do recorte
municipal) com painel do total; região/polo não entram nessa conta.
- **Fontes e limites** — limites de cobertura; regras para uso editorial; nota de
defeso eleitoral 2026.
- **Nada some e nada é cortado:** o dossiê **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)
@ -241,6 +320,11 @@ continuação com o texto inteiro.
## 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.
- **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.
- **Empenho ≠ pagamento**: cada valor acompanha sua fase
(autorizado/empenhado/liquidado/pago/restos); nunca consolidar como
equivalentes.
@ -259,9 +343,17 @@ continuação com o texto inteiro.
## Troubleshooting
- **Página 1 do dossiê / boletim estourou**: o builder aborta com a altura
medida. **Aperte o teto de conteúdo/corte copy — nunca mexa no layout para
espremer.**
- **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 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.
- **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.
`municipalitySlug` diferente do snapshot = erro (regenere).
- **Era sem pesquisa**: o builder sintetiza o arquivo da era com lacunas
explícitas — não invente itens.
- **Câmara 429/500**: `getJsonWithBackoff` + paginação com teto; falha degrada
@ -279,9 +371,13 @@ continuação com o texto inteiro.
- Intenção: `docs/plans/dossie-solla-cidade.md`; impl:
`docs/plans/dossie-solla-cidade-impl.md`.
- Designs hi-fi (fonte de verdade do port): `docs/plans/dossie-solla-cidade-ui-design.html`
e `docs/plans/dossie-solla-cidade-boletim-ui-design.html`.
e `docs/plans/dossie-solla-cidade-boletim-ui-design.html` — **desatualizados
desde a revisão de 2026-09-18** (carta, síntese, gráficos e folhas correntes
ainda não estão neles); um passe do `designer` precisa re-sincronizá-los.
- Precedente: `.agents/skills/relatorio-cidade/SKILL.md`,
`.opencode/agent/relatorio-cidade.md`, `scripts/build-city-report.mjs`.
- Scripts: `scripts/build-dossie-solla-cidade.mjs`, `scripts/lib/dossie*.mjs`.
- Scripts: `scripts/build-dossie-solla-cidade.mjs`,
`scripts/lib/dossiePack.mjs` (packing grow/shrink),
`scripts/lib/buildPdf.mjs` (probe/medição/emit), `scripts/lib/dossie*.mjs`.
- Bio: `.opencode/skills/solla-comunicacao/referencia/perfil-e-posicoes.md`.
- Runbook de extração read-only: `docs/ops/teqo-1313-deploy.md` §C163.

View file

@ -12,7 +12,14 @@ e com a instituição ao longo da carreira** — toda afirmação não trivial c
**URL + data**, tudo sem fonte vira **lacuna explícita** — **e** o **Boletim
Informativo modelo** (1 página A4, linguagem de eleitor, sem declaração de
fontes). Skill irmã da `dossie-solla-cidade` (C186): mesmo pipeline, recorte
institucional. Os dois layouts vêm dos artefatos hi-fi aprovados pelo `designer`.
institucional. O layout vem dos artefatos hi-fi aprovados pelo `designer`.
O dossiê institucional **não usa caps**: cada seção (eras, abrangência, títulos,
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** (recursos com fase, abrangência, trajetória), e cada
era abre com um **parágrafo de consolidação** do que ela entrega.
## Quando usar
@ -20,8 +27,9 @@ institucional. Os dois layouts vêm dos artefatos hi-fi aprovados pelo `designer
<instituição>" — ou várias numa invocação (`/dossie-solla-instituicao UFBA,
Correios`).
- Quem executa são o **orquestrador** (agente principal) + um sub-agente
**researcher por era, por instituição**, em paralelo + os scripts (`extract` no
homeserver, `build` local). O detalhe está em "Pipeline (etapas)".
**researcher por era, por instituição**, em paralelo + um sub-agente
**redator** (redação de abertura e parágrafos por era) + os scripts (`extract`
no homeserver, `build` local). O detalhe está em "Pipeline (etapas)".
## Lote (várias instituições)
@ -65,29 +73,40 @@ parcial** explícito).
`sourceUrl` + `sourceDate`; sem fonte, **não escreva o item** — ele vira
lacuna). Devolve **apenas o recibo curto** (seção "Recibo do researcher").
Não roda ssh nem build.
3. **Extração read-only no homeserver — serializada, 1 instituição por vez.**
3. **Redação (sub-agente, 1 por instituição).**
`.opencode/agent/dossie-solla-instituicao-redacao.md` — depois das eras
pesquisadas, escreve `data/dossie-solla-instituicao/<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 instituição por vez.**
`scripts/extract-institution-snapshot.mjs` (`INSTITUTION_REPORT_CONFIRM=1`,
sessão read-only) lê o acervo interno de falas por **tema→instituição**
(`topics` da entrada do catálogo; o acervo cobre 2011+ e não tem campo
instituição) e grava
`data/dossie-solla-instituicao/<slug>.institution.snapshot.json`.
4. **Fontes oficiais em tempo de build.** O Portal da Transparência **não filtra
5. **Fontes oficiais em tempo de build.** O Portal da Transparência **não filtra
por instituição** e a Câmara tampouco; emendas/proposições institucionais
entram como **itens de pesquisa** (com fase e fonte). Sem atribuição → lacuna,
**nunca zero silencioso**. O builder **não** busca emendas/Câmara/IBGE.
5. **Render local (por instituição):**
6. **Render local (por instituição):**
```bash
NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-instituicao.mjs \
--snapshot=data/dossie-solla-instituicao/<slug>.institution.snapshot.json \
--research-dir=data/dossie-solla-instituicao \
--out-dir=docs/research/dossie-solla-instituicao
```
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-instituicao/<slug>-<YYYY-MM-DD>-dossie.pdf`
+ `-dossie.md` + `-boletim.pdf`. Intermediários gitignored em
`data/dossie-solla-instituicao/` (HTML, JSONs, logs). Flags úteis:
`--generated-at=<ISO>`, `DOSSIER_STRICT=1` (falha o run se houver lacuna —
conferência, não entrega).
6. **Summary final.** Uma linha por entrada, na ordem da lista: `entrada · status
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)
@ -138,6 +157,28 @@ do `research.json` (`items[].answer/details`):
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": "ufba",
"status": "ok", // ou "failed"
"narrativePath": "data/dossie-solla-instituicao/ufba.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`):
@ -186,29 +227,66 @@ 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
{
"institutionSlug": "ufba", // obrigatório
"generatedAt": "2026-09-18T00:26:19.000Z", // obrigatório
"title": "O que Jorge Solla fez pela e na UFBA",
"opening": ["parágrafo", "parágrafo", "parágrafo"],
"eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" }
}
```
Sem o arquivo (ou com `institutionSlug` 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 institucional, "INSUMO INTERNO — defeso 2026", identificação
(tipo/esfera/alcance em badges), data, "como usar", escopo/versão.
- **A contribuição (carta)** — redação de abertura sobre o que Solla fez pela e
na instituição 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 + linha do tempo documentada do
vínculo; principais entregas localizadas (badge de abrangência + valor + badge
de fase + fonte); gancho para a agenda; o que falta. Guarda: **autorizado ≠
empenhado ≠ liquidado ≠ pago**.
- **Seção por era (A/B/C)** — recorte e método + trilha de recuperação; tabela
**Objeto/Valor/Ano/Fase/Abrangência/Fonte**; cards "Papéis com evidência";
títulos/honrarias. Uma era **sem evidência** vira **página de lacuna
explícita** (nunca seção em branco nem "zero").
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** (lista com barra e valor),
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", do `narrative.json` ou determinístico) + recorte e método + trilha de
recuperação; tabela **Objeto/Valor/Ano/Fase/Abrangência/Fonte**; cards "Papéis
com evidência". **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: instituição × setor × rede** — painel "Setor/rede não é a
instituição. Não some os recortes."; três listas com contagem por recorte
(**nenhum total combinado**); tabela de evidência de alcance; gancho e lacuna
prioritária.
instituição. 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.
- **Títulos, honrarias e vínculos** — tabela reconhecimento/natureza/fonte.
- **Fontes e limites** — tabela de lacunas; notícias/documentos consultados
(larguras fixas 12/13/47/28%); limites de cobertura; regras para uso editorial;
nota de defeso eleitoral 2026.
- **Nada some por não caber:** listas capadas declaram o resto ("e mais N") e a
reformulação fica no `summary` do researcher — nunca "…" silencioso (regra
C188 aplicada no dono).
- **Lacunas explícitas**, **Notícias e documentos consultados** e **Acervo
interno** — tabelas/listas completas, também correntes (sem cap). O acervo é
**amostra declarada** (as falas mais recentes com link, de N do recorte
temático) com painel do total; o recorte é por tema, não nominal.
- **Fontes e limites** — limites de cobertura; regras para uso editorial; nota de
defeso eleitoral 2026.
- **Nada some e nada é cortado:** o dossiê institucional **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)
@ -225,9 +303,14 @@ boletim) só é populado por item **com fonte** — o boletim não introduz fato
## 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** (`instituição`/`setor`/`rede`): setor e rede
**nunca** são somados como se fossem a instituição.
- **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.
- **Atribuição de emenda** de bancada/relator só com autoria checada; sem
atribuição → lacuna (o Portal não filtra por instituição).
@ -240,10 +323,19 @@ boletim) só é populado por item **com fonte** — o boletim não introduz fato
## Troubleshooting
- **Página do dossiê / boletim estourou**: o builder aborta com a altura medida.
**Aperte o teto de conteúdo/corte copy — nunca mexa no layout para espremer.**
- **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.
`institutionSlug` diferente do snapshot = erro (regenere).
- **Câmara 429/500**: `getJsonWithBackoff` + paginação com teto; falha degrada
para lacuna datada. Os discursos sempre passam janela (`dataInicio`/`dataFim`).
- **Sem chave do Portal da Transparência**: emendas institucionais viram lacuna
@ -261,11 +353,15 @@ boletim) só é populado por item **com fonte** — o boletim não introduz fato
`docs/plans/dossie-solla-instituicao-impl.md`.
- Designs hi-fi (fonte de verdade do port):
`docs/plans/dossie-solla-instituicao-ui-design.html` e
`docs/plans/dossie-solla-instituicao-boletim-ui-design.html`.
- Skill irmã (dona do pipeline): `.agents/skills/dossie-solla-cidade/SKILL.md`.
`docs/plans/dossie-solla-instituicao-boletim-ui-design.html` — **desatualizados
desde a revisão de 2026-09-18** (carta, síntese, gráficos e folhas correntes
ainda não estão neles); um passe do `designer` precisa re-sincronizá-los.
- Skill irmã (dona do pipeline): `.agents/skills/dossie-solla-cidade/SKILL.md` —
o ramo cidade segue com caps; a adoção do fluxo corrente é decisão de produto.
- Scripts: `scripts/build-dossie-solla-instituicao.mjs`,
`scripts/extract-institution-snapshot.mjs`, `scripts/institutionSnapshot.mjs`,
`scripts/lib/dossie*.mjs`, `scripts/lib/buildPdf.mjs`,
`scripts/lib/readOnlyExtract.mjs`.
`scripts/lib/dossiePack.mjs` (packing grow/shrink),
`scripts/lib/buildPdf.mjs` (probe/medição/emit),
`scripts/lib/dossie*.mjs`, `scripts/lib/readOnlyExtract.mjs`.
- Catálogo: `src/lib/institutionCatalog.ts`, `src/lib/institutionNameAliases.ts`.
- Runbook de extração read-only: `docs/ops/teqo-1313-deploy.md` §C163.

View file

@ -0,0 +1,35 @@
---
description: Redator do dossiê Solla por cidade — 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-cidade)
mode: subagent
---
# Dossiê Solla por cidade — redator (abertura + parágrafos por era)
Você é a etapa **redação** da skill `.agents/skills/dossie-solla-cidade/SKILL.md`
(fonte canônica: contrato do `narrative.json`, regras editoriais e recibo). Leia-a
antes de escrever. Você é invocado **uma vez por cidade**, depois de as três eras
terem sido pesquisadas, pelo agente principal.
## Papel
1. Ler os três `data/dossie-solla-cidade/<slug>.<era>.research.json` (`items[]`
com `answer`/`details`/`brief`/`numbers`/`sphere`/`sourceUrl`/`sourceDate`;
`gaps[]` são lacunas explícitas).
2. Escrever `data/dossie-solla-cidade/<slug>.narrative.json` no contrato da
skill: `municipalitySlug`, `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 cidade e seu recorte regional.
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.
- `região` e `polo` **nunca** são somados ao município — 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).

View file

@ -34,6 +34,9 @@ slug e coordena as demais eras/cidades em paralelo.
orquestrador (`scripts/extract-city-report-snapshot.mjs` no homeserver,
`scripts/build-dossie-solla-cidade.mjs` local). Não faça `ssh`, `scp` nem
render.
- 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).

View file

@ -0,0 +1,36 @@
---
description: Redator do dossiê Solla por instituição — 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-instituicao)
mode: subagent
---
# Dossiê Solla por instituição — redator (abertura + parágrafos por era)
Você é a etapa **redação** da skill
`.agents/skills/dossie-solla-instituicao/SKILL.md` (fonte canônica: contrato do
`narrative.json`, regras editoriais e recibo). Leia-a antes de escrever. Você é
invocado **uma vez por instituição**, depois de as três eras terem sido
pesquisadas, pelo agente principal.
## Papel
1. Ler os três `data/dossie-solla-instituicao/<slug>.<era>.research.json`
(`items[]` com `answer`/`details`/`brief`/`numbers`/`sphere`/`sourceUrl`/
`sourceDate`; `gaps[]` são lacunas explícitas).
2. Escrever `data/dossie-solla-instituicao/<slug>.narrative.json` no contrato da
skill: `institutionSlug`, `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 instituição 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.
- `setor` e `rede` **nunca** são somados à instituição — 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).

View file

@ -33,6 +33,9 @@ eras/instituições em paralelo.
orquestrador (`scripts/extract-institution-snapshot.mjs` no homeserver,
`scripts/build-dossie-solla-instituicao.mjs` local). Não faça `ssh`, `scp` nem
render.
- 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).

View file

@ -2,7 +2,7 @@
description: Gera o dossiê Solla por cidade (PDF A4 + .md) e o Boletim Informativo modelo de 1 página para um município ou um lote separado por vírgula
---
Carregue a skill `dossie-solla-cidade` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os slugs canônicos, dispara um researcher por era (A/B/C) por cidade, roda a extração read-only, busca as fontes oficiais e o build local (dossiê + boletim) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-cidade/SKILL.md` — não a transcreva nem recrie o fluxo.
Carregue a skill `dossie-solla-cidade` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: o orquestrador parseia a lista (`$ARGUMENTS`, separada por vírgula), resolve os slugs canônicos, dispara um researcher por era (A/B/C) por cidade, dispara o redator (redação de abertura + parágrafos por era, auditados contra os itens), roda a extração read-only, busca as fontes oficiais e o build local (dossiê + boletim, folhas correntes sem caps) e fecha o summary. A fonte canônica é `.agents/skills/dossie-solla-cidade/SKILL.md` — não a transcreva nem recrie o fluxo.
Uso: `/dossie-solla-cidade <Cidade> [<Cidade>, ...]` — ex.: `/dossie-solla-cidade Ilhéus, Itacaré, Una`. Uma cidade = dossiê (`<slug>-<YYYY-MM-DD>-dossie.pdf`/`.md`) + boletim (`-boletim.pdf`).

View file

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

View file

@ -0,0 +1 @@
**C186/C187 — Dossiê corrente (sem caps) + redação/síntese/gráficos, com paridade cidade × instituição (2026-09-18):** revisão de produto dos dois dossiês Solla a partir do uso real (UFBA). (1) **Fim dos caps**: eras, abrangência/região, lacunas, notícias e acervo saem completos — cada seção flui por quantas folhas precisar, com o builder medindo a altura real de cada linha (`scripts/lib/dossiePack.mjs`: probe → pack → **grow/shrink medido** por folha até estabilizar; `scripts/lib/buildPdf.mjs` ganhou `measureDocumentPackProbe`/`measureDocumentSheets`), quebra só entre linhas e guarda de A4 no emit; as folhas de Era C foram de ~41–63% para 82–90% de ocupação e nenhuma página fica pela metade. (2) **Redação de abertura** (nova página "A contribuição"): sub-agente redator (`.opencode/agent/dossie-solla-{cidade,instituicao}-redacao.md`) escreve `<slug>.narrative.json` (título + 3 parágrafos + 1 por era) **somente** dos itens com fonte, com o orquestrador auditando citação por citação (um nome inventado foi pego e removido no primeiro uso); sem o arquivo, o builder cai na consolidação determinística. (3) **Parágrafo de consolidação por era** ("O que esta era entrega") do `narrative.json` ou determinístico. (4) **Síntese e gráficos consolidados** (novas páginas): leitura dos números com lastro (pontos por era/esfera, concentração, fases) e SVG print-safe de **recursos com execução por ano** (empilhado por fase), propostas/articulações sem fase, abrangência, trajetória, áreas, lacunas e painel do acervo — valores em R$ sempre com a fase e recortes nunca somados; o acervo é **amostra declarada** (N de M do recorte) com painel do total. (5) **Paridade**: o dono (`dossieBlocks`/`dossieRender`/`dossieUnit`) foi generalizado (síntese, páginas carta/síntese/gráficos, engine de eras com honrarias, folhas de lacunas/notícias/acervo e copy por unidade) e o ramo cidade adotou as mesmas decisões (23 páginas no smoke de Ilhéus); a cidade mantém arrays e a instituição os objetos capados, ambos normalizados por `listOf`. (6) Skills/comandos/agentes e specs atualizados (`dossieBatchSkill`/`dossieInstitutionBatchSkill` pinam "não usa caps", grow/shrink, contrato do `narrative.json`, recibos e os agentes redatores), designs hi-fi re-sincronizados pelo `designer` (14 cenas instituição, 16 cidade). Sem schema/migration/DB write; artefatos gitignored. `pnpm gate:fast` verde (354 arquivos / 3687 testes) + prettier/knip limpos.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,13 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { emitHtmlPairPdf, launchPdfBrowser } from './lib/buildPdf.mjs'
import {
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'
@ -26,7 +32,8 @@ import { renderBulletinHtml } from './lib/dossieBulletinRender.mjs'
import { CAMARA_DEFAULT_FROM, fetchCamaraActivity } from './lib/dossieCamara.mjs'
import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs'
import { fetchHealthData } from './lib/dossieHealthData.mjs'
import { renderDossierHtml, renderDossierMd } from './lib/dossieRender.mjs'
import { adjustPackPlan, packProbeSections } from './lib/dossiePack.mjs'
import { dossierPackAnchors, renderDossierHtml, renderDossierMd } from './lib/dossieRender.mjs'
import { mergeDossierResearch, normalizeDossierResearchInput } from './lib/dossieResearch.mjs'
import { DEFAULT_AUTHOR_NAME, fetchAuthorEmendas } from './lib/portalTransparenciaEmendas.mjs'
@ -165,14 +172,36 @@ console.log(
`[${LABEL}] câmara=${camara.status} ibge=${health.status} lacunas_pesquisa=${research.gaps.length}`,
)
const readNarrative = async () => {
const path = join(researchDir, `${slug}.narrative.json`)
try {
const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8'))
if (raw.municipalitySlug !== slug) {
die(`Redação é de "${raw.municipalitySlug}", 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.`,
)
}
/**
* Builds the dossiê + boletim for a given fit fallback. The resumo page never
* cuts with "…": pass 1 prints the researcher's `summary` (or the full answer);
* if a page overflows the A4 guard, pass 2 re-renders with
* `textFallback: 'pointer'` so the lines without a `summary` point to the era
* pages (which keep the integral). The guard is still fail-closed.
* Builds the dossiê + boletim for a given fit fallback and pack plan. The resumo
* page never cuts with "…": pass 1 prints the researcher's `summary` (or the
* full answer); if the resumo sheet overflows the A4 guard, pass 2 re-renders
* with `textFallback: 'pointer'` so the lines without a `summary` point to the
* era pages (which keep the integral). The other sections flow by measurement
* (`pack`), so they multiply sheets instead of being capped.
*/
const buildArtifacts = (textFallback) => {
const buildArtifacts = ({ textFallback = 'full', pack = null, probe = false } = {}) => {
const report = buildDossierReport({
snapshot,
research,
@ -181,6 +210,7 @@ const buildArtifacts = (textFallback) => {
health,
generatedAt,
textFallback,
narrative,
})
const bulletin = buildBulletin({
facts: report.bulletinFacts,
@ -191,15 +221,64 @@ const buildArtifacts = (textFallback) => {
return {
report,
bulletin,
dossierHtml: renderDossierHtml(report),
dossierHtml: renderDossierHtml(report, { pack, probe }),
dossierMd: renderDossierMd(report),
bulletinHtml: renderBulletinHtml(bulletin),
}
}
let artifacts = buildArtifacts('full')
/**
* 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 artifacts = buildArtifacts()
let pack = null
try {
const probeArtifacts = buildArtifacts({ probe: true })
const probe = await measureDocumentPackProbe(browser, probeArtifacts.dossierHtml)
const anchors = Object.fromEntries(
Object.entries(dossierPackAnchors(probeArtifacts.report.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) {
artifacts = buildArtifacts({ pack })
const sheets = await measureDocumentSheets(browser, artifacts.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
}
artifacts = buildArtifacts({ pack })
console.log(
`[${LABEL}] pack ${settled ? 'estável' : 'no teto de tentativas'}: ${Object.entries(pack)
.map(([key, sizes]) => `${key}=${sizes.join('+')}`)
.join(' ')}`,
)
await emitHtmlPairPdf(browser, {
dossierHtml: artifacts.dossierHtml,
bulletinHtml: artifacts.bulletinHtml,
@ -208,7 +287,7 @@ try {
// Budget-gated 2-pass (C188): if the resumo sheet with the full text
// overflows, rebuild with pointer lines (no "…") before failing closed.
onDossierOverflow: async () => {
artifacts = buildArtifacts('pointer')
artifacts = buildArtifacts({ textFallback: 'pointer', pack })
return artifacts.dossierHtml
},
resumoOnly: true,
@ -234,7 +313,7 @@ console.log(`[${LABEL}] PDF → ${dossierPdfFile}`)
console.log(`[${LABEL}] boletim PDF → ${bulletinPdfFile}`)
console.log(
`[${LABEL}] ${snapshot.municipality.name}: eras=${report.eras.length} entregas=${report.page1.deliveries.items.length} ` +
`highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} html_bytes=${Buffer.byteLength(dossierHtml)}`,
`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) {

View file

@ -20,13 +20,24 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { emitHtmlPairPdf, launchPdfBrowser } from './lib/buildPdf.mjs'
import {
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 { renderDossierHtml, renderDossierMd } from './lib/dossieRender.mjs'
import { adjustPackPlan, packProbeSections } from './lib/dossiePack.mjs'
import {
INSTITUTION_PACK_ANCHORS,
renderDossierHtml,
renderDossierMd,
} from './lib/dossieRender.mjs'
import { mergeDossierResearch, normalizeDossierResearchInput } from './lib/dossieResearch.mjs'
import { INSTITUTION_UNIT } from './lib/dossieUnit.mjs'
@ -113,11 +124,33 @@ if (snapshot.meta?.codeSha && currentCodeSha && snapshot.meta.codeSha !== curren
)
}
const readNarrative = async () => {
const path = join(researchDir, `${slug}.narrative.json`)
try {
const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8'))
if (raw.institutionSlug !== slug) {
die(`Redação é de "${raw.institutionSlug}", 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: INSTITUTION_UNIT,
narrative,
})
const bulletin = buildBulletin({
facts: report.bulletinFacts,
@ -126,14 +159,12 @@ const bulletin = buildBulletin({
generatedAt,
})
const dossierHtml = renderDossierHtml(report)
const dossierMd = renderDossierMd(report)
const bulletinHtml = renderBulletinHtml(bulletin)
const baseName = `${slug}-${generatedAt.toISOString().slice(0, 10)}`
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml)
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml)
await mkdir(resolve(ROOT, outDir), { recursive: true })
@ -143,8 +174,55 @@ 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(INSTITUTION_PACK_ANCHORS).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,
@ -161,7 +239,7 @@ console.log(`[${LABEL}] PDF → ${dossierPdfFile}`)
console.log(`[${LABEL}] boletim PDF → ${bulletinPdfFile}`)
console.log(
`[${LABEL}] ${report.meta.subjectName}: eras=${report.eras.length} entregas=${report.page1.deliveries.items.length} ` +
`highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} html_bytes=${Buffer.byteLength(dossierHtml)}`,
`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) {

View file

@ -47,6 +47,105 @@ const measureSheetOverflows = (page, anchor, budgetPx = A4_PAGE_BUDGET_PX) =>
{ max: budgetPx, selector: `[data-page="${anchor}"]` },
)
const openPrintPage = async (browser, html) => {
const page = await browser.newPage()
await page.setViewportSize({ width: A4_WIDTH_PX, height: A4_HEIGHT_PX })
await page.setContent(html, { waitUntil: 'load' })
await page.emulateMedia({ media: 'print' })
return page
}
/**
* Rendered sheets with their real height and used height (bottom of the last
* non-footer child) — the pack loop uses it to shrink overflowing sheets and
* grow sheets with room, so a page is never half empty and never cut.
*/
export const measureDocumentSheets = async (browser, html) => {
const page = await openPrintPage(browser, html)
try {
return await page.evaluate(() =>
[...document.querySelectorAll('[data-page]')].map((sheet) => {
const children = [...sheet.children].filter(
(child) => !child.classList.contains('report-footer'),
)
const used = children.reduce(
(max, child) =>
Math.max(max, child.offsetTop + Math.max(child.offsetHeight, child.scrollHeight)),
0,
)
return {
page: sheet.getAttribute('data-page'),
height: sheet.scrollHeight,
used,
}
}),
)
} finally {
await page.close()
}
}
/**
* Probe pass of the packed institution sections (C187 flowing sheets): each
* `[data-pack-section]` sheet carries every unit (`[data-pack-unit]` with
* `data-pack-layout` = tr|card); returns the fixed overhead and the row costs
* (a card row is a pair, its cost the taller card) the packer consumes.
*
* The probe renders with `INSTITUTION_PROBE_CSS` (auto height, no gaps), so the
* overhead is the true fixed content: headers, section titles, the era method
* (tagged `data-pack-fixed="method"`, absent from continuation sheets) and the
* footer. The packer adds the card-row gap back.
*/
const PACK_CARD_ROW_GAP_PX = Math.round(3 * MM_TO_PX)
export const measureDocumentPackProbe = async (browser, html) => {
const page = await openPrintPage(browser, html)
try {
return await page.evaluate((cardRowGap) => {
const buildRows = (units, gap) => {
const rows = []
for (let index = 0; index < units.length; index += 1) {
const unit = units[index]
const pair = units[index + 1]
if (unit.layout === 'card' && pair?.layout === 'card') {
rows.push({
units: [unit.index, pair.index],
cost: Math.max(unit.height, pair.height) + gap,
})
index += 1
} else {
rows.push({ units: [unit.index], cost: unit.height + gap })
}
}
return rows
}
return [...document.querySelectorAll('[data-pack-section]')].map((sheet) => {
const units = [...sheet.querySelectorAll('[data-pack-unit]')].map((element) => ({
index: Number(element.getAttribute('data-pack-unit')),
layout: element.getAttribute('data-pack-layout') ?? 'tr',
height: Math.max(element.scrollHeight, element.offsetHeight),
}))
// The probe renders with no gaps, so the overhead uses the gap-free
// rows; the packer's costs add the real card-row gap back.
const rowsHeight = buildRows(units, 0).reduce((total, row) => total + row.cost, 0)
const overhead = Math.max(0, sheet.scrollHeight - rowsHeight)
const methodHeight = [...sheet.querySelectorAll('[data-pack-fixed]')].reduce(
(total, element) => total + element.offsetHeight,
0,
)
return {
key: sheet.getAttribute('data-pack-section'),
overhead,
overheadContinuation: Math.max(0, overhead - methodHeight),
rows: buildRows(units, cardRowGap),
}
})
}, PACK_CARD_ROW_GAP_PX)
} finally {
await page.close()
}
}
/** Throws with the list of overflowing pages — callers turn it into `die`. */
export const assertPageFits = (overflows, label, hint) => {
if (overflows.length === 0) return
@ -86,9 +185,7 @@ export const emitHtmlPairPdf = async (
resumoOnly = false,
},
) => {
const page = await browser.newPage()
await page.setViewportSize({ width: A4_WIDTH_PX, height: A4_HEIGHT_PX })
const page = await openPrintPage(browser, dossierHtml)
let currentDossierHtml = dossierHtml
let triedFallback = false
for (;;) {

View file

@ -23,14 +23,14 @@ import {
} from './dossieUnit.mjs'
import { capList, stripInlineSources, summarySurfaceText } from './reportText.mjs'
/**
* Only the resumo ("one look") caps its lists; the full sections flow across as
* many sheets as they need (the builder packs by measured height) — C186/C187
* revision of 2026-09-18, same rule for both recortes.
*/
const MAX_DELIVERIES_PAGE_ONE = 3
const MAX_HOOKS_PAGE_ONE = 2
const MAX_PENDING_PAGE_ONE = 4
const MAX_ERA_NUMBERS = 6
/** Curated research actions are all kept (the renderer paginates them); only the generic Câmara list is capped. */
const MAX_ERA_CAMARA_ACTIONS = 4
const MAX_REGION_ITEMS = 3
const MAX_SCOPE_LIST = 3
/** @param {string} sphere @param {any} [unit] @returns {string} */
export const dossierSphereLabel = (sphere, unit = MUNICIPALITY_UNIT) =>
@ -99,6 +99,27 @@ const itemNumberRows = (items) => {
return rows
}
/**
* BRL amounts written in the research copy ("R$ 1,2 mi", "R$ 1.299.027,30",
* "R$ 150 mil"). Returns null for non-money values ("175" vagas/leitos), so a
* count never enters a money chart.
*/
const parseMoneyBrl = (value) => {
if (typeof value !== 'string') return null
const match = /^R\$\s*([\d.,]+)\s*(mil|milh\S*|mi)?/i.exec(value.trim())
if (!match) return null
const amount = Number(match[1].replace(/\./g, '').replace(',', '.'))
if (!Number.isFinite(amount)) return null
const suffix = (match[2] ?? '').toLowerCase()
if (suffix === 'mil') return amount * 1e3
if (suffix) return amount * 1e6
return amount
}
const moneyRows = (numberRows) =>
numberRows
.map((row) => ({ ...row, amount: parseMoneyBrl(row.value) }))
.filter((row) => row.amount !== null)
const eraRecovery = {
A: 'Biografia Câmara · DOU/acervo do Ministério da Saúde · pesquisa web datada',
B: 'DOE-BA (DOOL) · notícias SESAB · Transparência Bahia · SIOPS/DATASUS',
@ -138,7 +159,7 @@ const camaraItemsAsActions = (camara, era) =>
* @property {any} cover
* @property {any} page1
* @property {any[]} trajectory
* @property {Array<{ id: string, label: string, period: string, subtitle: string, empty?: boolean, method: string, recovery: string, numbers: { items: any[], total: number, remaining: number }, actions: any[], camaraActionsRemaining: number, honors: any[] }>} eras
* @property {Array<{ id: string, label: string, period: string, subtitle: string, empty?: boolean, method: string, recovery: string, numbers: any, actions: any, honors: any }>} eras
* @property {{
* ruleTitle: string,
* ruleBody: string,
@ -153,11 +174,27 @@ const camaraItemsAsActions = (camara, era) =>
* ruleTitle: string,
* ruleBody: string,
* lists: Array<{ key: string, label: string, sphere: string, items: any[], total: number, omitted: number }>,
* evidence: { items: any[], total: number, omitted: number },
* hook: any,
* priorityGap: any,
* }} reach
* @property {{ items: any[], total: number, omitted: number }} acervo
* @property {{ items: any[], total: number, omitted: number, recorte?: number, topics?: string[] }} acervo
* @property {{
* byEra: any[],
* byEraSphere: any[],
* bySphere: any[],
* byPhase: any[],
* byArea: any[],
* byYear: any[],
* acervoByYear: any[],
* gapsByEra: any[],
* moneyByPhase: any[],
* moneyByYear: any[],
* moneyProposals: any[],
* moneyTotals: any,
* totals: any,
* lines: string[],
* }} [synthesis]
* @property {{ title: string, paragraphs: string[], authored: boolean }} [opening]
* @property {any[]} gaps
* @property {any[]} news
* @property {any} limits
@ -178,12 +215,12 @@ const camaraItemsAsActions = (camara, era) =>
* health?: any,
* generatedAt?: Date,
* unit?: any,
* narrative?: any,
* }} params
* @returns {DossierReport}
*/
export const buildDossierReport = (params) =>
isInstitutionUnit(params?.unit) ? buildInstitutionReport(params) : buildMunicipalityReport(params)
const buildMunicipalityReport = ({
snapshot,
research,
@ -192,6 +229,7 @@ const buildMunicipalityReport = ({
health = null,
generatedAt = new Date(),
textFallback = 'full',
narrative = null,
}) => {
const municipality = snapshot.municipality ?? {}
const municipalityName = municipality.name ?? municipality.slug ?? 'Município'
@ -221,14 +259,11 @@ const buildMunicipalityReport = ({
}))
const eraSections = DOSSIER_ERAS.map((era) => {
const eraItems = items.filter((item) => item.era === era.id)
const ownActions = researchActionsByEra(era.id)
const extraActions = era.id === 'C' ? camaraActions : []
const numbers = capList(
era.id === 'C'
? [...eraRows, ...itemNumberRows(items.filter((i) => i.era === 'C'))]
: itemNumberRows(items.filter((i) => i.era === era.id)),
MAX_ERA_NUMBERS,
)
const numbers =
era.id === 'C' ? [...eraRows, ...itemNumberRows(eraItems)] : itemNumberRows(eraItems)
const honors =
era.id === 'C'
? items
@ -239,19 +274,25 @@ const buildMunicipalityReport = ({
sourceUrl: item.sourceUrl,
}))
: []
// Curated research actions are all kept (the renderer paginates them into
// continuation sheets); only the generic Câmara list is capped.
const camaraActionsCapped = capList(extraActions, MAX_ERA_CAMARA_ACTIONS)
const actions = [...ownActions, ...camaraActionsCapped.items]
const camaraActionsRemaining = camaraActionsCapped.remaining
if (numbers.items.length === 0 && actions.length === 0) return null
const actions = [...ownActions, ...extraActions]
if (numbers.length === 0 && actions.length === 0 && honors.length === 0) return null
return {
...era,
method: eraMethod[era.id],
recovery: eraRecovery[era.id],
summary: buildEraSummary({
era,
items: eraItems,
money: moneyRows(numbers),
scopeLabel: 'no recorte municipal',
unit: MUNICIPALITY_UNIT,
}),
narrative:
typeof narrative?.eras?.[era.id] === 'string' && narrative.eras[era.id].trim()
? narrative.eras[era.id].trim()
: null,
numbers,
actions,
camaraActionsRemaining,
honors,
}
}).filter(Boolean)
@ -306,16 +347,38 @@ const buildMunicipalityReport = ({
)
const context = healthContext(health)
const regionalEvidence = capList(
regionalItems.map((item) => ({
item: item.answer,
sphere: item.sphere,
evidence: item.details ?? item.label,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
const regionItems = regionalItems.map((item) => ({
item: item.answer,
sphere: item.sphere,
evidence: item.details ?? item.label,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
}))
const speechFacts = speechFactsFromRows(snapshot, 'municipio')
const acervo = {
items: speechFacts.map((fact) => ({
id: fact.id,
period: fact.year ?? '—',
text: fact.headline,
excerpt: fact.detail,
sourceUrl: fact.sourceUrl,
})),
MAX_REGION_ITEMS,
)
total: speechFacts.length,
omitted: 0,
/** The municipality-tagged recorte in the base (the extractor brings the latest sample). */
recorte: snapshot.speeches?.totalCount ?? speechFacts.length,
topics: snapshot.speeches?.topics?.themes ?? [],
}
const synthesis = buildDossierSynthesis({
items,
numberRows: [...eraRows, ...itemNumberRows(items)],
speechFacts,
gaps: research.gaps ?? [],
acervoTotal: snapshot.speeches?.totalCount ?? speechFacts.length,
scopeLabel: 'no recorte municipal',
unit: MUNICIPALITY_UNIT,
})
const bulletinFacts = [
...items
@ -356,12 +419,26 @@ const buildMunicipalityReport = ({
sourceUrl: row.sourceUrl,
sourceDate: row.sourceDate,
})),
...speechFacts.map((fact) => ({ ...fact, headline: stripInlineSources(fact.headline) })),
]
const generatedAtLabel = formatDateTimeBr(generatedAt)
const readAtLabel = snapshot.meta?.readAt ? formatDateTimeBr(snapshot.meta.readAt) : '—'
const narrativeParagraphs = Array.isArray(narrative?.opening)
? narrative.opening.filter((paragraph) => typeof paragraph === 'string' && paragraph.trim())
: []
const opening = {
title:
typeof narrative?.title === 'string' && narrative.title.trim()
? narrative.title.trim()
: `O que Jorge Solla fez por ${municipalityName} e pela região`,
paragraphs: narrativeParagraphs.length > 0 ? narrativeParagraphs : [synthesis.lines.join(' ')],
authored: narrativeParagraphs.length > 0,
}
return {
unit: MUNICIPALITY_UNIT,
meta: {
title: 'Dossiê Solla por cidade',
municipalityName,
@ -393,18 +470,32 @@ const buildMunicipalityReport = ({
},
trajectory: CAREER_TIMELINE,
eras: eraSections,
acervo,
synthesis,
opening,
region: {
ruleTitle: 'Região não é cidade. Não some os dois recortes.',
ruleBody:
'Um equipamento de referência ou uma política regional pode atender moradores do município sem constituir entrega exclusiva para ele. Atribuição só entra com alcance documentado.',
municipal: { label: 'município', ...capList(municipalItems, MAX_SCOPE_LIST) },
regional: { label: 'região / polo', ...capList(regionalItems, MAX_SCOPE_LIST) },
evidence: regionalEvidence,
municipal: {
label: 'município',
items: municipalItems,
total: municipalItems.length,
},
regional: {
label: 'região / polo',
items: regionalItems,
total: regionalItems.length,
},
items: regionItems,
context,
hook: hooks.items[0] ?? null,
priorityGap: pending.items[0] ?? null,
},
gaps,
acervo,
synthesis,
opening,
news: (research.news ?? []).map((row) => ({
date: row.publishedAt,
outlet: row.outlet ?? '—',
@ -432,13 +523,16 @@ const buildMunicipalityReport = ({
* Institution branch (C187) — same owner, institution-shaped sections. *
* ------------------------------------------------------------------ */
/**
* The resumo sheet is the "one look": its lists stay capped and the note points
* to the full sections, which are never capped (they flow across sheets). The
* acervo is a sample of the internal base — a source panel, not a finding — so
* its count and year chart carry the whole volume.
*/
const MAX_INSTITUTION_DELIVERIES = 3
const MAX_INSTITUTION_TIMELINE = 3
const MAX_INSTITUTION_HOOKS = 2
const MAX_INSTITUTION_PENDING = 4
const MAX_INSTITUTION_SCOPE_LIST = 5
const MAX_INSTITUTION_EVIDENCE = MAX_INSTITUTION_SCOPE_LIST * 2
const MAX_INSTITUTION_ACERVO = 6
const INSTITUTION_SCOPE =
'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara dos Deputados 2015–2027. Instituição, setor e rede separados; sem fonte, não entra como entrega.'
@ -466,6 +560,13 @@ const capped = (list, max) => {
return { items, total, omitted: remaining }
}
/**
* Institution lists are never capped: the dossiê flows across as many sheets
* as the content needs (the builder packs sheets by measured height). Same
* shape as `capped` so the renderers stay uniform.
*/
const uncapped = (list) => ({ items: list, total: list.length, omitted: 0 })
const identityBadges = (identity) =>
[identity.kindLabel, identity.sphereLabel, identity.scope ? identity.scopeLabel : null].filter(
Boolean,
@ -473,16 +574,16 @@ const identityBadges = (identity) =>
/**
* Speeches from the internal acervo (read-only snapshot) that the research did
* not already carry: they are sourced evidence for the institution recorte, so
* they feed the bulletin ledger too — never a second, unsourced fact.
* not already carry: they are sourced evidence for the recorte, so they feed
* the bulletin ledger too — never a second, unsourced fact.
*/
const institutionSpeechFacts = (snapshot) =>
const speechFactsFromRows = (snapshot, sphere = 'instituicao') =>
(snapshot.speeches?.rows ?? [])
.filter((row) => Boolean(row.officialTextUrl || row.youtubeUrl || row.vodPlaybackUrl))
.map((row) => ({
id: `fala-${row.id}`,
era: 'C',
sphere: 'instituicao',
sphere,
area: 'Acervo de falas',
headline:
row.summary ??
@ -497,21 +598,303 @@ const institutionSpeechFacts = (snapshot) =>
sourceDate: row.speechAt ?? null,
}))
const buildInstitutionReport = ({ snapshot, research, generatedAt = new Date() }) => {
/* ---------------------------------------------------------------- *
* Dossier synthesis (C186/C187 analysis layer) — deterministic *
* aggregation of the sourced rows; it introduces no fact. *
* ---------------------------------------------------------------- */
const SYNTHESIS_PHASE_ORDER = [
'empenhado',
'liquidado',
'pago',
'autorizado',
'restos',
'nao_informado',
]
const countBy = (list, keyOf) => {
const counts = new Map()
for (const entry of list) {
const key = keyOf(entry)
if (key === null || key === undefined || key === '') continue
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return counts
}
const toRows = (counts, rank = null) =>
[...counts]
.map(([key, count]) => ({ key, count }))
.sort((left, right) =>
rank
? (rank.get(left.key) ?? rank.size) - (rank.get(right.key) ?? rank.size)
: right.count - left.count || String(left.key).localeCompare(String(right.key)),
)
const chronologicalRows = (counts) =>
[...counts]
.map(([key, count]) => ({ key, count }))
.sort((left, right) => String(left.key).localeCompare(String(right.key)))
const itemYear = (item) =>
item.numbers?.[0]?.year ?? (item.sourceDate ? String(item.sourceDate).slice(0, 4) : null)
const gapEraKey = (gap) => {
const match = /^era_([abc])_/i.exec(gap.id ?? '')
return match ? match[1].toUpperCase() : 'fora'
}
const buildDossierSynthesis = ({
items,
numberRows,
speechFacts,
gaps,
acervoTotal,
scopeLabel,
unit = INSTITUTION_UNIT,
}) => {
const byEra = toRows(
countBy(items, (item) => item.era),
new Map([
['A', 0],
['B', 1],
['C', 2],
]),
).map((row) => ({ key: row.key, label: `Era ${row.key}`, count: row.count }))
const byEraSphere = byEra.map((era) => ({
key: era.key,
label: era.label,
segments: unit.spheres.map((sphere) => ({
key: sphere,
label: unit.sphereLabels[sphere],
count: items.filter((item) => item.era === era.key && item.sphere === sphere).length,
})),
}))
const bySphere = unit.spheres.map((sphere) => ({
key: sphere,
label: unit.sphereLabels[sphere],
count: items.filter((item) => item.sphere === sphere).length,
}))
const byPhase = toRows(
countBy(numberRows, (row) => row.phase),
new Map(SYNTHESIS_PHASE_ORDER.map((phase, index) => [phase, index])),
).map((row) => ({ key: row.key, label: dossierPhaseLabel(row.key), count: row.count }))
const byArea = toRows(countBy(items, (item) => item.area))
const byYear = chronologicalRows(countBy(items, itemYear))
const acervoByYear = chronologicalRows(countBy(speechFacts, (fact) => fact.year))
const gapsByEra = toRows(
countBy(gaps, gapEraKey),
new Map([
['A', 0],
['B', 1],
['C', 2],
['fora', 3],
]),
).map((row) => ({
key: row.key,
label: row.key === 'fora' ? 'Fora de era' : `Era ${row.key}`,
count: row.count,
}))
const money = moneyRows(numberRows)
const sumMoney = (rows) => rows.reduce((sum, row) => sum + row.amount, 0)
const moneyByPhase = toRows(
countBy(money, (row) => row.phase),
new Map(SYNTHESIS_PHASE_ORDER.map((phase, index) => [phase, index])),
).map((row) => ({
key: row.key,
label: dossierPhaseLabel(row.key),
count: row.count,
amount: sumMoney(money.filter((entry) => entry.phase === row.key)),
}))
const moneyByYear = chronologicalRows(countBy(money, (row) => row.year)).map((row) => {
const rows = money.filter((entry) => entry.year === row.key)
return {
key: row.key,
count: row.count,
amount: sumMoney(rows),
label: formatMoneyCompact(sumMoney(rows)),
segments: SYNTHESIS_PHASE_ORDER.map((phase) => ({
key: phase,
amount: sumMoney(rows.filter((entry) => entry.phase === phase)),
})).filter((segment) => segment.amount > 0),
}
})
const executedMoney = money.filter((row) => row.phase === 'pago' || row.phase === 'liquidado')
const authorizedMoney = money.filter(
(row) => row.phase === 'autorizado' || row.phase === 'empenhado',
)
const proposedMoney = money.filter(
(row) => row.phase === 'nao_informado' || row.phase === 'restos',
)
const moneyTotals = {
executed: sumMoney(executedMoney),
authorized: sumMoney(authorizedMoney),
proposed: sumMoney(proposedMoney),
total: sumMoney(money),
count: money.length,
}
const moneyProposals = [...proposedMoney]
.sort((left, right) => right.amount - left.amount)
.map((row) => ({
key: `${row.object}-${row.year ?? 's/ano'}`,
label: row.object,
year: row.year,
amount: row.amount,
formatted: formatMoneyCompact(row.amount),
}))
const topEra = toRows(countBy(items, (item) => item.era))[0] ?? null
const topYears = [...acervoByYear].sort((left, right) => right.count - left.count).slice(0, 3)
const lines = []
if (items.length > 0) {
lines.push(
`Foram localizados ${items.length} pontos com fonte ${scopeLabel} — ${byEra
.map((row) => `${row.label}: ${row.count}`)
.join(' · ')}.`,
)
lines.push(
`Abrangência: ${bySphere.map((row) => `${row.count} ${row.label}`).join(' · ')}. Setor e rede não são somados à instituição.`,
)
if (topEra) {
lines.push(
`Concentração: a Era ${topEra.key} reúne ${topEra.count} de ${items.length} pontos com fonte (${Math.round((topEra.count / items.length) * 100)}%).`,
)
}
lines.push(
`Itens com valor: ${numberRows.length}${
byPhase.length ? ` — ${byPhase.map((row) => `${row.count} ${row.label}`).join(' · ')}` : ''
}. 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`)
lines.push(`Recursos localizados: ${parts.join(' · ')}.`)
}
if (byArea.length) {
lines.push(
`Temas com mais registros: ${byArea
.slice(0, 3)
.map((row) => `${row.key} (${row.count})`)
.join(' · ')}.`,
)
}
} else {
lines.push('Nenhum ponto com fonte foi localizado — a leitura é a das lacunas explícitas.')
}
if (speechFacts.length) {
lines.push(
`Acervo interno (read-only, 2011+): ${acervoTotal} falas no recorte temático — amostra de ${speechFacts.length} com link${
topYears.length > 1
? `; pico em ${topYears.map((row) => `${row.key} (${row.count})`).join(', ')}`
: ''
}.`,
)
}
if (gaps.length) {
const priority = gaps[0]?.label ?? gaps[0]?.id ?? '—'
lines.push(
`Lacunas declaradas: ${gaps.length}${
gapsByEra.length
? ` — ${gapsByEra.map((row) => `${row.label}: ${row.count}`).join(' · ')}`
: ''
}. Prioridade: ${priority}.`,
)
}
return {
byEra,
byEraSphere,
bySphere,
byPhase,
byArea,
byYear,
acervoByYear,
gapsByEra,
moneyByPhase,
moneyByYear,
moneyProposals,
moneyTotals,
totals: {
items: items.length,
numbers: numberRows.length,
money: money.length,
acervo: speechFacts.length,
acervoRecorte: acervoTotal,
gaps: gaps.length,
},
lines,
}
}
/**
* One deterministic consolidation paragraph per era — the renderer prefers the
* narrative file's own paragraph when the research copy provides one.
*/
const buildEraSummary = ({ era, items, money, scopeLabel, unit = INSTITUTION_UNIT }) => {
if (items.length === 0) return null
const spheres = unit.spheres.map((sphere) => ({
sphere,
label: unit.sphereLabels[sphere],
count: items.filter((item) => item.sphere === sphere).length,
}))
const areas = toRows(countBy(items, (item) => item.area))
const years = items
.map(itemYear)
.filter(Boolean)
.sort((left, right) => String(left).localeCompare(String(right)))
const period = years.length ? `${years[0]}–${years[years.length - 1]}` : era.period
const executed = money
.filter((row) => row.phase === 'pago' || row.phase === 'liquidado')
.reduce((sum, row) => sum + row.amount, 0)
const proposed = money
.filter((row) => row.phase === 'nao_informado')
.reduce((sum, row) => sum + row.amount, 0)
const parts = [
`A ${era.label} reúne ${items.length} ${items.length === 1 ? 'ponto' : 'pontos'} com fonte ${scopeLabel} (${period}): ${spheres
.map((row) => `${row.count} ${row.label}`)
.join(' · ')} — recortes não somáveis.`,
]
if (areas.length) {
parts.push(
`Concentração em ${areas
.slice(0, 3)
.map((row) => `${row.key} (${row.count})`)
.join(', ')}.`,
)
}
if (executed || proposed) {
const moneyParts = []
if (executed) moneyParts.push(`${formatMoneyCompact(executed)} com execução confirmada`)
if (proposed) moneyParts.push(`${formatMoneyCompact(proposed)} em proposta sem fase informada`)
parts.push(`Recursos: ${moneyParts.join(' · ')} — empenho não é pagamento.`)
}
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 = institutionSpeechFacts(snapshot)
const speechFacts = speechFactsFromRows(snapshot)
const items = research.items ?? []
const inScope = (sphere) => items.filter((item) => item.sphere === sphere)
const directItems = inScope('instituicao')
const sectorItems = inScope('setor')
const networkItems = inScope('rede')
const directItems = items.filter((item) => item.sphere === 'instituicao')
/** Honors live in their own sheet; they are not repeated as era action cards. */
const researchActionsByEra = (era) =>
items
.filter((item) => item.era === era)
.filter((item) => item.era === era && item.id !== 'era_c_titulos')
.map((item) => ({
sphere: item.sphere,
year: item.numbers?.[0]?.year ?? null,
@ -523,29 +906,41 @@ const buildInstitutionReport = ({ snapshot, research, generatedAt = new Date() }
}))
const eraSections = DOSSIER_ERAS.map((era) => {
const numbers = capped(
itemNumberRows(items.filter((item) => item.era === era.id)),
MAX_ERA_NUMBERS,
const eraItems = items.filter((item) => item.era === era.id)
const eraNumberRows = itemNumberRows(eraItems)
const numbers = uncapped(eraNumberRows)
const actions = uncapped(researchActionsByEra(era.id))
const honors = uncapped(
items
.filter((item) => item.era === era.id && item.id === 'era_c_titulos')
.map((item) => ({
text: item.answer,
detail: item.details ?? null,
brief: item.brief ?? null,
date: item.sourceDate ?? null,
year: item.numbers?.[0]?.year ?? null,
sourceUrl: item.sourceUrl,
})),
)
const actions = capped(researchActionsByEra(era.id), MAX_ERA_CAMARA_ACTIONS)
const honors = items
.filter((item) => item.era === era.id && item.id === 'era_c_titulos')
.map((item) => ({
text: item.answer,
detail: item.details ?? null,
brief: item.brief ?? null,
date: item.sourceDate ?? null,
year: item.numbers?.[0]?.year ?? null,
sourceUrl: item.sourceUrl,
}))
return {
...era,
method: institutionEraMethod[era.id],
recovery: institutionEraRecovery[era.id],
summary: buildEraSummary({
era,
items: eraItems,
money: moneyRows(eraNumberRows),
scopeLabel: 'no recorte institucional',
unit,
}),
narrative:
typeof narrative?.eras?.[era.id] === 'string' && narrative.eras[era.id].trim()
? narrative.eras[era.id].trim()
: null,
numbers,
actions,
honors,
empty: numbers.items.length === 0 && actions.items.length === 0,
empty: numbers.items.length === 0 && actions.items.length === 0 && honors.items.length === 0,
}
})
@ -580,16 +975,20 @@ const buildInstitutionReport = ({ snapshot, research, generatedAt = new Date() }
}))
const hooks = capped(hookList, MAX_INSTITUTION_HOOKS)
const acervo = capped(
speechFacts.map((fact) => ({
const acervo = {
items: speechFacts.map((fact) => ({
id: fact.id,
period: fact.year ?? '—',
text: fact.headline,
excerpt: fact.detail,
sourceUrl: fact.sourceUrl,
})),
MAX_INSTITUTION_ACERVO,
)
total: speechFacts.length,
omitted: 0,
/** The theme recorte in the base (the extractor brings the latest sample). */
recorte: snapshot.speeches?.totalCount ?? speechFacts.length,
topics: snapshot.speeches?.topics ?? [],
}
const timelineList = items
.filter((item) => item.sourceUrl)
@ -613,23 +1012,40 @@ const buildInstitutionReport = ({ snapshot, research, generatedAt = new Date() }
const scopeItem = (sphere, key, label) => {
const sphereItems = items.filter((item) => item.sphere === sphere)
return { key, label, sphere, ...capped(sphereItems, MAX_INSTITUTION_SCOPE_LIST) }
return {
key,
label,
sphere,
...uncapped(
sphereItems.map((item) => ({
id: item.id,
sphere: item.sphere,
area: item.area,
year: item.numbers?.[0]?.year ?? (item.sourceDate ?? '').slice(0, 4) ?? null,
title: item.answer,
brief: item.brief ?? null,
evidence: item.details ?? item.label,
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
})),
),
}
}
const scopeLists = [
scopeItem('instituicao', 'institution', unit.sphereLabels.instituicao),
scopeItem('setor', 'sector', unit.sphereLabels.setor),
scopeItem('rede', 'network', unit.sphereLabels.rede),
]
const scopeEvidence = capped(
[...sectorItems, ...networkItems].map((item) => ({
item: item.answer,
sphere: item.sphere,
evidence: item.details ?? item.label,
brief: item.brief ?? null,
sourceUrl: item.sourceUrl,
})),
MAX_INSTITUTION_EVIDENCE,
)
const synthesis = buildDossierSynthesis({
items,
numberRows: itemNumberRows(items),
speechFacts,
gaps: research.gaps ?? [],
acervoTotal: snapshot.speeches?.totalCount ?? speechFacts.length,
scopeLabel: 'no recorte institucional',
unit,
})
const bulletinFacts = items
.filter((item) => Boolean(item.sourceUrl))
@ -658,6 +1074,18 @@ const buildInstitutionReport = ({ snapshot, research, generatedAt = new Date() }
const generatedAtLabel = formatDateTimeBr(generatedAt)
const readAtLabel = snapshot.meta?.readAt ? formatDateTimeBr(snapshot.meta.readAt) : '—'
const narrativeParagraphs = Array.isArray(narrative?.opening)
? narrative.opening.filter((paragraph) => typeof paragraph === 'string' && paragraph.trim())
: []
const opening = {
title:
typeof narrative?.title === 'string' && narrative.title.trim()
? narrative.title.trim()
: `O que Jorge Solla fez pela e na ${subjectName}`,
paragraphs: narrativeParagraphs.length > 0 ? narrativeParagraphs : [synthesis.lines.join(' ')],
authored: narrativeParagraphs.length > 0,
}
return {
unit,
meta: {
@ -669,6 +1097,7 @@ const buildInstitutionReport = ({ snapshot, research, generatedAt = new Date() }
generatedAtLabel,
readAtLabel,
},
opening,
cover: {
kicker: 'Comunicação · pesquisa documental',
series: unit.series,
@ -686,16 +1115,19 @@ const buildInstitutionReport = ({ snapshot, research, generatedAt = new Date() }
trajectory: CAREER_TIMELINE,
eras: eraSections,
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.',
lists: scopeLists,
evidence: scopeEvidence,
hook: hooks.items[0] ?? null,
priorityGap: pending.items[0] ?? null,
},
gaps,
acervo,
synthesis,
opening,
news: (research.news ?? []).map((row) => ({
date: row.publishedAt,
outlet: row.outlet ?? '—',

View file

@ -11,7 +11,7 @@ import { BULLETIN_TIMELINE_STEPS } from './dossieCareer.mjs'
import { resolveDossierUnit } from './dossieUnit.mjs'
export const BULLETIN_HIGHLIGHT_LIMIT = 6
export const BULLETIN_MORE_LIMIT = 14
const BULLETIN_MORE_LIMIT = 14
const eraRank = { C: 0, B: 1, A: 2 }
@ -58,7 +58,10 @@ export const buildBulletin = ({
phaseLabel: fact.value ? dossierPhaseLabel(fact.phase) : null,
}))
const moreItems = ordered
.slice(BULLETIN_HIGHLIGHT_LIMIT, BULLETIN_HIGHLIGHT_LIMIT + BULLETIN_MORE_LIMIT)
.slice(
BULLETIN_HIGHLIGHT_LIMIT,
BULLETIN_HIGHLIGHT_LIMIT + (resolvedUnit.bulletinMoreLimit ?? BULLETIN_MORE_LIMIT),
)
.map((fact) => ({
label: fact.brief?.title ?? fact.headline,
detail: fact.brief?.note ?? fact.detail ?? fact.value ?? fact.area,

121
scripts/lib/dossiePack.mjs Normal file
View file

@ -0,0 +1,121 @@
/**
* Packing of the institution dossiê sheets (C187 flow). The probe pass renders
* each packed section as a single sheet and measures the real height of every
* unit; the packer turns those measurements into a plan (`sectionKey -> units
* per sheet`) so a section spans as many sheets as it needs and nothing is
* capped.
*
* A "row" is one packable line: a full-width unit (table row, list item) or a
* grid pair (two cards share the row height). Rows never split across sheets.
*
* The first plan is greedy over the probe costs; the builder then re-renders
* and measures each sheet, and `adjustPackPlan` moves rows between sheets until
* every page is full without overflowing (no waste, no cut).
*/
/**
* Per-sheet reserve for the block margins the tight probe zeroes out
* (`.block` 3.4mm, `.action-grid`/`.document-table` top margins). The A4 fit
* guard still catches any residue; the reserve keeps a first pack from
* overflowing.
*/
const PACK_SHEET_RESERVE_PX = 80
/** Extra room required to grow a sheet (the moved row may open a new section head). */
const GROW_SAFETY_PX = 80
/**
* @param {Array<{ key: string, overhead: number, overheadContinuation?: number, rows: Array<{ units: number[], cost: number }> }>} probe
* @param {number} budgetPx
* @param {number} [reservePx]
* @returns {Record<string, number[]>}
*/
export const packProbeSections = (probe, budgetPx, reservePx = PACK_SHEET_RESERVE_PX) => {
const plan = {}
const sheetBudget = Math.max(1, budgetPx - reservePx)
for (const section of probe) {
const usableFirst = Math.max(1, sheetBudget - section.overhead)
const usableRest = Math.max(1, sheetBudget - (section.overheadContinuation ?? section.overhead))
const chunks = []
let current = []
let used = 0
for (const row of section.rows) {
const usable = chunks.length === 0 ? usableFirst : usableRest
if (current.length > 0 && used + row.cost > usable) {
chunks.push(current)
current = []
used = 0
}
current.push(...row.units)
used += row.cost
}
chunks.push(current)
plan[section.key] = chunks.map((chunk) => chunk.length)
}
return plan
}
/**
* One grow/shrink pass over a rendered plan: an overflowing sheet gives its
* last row to the next chunk; a sheet with room takes the first row of the next
* chunk. Pure — the builder re-renders and re-measures between passes until the
* plan stops changing. A sheet that ever overflowed is locked (never grown
* again), which keeps the loop from oscillating.
*
* @param {{
* plan: Record<string, number[]>,
* probe: Array<{ key: string, rows: Array<{ units: number[], cost: number }> }>,
* sheets: Array<{ page: string, height: number, used: number }>,
* anchors: Record<string, string>,
* budgetPx: number,
* lockedAnchors?: Set<string>,
* }} params
* @returns {Record<string, number[]> | null}
*/
export const adjustPackPlan = ({
plan,
probe,
sheets,
anchors,
budgetPx,
lockedAnchors = new Set(),
}) => {
const byAnchor = new Map(sheets.map((sheet) => [sheet.page, sheet]))
let changed = false
const next = { ...plan }
for (const section of probe) {
const base = anchors[section.key]
const sizes = [...(plan[section.key] ?? [])]
if (!base || sizes.length === 0) continue
const rows = section.rows
let offset = 0
for (let index = 0; index < sizes.length; index += 1) {
const start = offset
const end = offset + sizes[index]
offset = end
const anchor = index === 0 ? base : `${base}-${index + 1}`
const sheet = byAnchor.get(anchor)
if (!sheet) continue
const rowsInChunk = rows.filter((row) => row.units[0] >= start && row.units[0] < end)
if (sheet.height > budgetPx) {
if (rowsInChunk.length <= 1) continue
const last = rowsInChunk[rowsInChunk.length - 1]
sizes[index] -= last.units.length
sizes[index + 1] = (sizes[index + 1] ?? 0) + last.units.length
changed = true
continue
}
if (lockedAnchors.has(anchor)) continue
const nextRow = rows.find((row) => row.units[0] === end)
if (!nextRow || sizes[index + 1] === undefined) continue
const free = budgetPx - sheet.used
if (free < nextRow.cost + GROW_SAFETY_PX) continue
sizes[index] += nextRow.units.length
sizes[index + 1] -= nextRow.units.length
if (sizes[index + 1] <= 0) sizes.splice(index + 1, 1)
changed = true
}
next[section.key] = sizes
}
return changed ? next : null
}

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,16 @@ export const MUNICIPALITY_UNIT = {
series: 'Série municipal · BA',
title: 'Dossiê Solla por cidade',
bulletinTitle: 'Boletim informativo modelo',
/** "E mais" budget of the one-page boletim (long labels wrap, so units differ). */
bulletinMoreLimit: 12,
/** Print copy that changes with the recorte (shared renderers read these). */
sphereColumnLabel: 'Esfera',
notSummedInline: 'não somar à cidade',
sumGuardNote: 'região e polo não são somados ao município.',
acervoNote: 'recorte municipal (menção confirmada); região e polo não entram nesta conta.',
eraNumbersTitle: 'Recursos e entregas quantificáveis',
eraActionsTitle: 'O que fez — item, alcance e lastro',
honorsTitle: 'Títulos, honrarias e vínculos locais',
}
export const INSTITUTION_UNIT = {
@ -40,6 +50,14 @@ export const INSTITUTION_UNIT = {
series: 'Série institucional · agenda com lastro',
title: 'Dossiê Solla por instituição',
bulletinTitle: 'Boletim informativo modelo',
bulletinMoreLimit: 10,
sphereColumnLabel: 'Abrangência',
notSummedInline: 'não somar à instituição',
sumGuardNote: 'setor e rede não são somados à instituição.',
acervoNote: 'recorte por tema, não nominal; não somar à instituição.',
eraNumbersTitle: 'Objeto, valor, fase e alcance',
eraActionsTitle: 'Papéis com evidência visível',
honorsTitle: 'Títulos, honrarias e vínculos institucionais',
}
const DOSSIER_UNITS = {

View file

@ -62,6 +62,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/dossieCamara.mjs',
'scripts/lib/dossieCareer.mjs',
'scripts/lib/dossieHealthData.mjs',
'scripts/lib/dossiePack.mjs',
'scripts/lib/dossieRender.mjs',
'scripts/lib/dossieResearch.mjs',
'scripts/lib/dossieUnit.mjs',

View file

@ -14,6 +14,7 @@ const read = (path: string): string => readFileSync(resolve(repoRoot, path), 'ut
const skill = read('.agents/skills/dossie-solla-cidade/SKILL.md')
const researcher = read('.opencode/agent/dossie-solla-cidade.md')
const writer = read('.opencode/agent/dossie-solla-cidade-redacao.md')
describe('skill /dossie-solla-cidade documents the batch contract', () => {
it('keeps the comma-separated batch entry and the single-city case', () => {
@ -88,4 +89,50 @@ describe('subagent .opencode/agent/dossie-solla-cidade.md is researcher-only', (
expect(researcher, 'researcher must not run the extractor CLI').not.toContain('--municipality=')
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ê sem caps + redação (revisão 2026-09-18)', () => {
it('documents the flowing sheets and the measured packing', () => {
expect(skill, 'no caps in the 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('"municipalitySlug": "ilheus"')
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-cidade-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('--municipality=')
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/)
})
})

View file

@ -102,13 +102,13 @@ describe('buildDossierReport', () => {
const report = build()
expect(report.eras.map((era) => era?.id)).toEqual(['A', 'B', 'C'])
expect(
report.eras.every((era) => (era?.numbers.items.length ?? 0) + (era?.actions.length ?? 0) > 0),
report.eras.every((era) => (era?.numbers.length ?? 0) + (era?.actions.length ?? 0) > 0),
).toBe(true)
})
it('turns official emenda execution into phase-labelled rows (empenho ≠ pagamento)', () => {
const eraC = build().eras.find((era) => era?.id === 'C')
expect(eraC?.numbers.items).toContainEqual(
expect(eraC?.numbers).toContainEqual(
expect.objectContaining({ object: 'Saúde', phase: 'empenhado', sphere: 'municipio' }),
)
})
@ -119,7 +119,9 @@ describe('buildDossierReport', () => {
expect.objectContaining({ topic: 'População residente (Censo 2022)' }),
)
const eraB = report.eras.find((era) => era?.id === 'B')
expect(eraB?.numbers.items.some((row) => row.object.includes('População'))).toBe(false)
expect(eraB?.numbers.some((row: { object: string }) => row.object.includes('População'))).toBe(
false,
)
})
it('never sums região/polo into the município list', () => {
@ -245,12 +247,12 @@ describe('buildDossierReport', () => {
const report = build({ research: manyRegional })
const eraA = report.eras.find((era) => era?.id === 'A')!
expect(eraA.actions).toHaveLength(5)
expect(eraA.camaraActionsRemaining).toBe(0)
expect(report.region.evidence.items).toHaveLength(3)
expect(report.region.evidence.total).toBe(5)
expect(report.region.evidence.remaining).toBe(2)
// Uncapped (2026-09-18): the full lists flow across sheets, so there is no
// remaining/omitted count on the era or region lists.
expect(eraA).not.toHaveProperty('camaraActionsRemaining')
expect(report.region.regional.items).toHaveLength(5)
expect(report.region.regional.total).toBe(5)
expect(report.region.regional.items.length + report.region.regional.remaining).toBe(5)
expect(report.region.regional).not.toHaveProperty('remaining')
})
it('carries the defeso limits and the editorial rules', () => {

View file

@ -1,10 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
BULLETIN_HIGHLIGHT_LIMIT,
BULLETIN_MORE_LIMIT,
buildBulletin,
} from '../../scripts/lib/dossieBulletin.mjs'
import { BULLETIN_HIGHLIGHT_LIMIT, buildBulletin } from '../../scripts/lib/dossieBulletin.mjs'
import { MUNICIPALITY_UNIT } from '../../scripts/lib/dossieUnit.mjs'
const fact = (index: number, overrides: Record<string, unknown> = {}) => ({
id: `fato-${index}`,
@ -28,7 +25,8 @@ describe('buildBulletin', () => {
const facts = Array.from({ length: 30 }, (_value, index) => fact(index))
const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt })
expect(bulletin.highlights).toHaveLength(BULLETIN_HIGHLIGHT_LIMIT)
expect(bulletin.moreItems).toHaveLength(BULLETIN_MORE_LIMIT)
// The unit descriptor owns the "E mais" budget (long labels wrap).
expect(bulletin.moreItems).toHaveLength(MUNICIPALITY_UNIT.bulletinMoreLimit)
})
it('counts the facts left out of the one-page boletim (never a silent drop, C188)', () => {
@ -76,7 +74,7 @@ describe('buildBulletin', () => {
const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt })
const headlines = new Set(facts.map((row) => row.headline))
expect(bulletin.highlights.length + bulletin.moreItems.length).toBe(
BULLETIN_HIGHLIGHT_LIMIT + BULLETIN_MORE_LIMIT,
BULLETIN_HIGHLIGHT_LIMIT + MUNICIPALITY_UNIT.bulletinMoreLimit,
)
expect(bulletin.highlights.every((row) => headlines.has(row.title))).toBe(true)
expect(bulletin.moreItems.every((row) => headlines.has(row.label))).toBe(true)

View file

@ -157,16 +157,13 @@ describe('buildDossierReport (institution unit)', () => {
expect(report.page1.deliveries.omitted).toBeGreaterThanOrEqual(0)
})
it('prints "e mais N" with the right singular when a list is capped', () => {
it('shows every sourced item — institution lists are never capped', () => {
const manyItems = [
item('era_c_emendas', 'C', { sphere: 'setor' }),
item('era_c_atuacao', 'C', { sphere: 'setor' }),
item('era_c_discursos', 'C', { sphere: 'setor' }),
item('era_c_proposicoes', 'C', { sphere: 'setor' }),
item('era_c_titulos', 'C', { sphere: 'setor' }),
item('era_c_parcerias', 'C', { sphere: 'setor' }),
]
const cappedReport = buildDossierReport({
const fullReport = buildDossierReport({
snapshot,
research: mergeDossierResearch(
[normalizeDossierResearchInput(eraFile('C', manyItems), { unit: INSTITUTION_UNIT })],
@ -175,11 +172,47 @@ describe('buildDossierReport (institution unit)', () => {
generatedAt,
unit: INSTITUTION_UNIT,
})
const list = cappedReport.reach.lists.find((row) => row.key === 'sector')
expect(list?.omitted).toBeGreaterThan(0)
const html = renderDossierHtml(cappedReport)
expect(html).toContain(`${list?.omitted} item`)
expect(html, 'the plural noun must not be chopped into "iten"').not.toContain('iten ')
const list = fullReport.reach.lists.find((row) => row.key === 'sector')
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_equipamentos', 'B', {
numbers: [{ label: 'Convênio', value: 'R$ 1,2 mi', year: '2012', phase: 'pago' }],
})
const countItem = item('era_b_obras', 'B', {
numbers: [{ label: 'Leitos', value: '175', year: '2010', phase: 'nao_informado' }],
})
const moneyReport = buildDossierReport({
snapshot,
research: mergeDossierResearch(
[
normalizeDossierResearchInput(eraFile('B', [moneyItem, countItem]), {
unit: INSTITUTION_UNIT,
}),
],
{ unit: INSTITUTION_UNIT },
),
generatedAt,
unit: INSTITUTION_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 })
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', () => {
@ -203,10 +236,16 @@ describe('renderDossierHtml/Md (institution unit)', () => {
for (const anchor of [
'capa',
'resumo',
'sintese',
'graficos',
'era-a',
'era-b',
'era-c',
'abrangencia',
'abrangencia-instituicao',
'abrangencia-setor',
'abrangencia-rede',
'titulos',
'lacunas',
'fontes',
]) {
@ -214,6 +253,77 @@ describe('renderDossierHtml/Md (institution unit)', () => {
}
})
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(
{
institutionSlug: 'ufba',
era: 'C',
researchedAt: '2026-09-17T10:00:00.000Z',
items: [
'era_c_discursos',
'era_c_proposicoes',
'era_c_emendas',
'era_c_titulos',
'era_c_atuacao',
'era_c_parcerias',
].map((id) => item(id, 'C', { sphere: 'setor' })),
news: Array.from({ length: 12 }, (_, index) => ({
title: `Notícia ${index + 1}`,
outlet: 'UFBA',
publishedAt: '2024-05-01',
url: `https://ufba.test/noticia-${index + 1}`,
})),
},
{ unit: INSTITUTION_UNIT },
),
],
{ unit: INSTITUTION_UNIT },
),
generatedAt,
unit: INSTITUTION_UNIT,
})
const bigHtml = renderDossierHtml(bigReport, {
pack: { 'era:C': [1], 'scope:setor': [1], news: [1] },
})
expect(bigHtml).toContain('data-page="era-c"')
expect(bigHtml).toContain('data-page="era-c-2"')
expect(bigHtml).toContain('data-page="abrangencia-setor"')
expect(bigHtml).toContain('data-page="abrangencia-setor-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 institutional classes and identity badges', () => {
expect(html).toContain('identity-badge')
expect(html).toContain('scope-sector')

View file

@ -14,6 +14,7 @@ const read = (path: string): string => readFileSync(resolve(repoRoot, path), 'ut
const skill = read('.agents/skills/dossie-solla-instituicao/SKILL.md')
const researcher = read('.opencode/agent/dossie-solla-instituicao.md')
const writer = read('.opencode/agent/dossie-solla-instituicao-redacao.md')
describe('skill /dossie-solla-instituicao documents the batch contract', () => {
it('keeps the comma-separated batch entry and the single-institution case', () => {
@ -92,4 +93,50 @@ describe('subagent .opencode/agent/dossie-solla-instituicao.md is researcher-onl
expect(researcher, 'researcher must not run the extractor CLI').not.toContain('--institution=')
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ê institucional sem caps + redação (revisão 2026-09-18)', () => {
it('documents the flowing sheets and the measured packing', () => {
expect(skill, 'no caps in the institution 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('"institutionSlug": "ufba"')
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-instituicao-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('--institution=')
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/)
})
})

View file

@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import { adjustPackPlan, packProbeSections } from '../../scripts/lib/dossiePack.mjs'
// C187 flowing sheets: the probe measures real heights and the packer decides
// how many units fit per sheet — sections multiply sheets, nothing is capped,
// and a row never splits across sheets. The builder then re-renders and
// measures, and `adjustPackPlan` moves rows until no page is half empty and
// none overflows.
const probe = [
{
key: 'era:C',
overhead: 200,
overheadContinuation: 100,
rows: [
{ units: [0], cost: 100 },
{ units: [1], cost: 100 },
{ units: [2, 3], cost: 150 },
{ units: [4, 5], cost: 150 },
],
},
{
key: 'news',
overhead: 100,
overheadContinuation: 100,
rows: [
{ units: [0], cost: 60 },
{ units: [1], cost: 60 },
],
},
]
describe('packProbeSections', () => {
it('packs rows in order up to the budget, never splitting a row', () => {
const plan = packProbeSections(probe, 550)
expect(plan['era:C']).toEqual([2, 4])
expect(plan.news).toEqual([2])
})
it('keeps the sheet reserve free for the block margins', () => {
const rows = [
{ units: [0], cost: 400 },
{ units: [1], cost: 150 },
]
expect(packProbeSections([{ key: 'x', overhead: 0, rows }], 560, 0).x).toEqual([2])
expect(packProbeSections([{ key: 'x', overhead: 0, rows }], 560, 60).x).toEqual([1, 1])
})
it('uses the smaller continuation overhead after the first sheet', () => {
const plan = packProbeSections(
[
{
key: 'era:B',
overhead: 300,
overheadContinuation: 60,
rows: [
{ units: [0], cost: 180 },
{ units: [1], cost: 180 },
{ units: [2], cost: 180 },
],
},
],
500,
)
expect(plan['era:B']).toEqual([1, 2])
})
})
describe('adjustPackPlan', () => {
const anchors = { 'era:C': 'era-c' }
it('gives the last row of an overflowing sheet to the next chunk', () => {
const adjusted = adjustPackPlan({
plan: { 'era:C': [4, 2] },
probe,
sheets: [
{ page: 'era-c', height: 1200, used: 1200 },
{ page: 'era-c-2', height: 1123, used: 300 },
],
anchors,
budgetPx: 1133,
})
expect(adjusted).toEqual({ 'era:C': [2, 4] })
})
it('grows a sheet with room by taking the first row of the next chunk', () => {
const adjusted = adjustPackPlan({
plan: { 'era:C': [4, 2] },
probe,
sheets: [
{ page: 'era-c', height: 1123, used: 500 },
{ page: 'era-c-2', height: 1123, used: 300 },
],
anchors,
budgetPx: 1133,
})
expect(adjusted).toEqual({ 'era:C': [6] })
})
it('returns null when nothing can move (settled plan)', () => {
const adjusted = adjustPackPlan({
plan: { 'era:C': [4, 2] },
probe,
sheets: [
{ page: 'era-c', height: 1123, used: 1100 },
{ page: 'era-c-2', height: 1123, used: 300 },
],
anchors,
budgetPx: 1133,
})
expect(adjusted).toBeNull()
})
})

View file

@ -177,8 +177,9 @@ describe('renderDossierHtml — reformulated brief (no ellipsis)', () => {
})
it('paginates long news lists into continuation sheets', () => {
expect(html).toContain('data-page="noticias"')
expect(html).toContain('data-page="noticias-2"')
const packed = renderDossierHtml(briefReport, { pack: { news: [1] } })
expect(packed).toContain('data-page="noticias"')
expect(packed).toContain('data-page="noticias-2"')
})
})
@ -200,7 +201,7 @@ describe('renderDossierHtml — era continuation sheets', () => {
}),
])
const report = buildDossierReport({ snapshot, research: manyActions, generatedAt })
const html = renderDossierHtml(report)
const html = renderDossierHtml(report, { pack: { 'era:C': [1] } })
it('paginates era action cards into continuation sheets instead of dropping them', () => {
expect(html).toContain('data-page="era-c"')