mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
4b00a8778a
commit
f9f23bf64e
27 changed files with 5495 additions and 3091 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
35
.opencode/agent/dossie-solla-cidade-redacao.md
Normal file
35
.opencode/agent/dossie-solla-cidade-redacao.md
Normal 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).
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
36
.opencode/agent/dossie-solla-instituicao-redacao.md
Normal file
36
.opencode/agent/dossie-solla-instituicao-redacao.md
Normal 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).
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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>"`.
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-09-18-c186-c187-dossie-corrente.md
Normal file
1
docs/changelog/2026-09-18-c186-c187-dossie-corrente.md
Normal 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
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 (;;) {
|
||||
|
|
|
|||
|
|
@ -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 ?? '—',
|
||||
|
|
|
|||
|
|
@ -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
121
scripts/lib/dossiePack.mjs
Normal 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
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
114
tests/unit/dossiePack.unit.spec.ts
Normal file
114
tests/unit/dossiePack.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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"')
|
||||
|
|
|
|||
Loading…
Reference in a new issue