From f9f23bf64e855a2c210075ea98a9ef3eac9191d1 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 17 Sep 2026 22:29:13 -0300 Subject: [PATCH] =?UTF-8?q?feat(C186/C187):=20dossi=C3=AA=20corrente=20sem?= =?UTF-8?q?=20caps=20+=20reda=C3=A7=C3=A3o,=20s=C3=ADntese=20e=20gr=C3=A1f?= =?UTF-8?q?icos=20(paridade=20cidade=20=C3=97=20institui=C3=A7=C3=A3o)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .agents/skills/dossie-solla-cidade/SKILL.md | 144 +- .../skills/dossie-solla-instituicao/SKILL.md | 150 +- .../agent/dossie-solla-cidade-redacao.md | 35 + .opencode/agent/dossie-solla-cidade.md | 3 + .../agent/dossie-solla-instituicao-redacao.md | 36 + .opencode/agent/dossie-solla-instituicao.md | 3 + .opencode/commands/dossie-solla-cidade.md | 2 +- .../commands/dossie-solla-instituicao.md | 2 +- .../2026-09-18-c186-c187-dossie-corrente.md | 1 + docs/plans/dossie-solla-cidade-ui-design.html | 2768 ++++++++++------- .../dossie-solla-instituicao-ui-design.html | 2463 ++++++++------- scripts/build-dossie-solla-cidade.mjs | 103 +- scripts/build-dossie-solla-instituicao.mjs | 88 +- scripts/lib/buildPdf.mjs | 103 +- scripts/lib/dossieBlocks.mjs | 590 +++- scripts/lib/dossieBulletin.mjs | 7 +- scripts/lib/dossiePack.mjs | 121 + scripts/lib/dossieRender.mjs | 1573 ++++++---- scripts/lib/dossieUnit.mjs | 18 + scripts/lib/test-affected-core.mjs | 1 + tests/unit/dossieBatchSkill.unit.spec.ts | 47 + tests/unit/dossieBlocks.unit.spec.ts | 18 +- tests/unit/dossieBulletin.unit.spec.ts | 12 +- tests/unit/dossieInstitution.unit.spec.ts | 130 +- .../dossieInstitutionBatchSkill.unit.spec.ts | 47 + tests/unit/dossiePack.unit.spec.ts | 114 + tests/unit/dossieRender.unit.spec.ts | 7 +- 27 files changed, 5495 insertions(+), 3091 deletions(-) create mode 100644 .opencode/agent/dossie-solla-cidade-redacao.md create mode 100644 .opencode/agent/dossie-solla-instituicao-redacao.md create mode 100644 docs/changelog/2026-09-18-c186-c187-dossie-corrente.md create mode 100644 scripts/lib/dossiePack.mjs create mode 100644 tests/unit/dossiePack.unit.spec.ts diff --git a/.agents/skills/dossie-solla-cidade/SKILL.md b/.agents/skills/dossie-solla-cidade/SKILL.md index 87b2eeb9..3a2b734b 100644 --- a/.agents/skills/dossie-solla-cidade/SKILL.md +++ b/.agents/skills/dossie-solla-cidade/SKILL.md @@ -19,7 +19,8 @@ aprovados pelo `designer`. - A comunicação pede "o dossiê de " e/ou o "boletim modelo de " — 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/.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/.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/--dossie.pdf` + `-dossie.md` + `-boletim.pdf`. Intermediários gitignored em `data/dossie-solla-cidade/` (HTML, JSONs, emendas, logs). Flags úteis: `--emendas=` (replay sem rede), `--author=` (default `JORGE SOLLA`), `--generated-at=`, `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 `..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: + +**`.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. diff --git a/.agents/skills/dossie-solla-instituicao/SKILL.md b/.agents/skills/dossie-solla-instituicao/SKILL.md index 77b2cc31..3d971737 100644 --- a/.agents/skills/dossie-solla-instituicao/SKILL.md +++ b/.agents/skills/dossie-solla-instituicao/SKILL.md @@ -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 " — 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/.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/.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/.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/--dossie.pdf` + `-dossie.md` + `-boletim.pdf`. Intermediários gitignored em `data/dossie-solla-instituicao/` (HTML, JSONs, logs). Flags úteis: `--generated-at=`, `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 `..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. +`.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. diff --git a/.opencode/agent/dossie-solla-cidade-redacao.md b/.opencode/agent/dossie-solla-cidade-redacao.md new file mode 100644 index 00000000..b97ca398 --- /dev/null +++ b/.opencode/agent/dossie-solla-cidade-redacao.md @@ -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/..research.json` (`items[]` + com `answer`/`details`/`brief`/`numbers`/`sphere`/`sourceUrl`/`sourceDate`; + `gaps[]` são lacunas explícitas). +2. Escrever `data/dossie-solla-cidade/.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). diff --git a/.opencode/agent/dossie-solla-cidade.md b/.opencode/agent/dossie-solla-cidade.md index f366ba46..29c5f08f 100644 --- a/.opencode/agent/dossie-solla-cidade.md +++ b/.opencode/agent/dossie-solla-cidade.md @@ -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). diff --git a/.opencode/agent/dossie-solla-instituicao-redacao.md b/.opencode/agent/dossie-solla-instituicao-redacao.md new file mode 100644 index 00000000..7cae0714 --- /dev/null +++ b/.opencode/agent/dossie-solla-instituicao-redacao.md @@ -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/..research.json` + (`items[]` com `answer`/`details`/`brief`/`numbers`/`sphere`/`sourceUrl`/ + `sourceDate`; `gaps[]` são lacunas explícitas). +2. Escrever `data/dossie-solla-instituicao/.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). diff --git a/.opencode/agent/dossie-solla-instituicao.md b/.opencode/agent/dossie-solla-instituicao.md index 9788706d..864181e5 100644 --- a/.opencode/agent/dossie-solla-instituicao.md +++ b/.opencode/agent/dossie-solla-instituicao.md @@ -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). diff --git a/.opencode/commands/dossie-solla-cidade.md b/.opencode/commands/dossie-solla-cidade.md index 06a96f99..9573c6ec 100644 --- a/.opencode/commands/dossie-solla-cidade.md +++ b/.opencode/commands/dossie-solla-cidade.md @@ -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 [, ...]` — ex.: `/dossie-solla-cidade Ilhéus, Itacaré, Una`. Uma cidade = dossiê (`--dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). diff --git a/.opencode/commands/dossie-solla-instituicao.md b/.opencode/commands/dossie-solla-instituicao.md index faceea1c..7b9e6f13 100644 --- a/.opencode/commands/dossie-solla-instituicao.md +++ b/.opencode/commands/dossie-solla-instituicao.md @@ -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 [, ...]` — ex.: `/dossie-solla-instituicao UFBA, Correios`. Uma instituição = dossiê (`--dossie.pdf`/`.md`) + boletim (`-boletim.pdf`). Token fora do catálogo falha fechado; use o escape one-off `--slug= --name=""`. diff --git a/docs/changelog/2026-09-18-c186-c187-dossie-corrente.md b/docs/changelog/2026-09-18-c186-c187-dossie-corrente.md new file mode 100644 index 00000000..3c34d80e --- /dev/null +++ b/docs/changelog/2026-09-18-c186-c187-dossie-corrente.md @@ -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 `.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. diff --git a/docs/plans/dossie-solla-cidade-ui-design.html b/docs/plans/dossie-solla-cidade-ui-design.html index f065ab05..f4f5b1eb 100644 --- a/docs/plans/dossie-solla-cidade-ui-design.html +++ b/docs/plans/dossie-solla-cidade-ui-design.html @@ -10,7 +10,7 @@ --font-sans: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; --color-ink: #172033; --color-ink-soft: #435264; - --color-paper: #ffffff; + --color-paper: #fff; --color-workspace: #d8dde1; --color-line: #cbd5dc; --color-wash: #f3f6f8; @@ -18,14 +18,11 @@ --color-accent-soft: #e8f0f4; --color-warning: #8a5a18; --color-warning-soft: #fff7e8; - --radius-sm: 0.125rem; - --radius-md: 0.25rem; } - :root { --ink: #172033; --ink-soft: #435264; - --paper: #ffffff; + --paper: #fff; --workspace: #d8dde1; --line: #cbd5dc; --wash: #f3f6f8; @@ -34,36 +31,33 @@ --warning: #8a5a18; --warning-soft: #fff7e8; } - * { box-sizing: border-box; } - html { color-scheme: light; } - body { margin: 0; - font-family: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; color: var(--ink); background: var(--workspace); + font-family: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; -webkit-font-smoothing: antialiased; } - .sheet { + position: relative; width: 210mm; min-height: 297mm; - padding: 13mm 15mm 12mm; - background: var(--paper); - box-shadow: 0 18px 44px rgb(23 32 51 / 18%); - font-size: 11pt; - line-height: 1.38; + height: 297mm; + padding: 12mm 14mm 10mm; + overflow: hidden; display: flex; flex-direction: column; - overflow: hidden; + background: var(--paper); + box-shadow: 0 18px 44px rgb(23 32 51 / 18%); + font-size: 10.5pt; + line-height: 1.32; } - .sheet p, .sheet ul, .sheet ol, @@ -75,181 +69,550 @@ text-decoration: underline; text-underline-offset: 1.5px; } - .sheet a:focus-visible { - outline: 2px solid #315c75; - outline-offset: 2px; + .accent-bar { + position: absolute; + left: 0; + top: 0; + width: 4mm; + height: 297mm; + background: #315c75; } - - /* Corpo editorial nunca desce de 10,5 pt; 7,5–9 pt ficam restritos a - metadados, rótulos e notas de fonte. */ - .sheet .text-\[9pt\], - .sheet .text-\[9\.5pt\], - .sheet .text-\[10pt\] { - font-size: 10.5pt !important; - } - .report-header { display: flex; justify-content: space-between; gap: 8mm; align-items: flex-start; padding-bottom: 3mm; - border-bottom: 0.45mm solid var(--ink); + border-bottom: 0.45mm solid #172033; } - .report-kicker { margin: 0; - color: var(--accent); + color: #315c75; font-size: 8pt; - line-height: 1.2; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; } - + .sheet-title { + margin: 1.2mm 0 0; + font-size: 18pt; + font-weight: 700; + line-height: 1.1; + letter-spacing: -0.02em; + } + .sheet-sub { + margin: 0.8mm 0 0; + font-size: 10.5pt; + color: #435264; + } .report-meta { min-width: 43mm; margin: 0; - color: var(--ink-soft); + color: #435264; font-size: 8pt; line-height: 1.45; text-align: right; font-variant-numeric: tabular-nums; } - + .report-meta p { + margin: 0; + } .report-footer { margin-top: auto; padding-top: 2.5mm; - border-top: 0.25mm solid var(--line); + border-top: 0.25mm solid #cbd5dc; display: flex; justify-content: space-between; gap: 5mm; - color: var(--ink-soft); + color: #435264; font-size: 8pt; line-height: 1.3; } - .section-title { margin: 0; font-size: 13.5pt; line-height: 1.18; letter-spacing: -0.01em; } - .eyebrow { margin: 0 0 1.4mm; - color: var(--accent); + color: #315c75; font-size: 8pt; - line-height: 1.2; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } - + .warning-text { + color: #8a5a18; + } + .muted { + color: #435264; + } + .small { + font-size: 10.5pt; + line-height: 1.28; + } + .meta { + font-size: 7.7pt; + line-height: 1.2; + color: #435264; + } + .strong { + font-weight: 600; + } + .tabular { + font-variant-numeric: tabular-nums; + } + .block { + margin-top: 3.4mm; + break-inside: avoid; + } + .block-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 4mm; + } .source-link { display: inline-flex; - align-items: baseline; - gap: 1mm; white-space: nowrap; font-size: 9pt; font-weight: 600; } - - .source-link svg { - width: 3mm; - height: 3mm; - transform: translateY(0.35mm); - } - - .scope { + .scope, + .phase { display: inline-flex; align-items: center; min-height: 5mm; padding: 0.4mm 1.6mm; - border: 0.25mm solid #9aacb7; - border-radius: 99px; - color: #304758; - background: #f7fafb; font-size: 7.5pt; line-height: 1; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; } - + .scope { + border: 0.25mm solid #9aacb7; + border-radius: 99px; + color: #304758; + background: #f7fafb; + } .scope-region { border-color: #aa8752; color: #6b4918; background: #fff9ee; } - - .phase { - display: inline-block; - padding: 0.5mm 1.4mm; - border-radius: 1mm; - background: #e9eef1; - color: #334553; - font-size: 7.5pt; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.035em; + .scope-pole { + border-color: #8f819c; + color: #554362; + background: #faf7fc; + } + .phase { + border-radius: 1mm; + color: #334553; + background: #e9eef1; + } + .phase-paid { + color: #285338; + background: #e6f2e9; + } + .phase-pending { + color: #6b4918; + background: #fff2d9; } - .document-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 10.5pt; line-height: 1.22; + margin-top: 2.5mm; } - .document-table th { padding: 1.7mm 1.6mm; - border-top: 0.25mm solid var(--line); + border-top: 0.25mm solid #cbd5dc; border-bottom: 0.35mm solid #9caab3; - background: var(--wash); - color: var(--ink-soft); + background: #f3f6f8; + color: #435264; font-size: 7.5pt; font-weight: 700; letter-spacing: 0.045em; text-align: left; text-transform: uppercase; } - .document-table td { padding: 2mm 1.6mm; border-bottom: 0.25mm solid #dce3e7; vertical-align: top; overflow-wrap: anywhere; } - .document-table tbody tr:nth-child(even) { background: #fafcfd; } - .document-table .numeric { + .numeric { font-variant-numeric: tabular-nums; white-space: nowrap; } - - .placeholder-bar { - display: inline-block; - height: 2.6mm; - border-radius: 1mm; - background: #d8e0e5; - vertical-align: middle; + .panel { + border: 0.25mm solid #cbd5dc; + border-radius: 0.25rem; + padding: 3mm; + break-inside: avoid; + } + .panel-warning { + border: none; + border-left: 3mm solid #8a5a18; + background: #fff7e8; + } + .panel-region { + border: 0.35mm solid #c9a66d; + background: #fffbf3; + } + .tight-list { + margin: 0; + padding-left: 4mm; + } + .tight-list li { + margin-bottom: 1mm; + } + .aside-accent { + border-radius: 0.25rem; + background: #e8f0f4; + padding: 3mm; + font-size: 10.5pt; + line-height: 1.35; + } + .rule-warning { + display: flex; + align-items: flex-start; + gap: 3mm; + border: 0.45mm solid #8a5a18; + background: #fff7e8; + border-radius: 0.25rem; + padding: 4mm; + margin-top: 4mm; + } + .warn-icon { + width: 5mm; + height: 5mm; + color: #8a5a18; + flex-shrink: 0; + margin-top: 0.5mm; + } + .cover-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8mm; + border-bottom: 0.25mm solid #cbd5dc; + padding-bottom: 4mm; + } + .cover-series { + margin: 2mm 0 0; + font-size: 10.5pt; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #435264; + } + .internal-note { + border: 0.25mm solid #cbd5dc; + background: #f3f6f8; + padding: 2mm 3mm; + text-align: right; + font-size: 8pt; + line-height: 1.5; + color: #435264; + border-radius: 0.25rem; + } + .cover-block { + margin-top: 28mm; + max-width: 150mm; + } + .cover-title { + margin: 0; + font-size: 34pt; + font-weight: 700; + line-height: 1.03; + letter-spacing: -0.035em; + } + .cover-sub { + margin: 5mm 0 0; + max-width: 125mm; + font-size: 15pt; + line-height: 1.4; + color: #435264; + } + .cover-dl { + display: grid; + grid-template-columns: 42mm 1fr; + border-block: 0.25mm solid #cbd5dc; + margin: 22mm 0 0; + font-size: 11pt; + } + .cover-dl dt, + .cover-dl dd { + padding: 3mm 0; + border-bottom: 0.25mm solid #cbd5dc; + } + .cover-dl dt { + font-weight: 600; + color: #435264; + } + .cover-dl dd { + margin: 0; + font-weight: 600; + } + .cover-dl dt:last-of-type, + .cover-dl dd:last-of-type { + border-bottom: 0; + } + .cover-grid { + display: grid; + grid-template-columns: 1fr 56mm; + gap: 5mm; + margin-top: 6mm; + } + .how-to { + border-radius: 0.25rem; + background: #e8f0f4; + padding: 4mm; + } + .how-to-title { + margin: 0; + font-size: 10.5pt; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #315c75; } - .asset-box { border: 0.35mm dashed #80919d; color: #4c5c68; background: #f7f9fa; + padding: 3mm; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + min-height: 30mm; + border-radius: 0.25rem; + font-size: 8pt; + font-weight: 600; + } + .cover-foot { + margin-top: auto; + } + .ink-rule { + height: 0.5mm; + background: #172033; + margin-bottom: 5mm; + } + .cover-foot-row { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 8mm; + } + .opening-paragraph { + margin: 0 0 3.4mm; + font-size: 11pt; + line-height: 1.45; + } + .timeline-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.8mm; + margin-top: 2.5mm; + } + .timeline-cell { + border-top: 1.6px solid #315c75; + background: #f3f6f8; + padding: 2mm; + } + .timeline-period { + margin: 0; + font-size: 7.6pt; + font-weight: 700; + color: #315c75; + } + .timeline-role { + margin: 0.8mm 0 0; + font-size: 10.5pt; + font-weight: 600; + line-height: 1.16; + } + .timeline-recovery { + margin: 0.7mm 0 0; + font-size: 7.5pt; + line-height: 1.16; + color: #435264; + } + .deliveries { + border-block: 0.25mm solid #cbd5dc; + margin-top: 1.8mm; + } + .delivery-row { + display: grid; + grid-template-columns: 26mm 1fr 42mm; + gap: 3mm; + padding: 2mm 0; + border-bottom: 0.25mm solid #cbd5dc; + } + .delivery-row:last-child { + border-bottom: 0; + } + .page1-grid, + .synthesis-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3mm; + margin-top: 3mm; + } + .synthesis-lines { + margin: 0; + padding-left: 4.5mm; + font-size: 9.5pt; + line-height: 1.35; + } + .synthesis-lines li { + margin-bottom: 1.8mm; + } + .chart-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3mm; + margin-top: 3mm; + } + .chart-card { + border: 0.25mm solid #cbd5dc; + border-radius: 0.25rem; + padding: 3mm; + break-inside: avoid; + } + .chart { + display: block; + width: 100%; + height: auto; + } + .chart-title { + margin: 0 0 2mm; + font-size: 9.5pt; + font-weight: 600; + } + .chart-label { + font-size: 7.4pt; + fill: #435264; + } + .chart-value { + font-size: 7.6pt; + fill: #1f2933; + font-weight: 700; + } + .chart-legend { + margin: 1.5mm 0 0; + font-size: 7.6pt; + color: #435264; + } + .value-list { + list-style: none; + margin: 0; + padding: 0; + } + .value-list li { + display: grid; + grid-template-columns: 1fr 20mm 18mm; + gap: 1.6mm; + align-items: center; + padding: 0.9mm 0; + border-bottom: 0.25mm solid #e3e9ec; + font-size: 8.2pt; + } + .value-list-bar { + display: block; + height: 2.2mm; + background: #eef2f5; + border-radius: 1mm; + overflow: hidden; + } + .value-list-bar span { + display: block; + height: 100%; + background: #315c75; + } + .value-list-value { + text-align: right; + font-weight: 700; + white-space: nowrap; + } + .era-method { + display: grid; + grid-template-columns: 1fr 55mm; + gap: 4mm; + margin-top: 4mm; + } + .era-summary { + margin-top: 3.4mm; + border-left: 1.2mm solid #315c75; + background: #f7fafb; + padding: 2.6mm 3mm; + } + .era-summary p { + margin: 0; + font-size: 10pt; + line-height: 1.4; + } + .action-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3mm; + margin-top: 3mm; + } + .action-head { + display: flex; + justify-content: space-between; + gap: 3mm; + } + .action-title { + margin: 2mm 0 0; + font-size: 10.5pt; + font-weight: 600; + } + .scope-compare { + display: grid; + grid-template-columns: 1fr 12mm 1fr; + align-items: stretch; + gap: 3mm; + margin-top: 3mm; + } + .compare-mid { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #8a5a18; + } + .compare-mid:before, + .compare-mid:after { + content: ''; + width: 1px; + flex: 1; + background: #d8c09a; + } + .compare-mid span { + font-size: 18pt; + font-weight: 700; + margin: 2mm 0; + } + .scope-count { + margin: 3mm 0 0; + font-size: 16pt; + font-weight: 600; } - @page { size: A4; margin: 0; } - @media print { body { background: #fff; @@ -265,16 +628,8 @@ .scene { margin: 0 !important; break-after: page; - page-break-after: always; - } - .scene:last-child { - break-after: auto; - page-break-after: auto; } .sheet { - width: 210mm; - height: 297mm; - min-height: 297mm; box-shadow: none; } } @@ -282,102 +637,62 @@
-

- Gate visual · Impeccable C +

+ Gate visual · re-sincronizado com o port de 18/09/2026

Dossiê Solla por cidade — relatório A4

- Documento interno para a assessoria localizar uma entrega, conferir o lastro e identificar o - que ainda falta antes de transformar pesquisa em boletim. Cinco páginas A4 estáticas; dados - de exemplo são deliberadamente genéricos. + Documentação hi-fi do layout entregue: folhas correntes, síntese, gráficos, consolidação por + era, região/polo e listas completas. Conteúdo deliberadamente genérico.

-
- A4 · 210 × 297 mm - corpo 11 pt - institucional · sem campanha - fontes item a item -
-
- -
-

- Cena 01 · Capa / identificação +

+

+ Cena 01 · Capa

-
-
-
+
+
+
-

Núcleo de comunicação · pesquisa documental

-

- Série municipal · BA -

+

Comunicação · pesquisa documental

+

Série municipal · BA

-
- INSUMO INTERNO
Não circular · não publicar +
+ INSUMO INTERNO
Não circular · não publicar
- -
+

Dossiê de atuação pública

-

- Município
de Exemplo -

-

+

Município
de Exemplo

+

O que Jorge Solla fez pela cidade e por seu recorte regional ao longo da carreira

- -
-
- Território de Identidade -
-
Território de Exemplo
-
- Região / polo adotado -
-
- Polo de Exemplo · recorte não somável ao município -
-
Data de geração
-
- 17 de setembro de 2026 · 14h30 -
+
+
Território de Identidade
+
Território de Exemplo
+
Região / polo adotado
+
Recorte regional não somável ao município
+
Data de geração
+
DD/MM/AAAA · HHhMM
- -
-
-

- Como usar -

-

- Escolha um tema, confira a fonte ao lado da afirmação e trate toda lacuna como - tarefa de apuração. Este arquivo não é texto final nem peça publicitária. +

+
+

Como usar

+

+ Escolha um tema, confira a fonte e trate toda lacuna como tarefa de apuração.

-
- NEEDS ASSET
selo institucional autorizado
opcional — nunca marca de campanha -
+
NEEDS ASSET
selo autorizado
e origem
- -
-
-
-

- Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara dos - Deputados 2015–2027. Afirmações sem fonte não entram como entrega. +

+
+
+

+ Escopo: carreira técnica até 2006 · SESAB 2007–2014 · Câmara 2015–2027.

- -
-

- Cena 02 · Página 1 — resumo de uma olhada +

+

+ Cena 02 · A contribuição (carta)

-
+

Dossiê de atuação pública · Município de Exemplo

-

- Resumo de uma olhada -

-

+

O que Jorge Solla fez pelo município

+

+ Redação de síntese sobre os pontos com fonte — nada aqui acrescenta fato novo +

+
+
+

INSUMO INTERNO

+

Gerado em DD/MM/AAAA

+

pág. 2/N

+
+
+
+

+ A primeira fase reúne vínculos técnicos e de gestão que têm relação documentada com o + município ou seu entorno. +

+

+ Na gestão estadual, equipamentos, obras e políticas aparecem com alcance municipal, + regional ou de polo claramente identificado. +

+

+ No mandato federal, recursos e atuação local mantêm autoria, fonte, data e fase de + execução; proposta não vira entrega e empenho não vira pagamento. +

+
+ +
+ Dossiê por cidade · A contribuiçãopág. 2/N +
+
+
+ +
+

+ Cena 03 · Resumo de uma olhada +

+
+
+
+

Dossiê de atuação pública · Município de Exemplo

+

Resumo de uma olhada

+

Território de Exemplo · informação datada e pronta para conferência

-

Gerado em 17/09/2026

+

Gerado em DD/MM/AAAA

INSUMO INTERNO

-

pág. 1/5

+

pág. 3/N

- -
-
+
+

01 · trajetória

-

Linha do tempo da carreira

+

Linha do tempo da carreira

-

Período + papel + trilha de recuperação

+

Período + papel + trilha de recuperação

-
-
-

1989/90–1998

-

Epidemiologia · SESAB

-

- Biografia Câmara + fala de 2007 · data inicial incerta -

+
+
+

AAAA–AAAA

+

Papel técnico documentado

+

Fonte oficial

-
-

1995–1999

-

- Consultor · Ministério da Saúde -

-

- Biografia oficial da Câmara -

+
+

AAAA–AAAA

+

Gestão municipal

+

Biografia + ato

-
-

1999–2002

-

- Secretário municipal · Vitória da Conquista -

-

- Câmara + acervo institucional -

+
+

2007–2014

+

Gestão estadual

+

DOE-BA + notícias

-
-

2003–2005

-

- Secretário de Atenção à Saúde · MS -

-

- Câmara + discurso de 2007 · término a conferir -

+
+

2015–2019

+

Mandato federal

+

Câmara

-
-

2007–2014

-

- Secretário estadual · SESAB -

-

- DOE-BA + notícias SESAB -

+
+

2019–2023

+

Mandato federal

+

Câmara

-
-

2015–2027

-

- Deputado federal · 55ª–57ª legislaturas -

-

API Câmara · id 178857

+
+

2023–2027

+

Mandato federal

+

Câmara

- -
-
-
-

02 · destaques com lastro

-

Principais entregas localizadas

-
- Valores abaixo são placeholders -
-
-
+
+

02 · destaques com lastro

+

Principais entregas localizadas

+
+
município -

Era C · 2024

+

Era C · AAAA

-

Recurso de exemplo para equipamento de saúde

-

- Descrição genérica até conferência do objeto e da autoria. -

+ Recurso documentado +

Objeto e autoria conferidos.

-

R$ 1,2 mi

- empenhado - + R$ —
pago
fonte
-
+
região -

Era B · 2012

+

Era B · AAAA

-

- Entrega regional de exemplo com alcance compartilhado -

-

- Não somar ao total municipal; alcance e beneficiários ainda a confirmar. -

+ Entrega regional +

Alcance compartilhado; não somar à cidade.

- -

fase: não informada

- -
-
-
-
- município -

Era A · 2003

-
-
-

Ação técnica de exemplo vinculada ao município

-

- Registro localizado; efeito quantitativo não consta na fonte. -

-
-
- -

sem valor informado

- + R$ —
não informada
fonte
-

- Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada - valor. -

- -
-
-

03 · ganchos para o boletim

-
    -
  • - Tema: saúde. Ângulo: acompanhar a execução do - recurso, sem chamar empenho de pagamento. - (fonte) -
  • -
  • - Tema: rede regional. Ângulo: explicar alcance - compartilhado sem atribuí-lo só à cidade. - (fonte) -
  • +
    +
    +

    Ganchos

    +

    Tema, ângulo e fonte para conferência.

    +
    +
    +

    O que falta

    +
      +
    • Confirmar fase.
    • +
    • Recuperar ato primário.
    -
    -

    04 · o que falta

    -
      -
    • Confirmar pagamento do valor ilustrativo.
    • -
    • Recuperar ato primário da Era A.
    • -
    • Validar vínculo local de honraria citada.
    • -
    -

    - Ausência é resultado: não completar por inferência. -

    -
    -
    - Dossiê Solla por cidade · Município de Exemplo · INSUMO INTERNO - Gerado em 17/09/2026 · pág. 1/5 + Dossiê por cidade · Resumopág. 3/N
- -
-

- Cena 03 · Seção por era — Era C +

+

+ Cena 04 · Síntese

-
+

Dossiê de atuação pública · Município de Exemplo

-

- Era C · Deputado Federal -

-

- 2015–2027 · 55ª, 56ª e 57ª legislaturas -

+

Síntese do que foi localizado

+

Contagens por recorte, nunca soma entre eles

-

Gerado em 17/09/2026

+

Gerado em DD/MM/AAAA

INSUMO INTERNO

-

pág. 3/5

+

pág. 4/N

+
+

Leitura rápida

+

O que os pontos com fonte mostram

+
    +
  • N pontos com fonte no recorte municipal, distribuídos pelas eras A, B e C.
  • +
  • Município, região e polo permanecem em contagens separadas.
  • +
  • Itens com valor mantêm a fase; empenho não é pagamento.
  • +
  • Recursos executados e propostas sem fase têm leituras distintas.
  • +
  • Temas mais frequentes orientam a leitura, sem substituir a lista completa.
  • +
  • Acervo read-only: universo e amostra aparecem declarados.
  • +
  • Lacunas por era viram tarefas de apuração.
  • +
+
+
+
+

Cobertura das eras

+
    +
  • Era A — N pontos
  • +
  • Era B — N pontos
  • +
  • Era C — N pontos
  • +
+
+
+

Guardas de leitura

+
    +
  • Região e polo não somam ao município.
  • +
  • Empenho ≠ pagamento.
  • +
  • Sem fonte, não publica.
  • +
+
+
+
+

Recursos por fase

+

Valores localizados, sem consolidar fases

+ + + + + + + + + + + + + + + + + + + + + + + + + +
FaseItensValor
pagoNR$ —
empenhadoNR$ —
não informadaNR$ —
+

Soma por fase apenas; fases não são equivalentes.

+
+
+ Dossiê por cidade · Síntesepág. 4/N +
+
+
-
+
+

+ Cena 05 · Gráficos consolidados +

+
+
+
+

Dossiê de atuação pública · Município de Exemplo

+

Gráficos consolidados

+

Recursos e entregas — valores sempre com a fase

+
+
+

Gerado em DD/MM/AAAA

+

INSUMO INTERNO

+

pág. 5/N

+
+
+
+
+

Recursos com execução por ano, empilhados por fase (R$)

+ + + + + + + + AAAA + AAAA + AAAA + +

verde: pago/liquidado · âmbar: autorizado/empenhado.

+
+
+

Propostas sem fase informada (R$)

+
    +
  • + AAAA · Proposta AR$ — +
  • +
  • + AAAA · Articulação BR$ — +
  • +
+

citadas nas fontes, sem execução declarada.

+
+
+

Abrangência: município × região × polo

+ + município + + região + + polo + + +

região e polo não somam ao município.

+
+
+

Trajetória: pontos com fonte por ano

+ + + + + + + + + +
+
+

Áreas com mais registros

+ + Área A + + Área B + + Área C + + Área D + + +
+
+

Lacunas declaradas por era

+ + Era A + + Era B + + Era C + + +

cada lacuna é tarefa de apuração.

+
+
+

Acervo interno (read-only)

+

N

+

falas no recorte temático (2011+)

+
    +
  • Amostra de N falas com link direto.
  • +
  • Recorte municipal; região e polo não entram nesta conta.
  • +
+
+
+
+ Dossiê por cidade · Gráficospág. 5/N +
+
+
+ +
+

+ Cena 06 · Era A +

+
+
+
+

Dossiê de atuação pública · Município de Exemplo

+

Era A · Carreira técnica e gestão até 2006

+

Formação, epidemiologia e gestão pública

+
+
+

Gerado em DD/MM/AAAA

+

pág. 6/N

+
+
+

Recorte e método

-

- Itens ligados ao município foram conferidos em fonte oficial e separados por esfera. - Discursos, proposições e execução financeira têm datas e estágios distintos. -

+

Itens ligados ao município, separados por esfera e fonte.

-
- -
-
-
-

Números com fase

-

Recursos e entregas quantificáveis

-
-

- Exemplos de preenchimento — não são fatos publicados -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Valores de exemplo por ano, fase, esfera e fonte -
ObjetoValorAnoFaseEsferaFonte
Equipamento de saúde — exemploR$ 1,2 mi2024empenhadomunicípio - (fonte)
consulta —/—/2026 -
Custeio de exemplo2022pagomunicípio - (fonte)
valor a recuperar -
Ação compartilhada de exemploR$ —2021autorizadoregião - (fonte)
não somar à cidade -
Saldo inscrito de exemploR$ —2020restosmunicípio - (fonte)
situação a conferir -
-

- Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor - pago. +

+

O que esta era entrega

+

+ Parágrafo de consolidação: vínculos técnicos e de gestão localizados são resumidos + antes da lista; o recorte regional permanece separado do município.

- -
+

Atuação registrada

-

O que fez — item, alcance e lastro

-
-
-
- municípioCâmara · 2023 +

O que fez — item, alcance e lastro

+
+
+
+ municípioAAAA
-

- Pronunciamento de exemplo com menção direta à cidade -

-

- Trecho e contexto precisam ser lidos antes do uso editorial. -

- (fonte) +

Item municipal documentado

+

Registro com fonte datada.

+ (fonte)
-
-
- poloCâmara · 2019 +
+
+ regiãoAAAA
-

- Articulação de exemplo com alcance no polo regional -

-

- A cidade pode estar no raio de alcance, mas não recebe atribuição exclusiva. -

- (fonte) +

Item regional documentado

+

Não somar à cidade.

+ (fonte)
- -
-
-

Títulos, honrarias e vínculos locais

-

- Vínculo local citado em fonte secundária: confirmação documental - pendente. - (fonte) -

-
-
- NEEDS ASSET
reprodução do ato ou clipping
somente com origem e licença -
-
-
- Dossiê Solla por cidade · Era C · Município de ExemploGerado em 17/09/2026 · pág. 3/5 + Dossiê por cidade · Era Apág. 6/N
- -
-

- Cena 04 · Região / polo — distinção não somável +

+

+ Cena 07 · Era B

-
+

Dossiê de atuação pública · Município de Exemplo

-

- Região / polo -

-

- Impacto compartilhado, apresentado fora da conta municipal -

+

Era B · Secretaria Estadual de Saúde da Bahia

+

2007–2014 · equipamentos, políticas e obras

-

Gerado em 17/09/2026

-

INSUMO INTERNO

-

pág. 4/5

+

Gerado em DD/MM/AAAA

+

pág. 7/N

- -
-
- -
-

- Região não é cidade. Não some os dois recortes. -

-

- 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. -

-
+
+
+

Recorte e método

+

Cada linha informa esfera, fase e fonte.

+ +
+
+

O que esta era entrega

+

+ Parágrafo de consolidação: equipamentos, obras e políticas aparecem em linguagem + contínua antes da tabela, sempre distinguindo alcance municipal, regional e de polo. +

- -
-

Leitura lado a lado

-

Duas listas, dois totais, nenhuma soma

-
-
-
- municípioTOTAL LOCAL -
-

- R$ 1,2 mi - exemplo · empenhado -

-
    -
  • - Objeto de exemplo nominalmente associado ao município. - (fonte) -
  • -
  • - Menção direta à cidade em registro oficial. - (fonte) -
  • -
-
- -
-
- região / poloSEM TOTAL COMBINADO -
-

- R$ — valor compartilhado -

-
    -
  • - Serviço de exemplo sediado no polo e usado por vários municípios. - (fonte) -
  • -
  • - Política regional de exemplo; alcance municipal ainda sem série. - (fonte) -
  • -
-
-
-
- -
-
-
-

Itens regionais recuperados

-

- Evidência de alcance, sem apropriação local -

-
- recorte regional -
- - - - - - - - +
+

Entregas localizadas

+

Recursos e entregas quantificáveis

+
- Itens do recorte regional com evidência e fonte -
- - - - + + + + + + - - - - + + + + + + - + + + + - - - - - - - - +
ItemEsferaEvidência de alcanceFonteObjetoValorAnoFaseEsferaFonte
Unidade de referência de exemplopolo - Lista de municípios atendidos não localizada; não quantificar beneficiários. - - (fonte) - Equipamento AR$ —AAAApagomunicípiofonte
Programa compartilhado de exemploPrograma BR$ —AAAAnão informada regiãoFonte menciona território; município não é individualizado. - (fonte) -
Articulação federativa de exemploregiãoRegistro documental localizado; efeito material ainda não medido. - (fonte) - fonte
- -
-
-

Gancho possível

-

- Tema: acesso regional à saúde. Ângulo: mostrar a - função de referência do polo e citar a cidade apenas dentro do alcance comprovado. - (fonte) -

-
-
-

Lacuna prioritária

-

- Recuperar lista oficial de municípios atendidos e período de vigência. Até lá, - nenhum número de beneficiários. -

-
-
-
- Dossiê Solla por cidade · Região/polo · recortes não somáveisGerado em 17/09/2026 · pág. 4/5 + Dossiê por cidade · Era Bpág. 7/N
- -
-

- Cena 05 · Fontes e limites + defeso eleitoral +

+

+ Cena 08 · Era C

-
+

Dossiê de atuação pública · Município de Exemplo

-

- Fontes e limites -

-

+

Era C · Deputado Federal

+

2015–2027 · recursos, proposições e atuação

+
+
+

Gerado em DD/MM/AAAA

+

pág. 8/N

+
+
+
+
+

Recorte e método

+

Autoria, ano, fase e alcance checados item a item.

+
+ +
+
+

O que esta era entrega

+

+ Parágrafo de consolidação: o mandato reúne emendas e atuação local com fonte. Região e + polo permanecem fora da conta municipal; empenho não é pagamento. +

+
+
+

Entregas localizadas

+

Recursos e entregas quantificáveis

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ObjetoValorAnoFaseEsferaFonte
Emenda AR$ —AAAApagomunicípiofonte
Ação regional BR$ —AAAAempenhadoregiãofonte
+
+
+ Dossiê por cidade · Era Cpág. 8/N +
+
+
+ +
+

+ Cena 09 · Era C — continuação 2 +

+
+
+
+

Dossiê de atuação pública · Município de Exemplo

+

Era C · Deputado Federal — continuação 2

+

Folha corrente · a era flui sem corte nem descarte silencioso

+
+
+

Gerado em DD/MM/AAAA

+

pág. 9/N

+
+
+
+

Atuação registrada

+

O que fez — item, alcance e lastro

+
+
+
+ municípioAAAA +
+

Pronunciamento com menção direta

+

Trecho e contexto para conferência.

+ (fonte) +
+
+
+ poloAAAA +
+

Articulação de alcance regional

+

Sem atribuição exclusiva à cidade.

+ (fonte) +
+
+
+ municípioAAAA +
+

Proposição documentada

+

Tramitação não equivale a entrega concluída.

+ (fonte) +
+
+
+ municípioAAAA +
+

Vínculo local

+

Ato e período visíveis.

+ (fonte) +
+
+
+
+ Dossiê por cidade · Era C · continuação 2pág. 9/N +
+
+
+ +
+

+ Cena 10 · Região / polo — contadores +

+
+
+
+

Dossiê de atuação pública · Município de Exemplo

+

Região / polo

+

Impacto compartilhado, apresentado fora da conta municipal

+
+
+

Gerado em DD/MM/AAAA

+

pág. 10/N

+
+
+ +
+

Leitura lado a lado

+

Duas listas, dois totais, nenhuma soma

+
+
+ município +

N itens

+

Total local, com objeto nominalmente associado.

+
+
≠
+
+ região / polo +

N itens

+

Alcance compartilhado, sem total combinado.

+
+
+
+
+

Contadores de abrangência

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RecorteItens com fonteItens com valorRegra
municípioNNTotal local
regiãoNNNão somar
poloNNNão somar
+
+
+ Dossiê por cidade · Região/polopág. 10/N +
+
+
+ +
+

+ Cena 11 · Região / polo — município · lista completa +

+
+
+
+

Dossiê de atuação pública · Município de Exemplo

+

Região / polo · município

+

Lista completa do recorte local

+
+
+

Gerado em DD/MM/AAAA

+

pág. 11/N

+
+
+
+
+
+

Município

+

Todos os itens com alcance direto

+
+ N de N +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemEra / anoValor / faseEvidência localFonte
Item municipal 01Era A · AAAAR$ —Município nominal na fonte.fonte
Item municipal 02Era B · AAAApagoObjeto local conferido.fonte
Item municipal 03Era C · AAAAempenhadoAutoria e localidade checadas.fonte
Item municipal 04Era C · AAAAR$ —Menção direta documentada.fonte
Item municipal 05Era C · AAAAnão informadaFase não inferida.fonte
+

+ Lista completa no documento gerado; quando necessário, continua em folha corrente. +

+
+
+ Dossiê por cidade · Região/polo · municípiopág. 11/N +
+
+
+ +
+

+ Cena 12 · Região / polo — regional · lista completa +

+
+
+
+

Dossiê de atuação pública · Município de Exemplo

+

Região / polo · região / polo

+

Lista completa do impacto compartilhado

+
+
+

Gerado em DD/MM/AAAA

+

pág. 12/N

+
+
+
+
+
+

Recorte regional

+

Evidência de alcance, sem apropriação local

+
+ N de N +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemEsferaEra / anoEvidência de alcanceFonte
Unidade de referência ApoloEra B · AAAAAtendimento regional documentado.fonte
Programa compartilhado BregiãoEra B · AAAATerritório citado; município não individualizado.fonte
Articulação federativa CregiãoEra C · AAAAEfeito material ainda não medido.fonte
Serviço de referência DpoloEra C · AAAALista de atendidos identificada.fonte
+
+ +
+ Dossiê por cidade · Região/polo · regionalpág. 12/N +
+
+
+ +
+

+ Cena 13 · Lacunas — lista corrente +

+
+
+
+

Dossiê de atuação pública · Município de Exemplo

+

Lacunas explícitas

+

O que não foi localizado vira tarefa de apuração

+
+
+

Gerado em DD/MM/AAAA

+

pág. 13/N

+
+
+
+
+
+

Ausência é resultado

+

Lista completa de lacunas

+
+ N de N +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EraLacunaJá consultadoPróxima buscaStatus
Era AVínculo localBiografia e busca datadaArquivo municipalaberta
Era BInstrumento da obraNotícias e portalDOE-BAprioridade
Era CFase atualCâmara e CGUDocumento financeiroaberta
Fora de eraAcervo pré-2011Base read-onlyAcervo físicoaberta
+
+ +
+ Dossiê por cidade · Lacunaspág. 13/N +
+
+
+ +
+

+ Cena 14 · Notícias e documentos — lista corrente +

+
+
+
+

Dossiê de atuação pública · Município de Exemplo

+

Notícias e documentos consultados

+

Inventário datado que sustenta as seções anteriores

+
+
+

Gerado em DD/MM/AAAA

+

pág. 14/N

+
+
+
+
+
+

Corpus consultado

+

Lista completa, em folhas correntes

+
+ N de N +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DataDocumento / notíciaOrigemUso no dossiêLink
DD/MM/AAAADocumento municipal AÓrgão oficialEra A · vínculoabrir
DD/MM/AAAANotícia institucional BSESABEra B · entregaabrir
DD/MM/AAAARegistro financeiro CPortal oficialEra C · faseabrir
DD/MM/AAAATramitação DCâmaraEra C · atuaçãoabrir
DD/MM/AAAADocumento regional EEstadoRegião / poloabrir
+

Continuação automática; nenhum documento é descartado para caber.

+
+
+ Dossiê por cidade · Notícias e documentospág. 14/N +
+
+
+ +
+

+ Cena 15 · Acervo — amostra declarada +

+
+
+
+

Dossiê de atuação pública · Município de Exemplo

+

Acervo interno de falas

+

Amostra declarada do recorte municipal read-only (2011+)

+
+
+

Gerado em DD/MM/AAAA

+

pág. 15/N

+
+
+
+
+

Universo recuperado

+

N falas

+

Menção municipal confirmada.

+
+
+

Amostra exibida

+

N de N

+

Quantidade e critério explícitos.

+
+
+
+

Amostra com link direto

+

Falas selecionadas para conferência

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DataTemaTrecho / contextoAbrangênciaLink
DD/MM/AAAATema AResumo fiel da fala.municípioabrir
DD/MM/AAAATema BMenção direta confirmada.municípioabrir
DD/MM/AAAATema CContexto parlamentar.municípioabrir
DD/MM/AAAATema DRecorte selecionado.municípioabrir
+
+
+ Dossiê por cidade · Acervo · amostra declaradapág. 15/N +
+
+
+ +
+

+ Cena 16 · Fontes e limites +

+
+
+
+

Dossiê de atuação pública · Município de Exemplo

+

Fontes e limites

+

O que foi consultado, o que não foi achado e o que não se pode afirmar

-

Gerado em 17/09/2026

-

INSUMO INTERNO

-

pág. 5/5

+

INSUMO INTERNO

+

Gerado em DD/MM/AAAA

+

pág. N/N

- -
-
-
-

Lacunas explícitas

-

O que não foi encontrado

-
-

- Não completar por memória ou inferência +

+

Hierarquia de fontes

+

Lastro visível, item a item

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CamadaUsoLimite
Fontes primáriasAtos, portais, tramitações e documentos oficiais.Fase e autoria só conforme o registro.
Fontes institucionaisNotícias de município, Estado e órgãos.Anúncio não prova execução.
Acervo internoFalas e recortes de 2011+.Não cobre a carreira anterior.
Busca web datadaLocalização e triangulação.Sem lastro suficiente vira lacuna.
+
+
+
+

Limites

+
    +
  • Município, região e polo não se somam.
  • +
  • Autorizado, empenhado, liquidado e pago não são equivalentes.
  • +
  • “Não localizado” nunca significa zero.
  • +
+
+
+

Defeso 2026

+

+ Documento interno de pesquisa. Não circular, publicar ou converter automaticamente + em peça de campanha.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Tabela de lacunas explícitas e próximos passos de apuração -
LacunaOnde foi buscadoLimite editorialPróximo passo
Pagamento do valor ilustrativoPortal da Transparência e documentos da emenda; consulta em 17/09/2026.Falar apenas em “empenhado”; não usar “pago” ou “entregue”. - Reconsultar documento de execução. - (fonte) -
Ato primário da Era ABiografia Câmara, DOU e acervo web do Ministério da Saúde.Não atribuir ação local ao cargo sem documento contemporâneo. - Solicitar busca em acervo físico. - (fonte) -
Municípios atendidos pelo poloSESAB, CNES e pesquisa web datada.Não informar total de beneficiários nem somar ao município. - Obter relação oficial de referência. - (fonte) -
Vínculo local de honrariaBiografia oficial e imprensa local.Não tratar citação secundária como confirmação documental. - Localizar decreto, ata ou diploma. - (fonte) -
- -
-

Lista clicável

-

Notícias e documentos consultados

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Fontes consultadas com largura fixa: data 9%, veículo 14%, título 49% e link 28% -
DataVeículoTítuloLink
—/—/24Fonte oficialDocumento de exemplo sobre recurso municipal — título a confirmar - (fonte) · Portal -
—/—/23CâmaraPronunciamento de exemplo com menção ao município - (fonte) · Câmara -
—/—/12SESABRegistro de exemplo sobre serviço de referência regional - (fonte) · SESAB -
17/09/26CNES - Consulta de estabelecimento — resultado insuficiente para provar alcance - regional - - (fonte) · CNES -
-
- -
-
-

Limites de cobertura

-
    -
  • - O acervo interno de falas cobre 2011+; períodos anteriores dependem de fontes - externas. -
  • -
  • Resultado de busca não prova ausência histórica.
  • -
  • Emenda de bancada ou relator só recebe autoria quando a fonte a confirma.
  • -
-
-
-

Regras para uso editorial

-
    -
  • Sem fonte, não publica.
  • -
  • URL e data acompanham cada afirmação não trivial.
  • -
  • Município, região e polo permanecem separados.
  • -
  • Valor sempre informa a fase de execução.
  • -
-
-
- - -
- Dossiê Solla por cidade · Fontes e limites · INSUMO INTERNOGerado em 17/09/2026 · pág. 5/5 + Dossiê por cidade · Fontes e limitespág. N/N
diff --git a/docs/plans/dossie-solla-instituicao-ui-design.html b/docs/plans/dossie-solla-instituicao-ui-design.html index f26ac426..9b98bb8e 100644 --- a/docs/plans/dossie-solla-instituicao-ui-design.html +++ b/docs/plans/dossie-solla-instituicao-ui-design.html @@ -10,7 +10,7 @@ --font-sans: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; --color-ink: #172033; --color-ink-soft: #435264; - --color-paper: #ffffff; + --color-paper: #fff; --color-workspace: #d8dde1; --color-line: #cbd5dc; --color-wash: #f3f6f8; @@ -18,14 +18,11 @@ --color-accent-soft: #e8f0f4; --color-warning: #8a5a18; --color-warning-soft: #fff7e8; - --radius-sm: 0.125rem; - --radius-md: 0.25rem; } - :root { --ink: #172033; --ink-soft: #435264; - --paper: #ffffff; + --paper: #fff; --workspace: #d8dde1; --line: #cbd5dc; --wash: #f3f6f8; @@ -34,7 +31,6 @@ --warning: #8a5a18; --warning-soft: #fff7e8; } - * { box-sizing: border-box; } @@ -48,20 +44,20 @@ font-family: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; -webkit-font-smoothing: antialiased; } - .sheet { + position: relative; width: 210mm; + min-height: 297mm; height: 297mm; - padding: 13mm 15mm 12mm; + padding: 12mm 14mm 10mm; overflow: hidden; display: flex; flex-direction: column; background: var(--paper); box-shadow: 0 18px 44px rgb(23 32 51 / 18%); font-size: 10.5pt; - line-height: 1.38; + line-height: 1.32; } - .sheet p, .sheet ul, .sheet ol, @@ -73,46 +69,63 @@ text-decoration: underline; text-underline-offset: 1.5px; } - .sheet a:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; + .accent-bar { + position: absolute; + left: 0; + top: 0; + width: 4mm; + height: 297mm; + background: #315c75; } - .report-header { display: flex; - align-items: flex-start; justify-content: space-between; gap: 8mm; + align-items: flex-start; padding-bottom: 3mm; - border-bottom: 0.45mm solid var(--ink); + border-bottom: 0.45mm solid #172033; } .report-kicker { margin: 0; - color: var(--accent); + color: #315c75; font-size: 8pt; - line-height: 1.2; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; } + .sheet-title { + margin: 1.2mm 0 0; + font-size: 18pt; + font-weight: 700; + line-height: 1.1; + letter-spacing: -0.02em; + } + .sheet-sub { + margin: 0.8mm 0 0; + font-size: 10.5pt; + color: #435264; + } .report-meta { - min-width: 52mm; + min-width: 43mm; margin: 0; - color: var(--ink-soft); - font-size: 7.8pt; + color: #435264; + font-size: 8pt; line-height: 1.45; text-align: right; font-variant-numeric: tabular-nums; } + .report-meta p { + margin: 0; + } .report-footer { margin-top: auto; padding-top: 2.5mm; - border-top: 0.25mm solid var(--line); + border-top: 0.25mm solid #cbd5dc; display: flex; justify-content: space-between; gap: 5mm; - color: var(--ink-soft); - font-size: 7.8pt; + color: #435264; + font-size: 8pt; line-height: 1.3; } .section-title { @@ -123,42 +136,66 @@ } .eyebrow { margin: 0 0 1.4mm; - color: var(--accent); + color: #315c75; font-size: 8pt; - line-height: 1.2; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } + .warning-text { + color: #8a5a18; + } + .muted { + color: #435264; + } + .small { + font-size: 10.5pt; + line-height: 1.28; + } + .meta { + font-size: 7.7pt; + line-height: 1.2; + color: #435264; + } + .strong { + font-weight: 600; + } + .tabular { + font-variant-numeric: tabular-nums; + } + .block { + margin-top: 3.4mm; + break-inside: avoid; + } + .block-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 4mm; + } .source-link { display: inline-flex; align-items: baseline; - gap: 1mm; white-space: nowrap; - font-size: 8.5pt; + font-size: 9pt; font-weight: 600; } - .source-link svg { - width: 3mm; - height: 3mm; - transform: translateY(0.35mm); - } .scope, .phase, .identity-badge { display: inline-flex; align-items: center; min-height: 5mm; - padding: 0.45mm 1.6mm; - border-radius: 99px; - font-size: 7.4pt; + padding: 0.4mm 1.6mm; + font-size: 7.5pt; line-height: 1; font-weight: 700; - letter-spacing: 0.035em; + letter-spacing: 0.04em; text-transform: uppercase; } .scope { - border: 0.25mm solid #8da3b0; + border: 0.25mm solid #9aacb7; + border-radius: 99px; color: #304758; background: #f7fafb; } @@ -186,32 +223,33 @@ background: #fff2d9; } .identity-badge { - border: 0.25mm solid var(--line); - color: var(--ink-soft); - background: var(--wash); + border: 0.25mm solid #cbd5dc; + border-radius: 99px; + color: #435264; + background: #f3f6f8; } - .document-table { width: 100%; table-layout: fixed; border-collapse: collapse; - font-size: 9.4pt; - line-height: 1.25; + font-size: 10.5pt; + line-height: 1.22; + margin-top: 2.5mm; } .document-table th { - padding: 1.7mm 1.5mm; - border-top: 0.25mm solid var(--line); + padding: 1.7mm 1.6mm; + border-top: 0.25mm solid #cbd5dc; border-bottom: 0.35mm solid #9caab3; - color: var(--ink-soft); - background: var(--wash); - font-size: 7.3pt; + background: #f3f6f8; + color: #435264; + font-size: 7.5pt; font-weight: 700; - letter-spacing: 0.04em; + letter-spacing: 0.045em; text-align: left; text-transform: uppercase; } .document-table td { - padding: 2mm 1.5mm; + padding: 2mm 1.6mm; border-bottom: 0.25mm solid #dce3e7; vertical-align: top; overflow-wrap: anywhere; @@ -220,18 +258,203 @@ background: #fafcfd; } .numeric { - white-space: nowrap; font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .panel { + border: 0.25mm solid #cbd5dc; + border-radius: 0.25rem; + padding: 3mm; + break-inside: avoid; + } + .panel-warning { + border: none; + border-left: 3mm solid #8a5a18; + background: #fff7e8; + } + .tight-list { + margin: 0; + padding-left: 4mm; + } + .tight-list li { + margin-bottom: 1mm; + } + .aside-accent { + border-radius: 0.25rem; + background: #e8f0f4; + padding: 3mm; + font-size: 10.5pt; + line-height: 1.35; + } + .rule-warning { + display: flex; + align-items: flex-start; + gap: 3mm; + border: 0.45mm solid #8a5a18; + background: #fff7e8; + border-radius: 0.25rem; + padding: 4mm; + margin-top: 4mm; + } + .warn-icon { + width: 5mm; + height: 5mm; + color: #8a5a18; + flex-shrink: 0; + margin-top: 0.5mm; + } + .cover-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8mm; + border-bottom: 0.25mm solid #cbd5dc; + padding-bottom: 4mm; + } + .cover-series { + margin: 2mm 0 0; + font-size: 10.5pt; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #435264; + } + .internal-note { + border: 0.25mm solid #cbd5dc; + background: #f3f6f8; + padding: 2mm 3mm; + text-align: right; + font-size: 8pt; + line-height: 1.5; + color: #435264; + border-radius: 0.25rem; + } + .cover-block { + margin-top: 28mm; + max-width: 150mm; + } + .cover-title { + margin: 0; + font-size: 34pt; + font-weight: 700; + line-height: 1.03; + letter-spacing: -0.035em; + } + .cover-sub { + margin: 5mm 0 0; + max-width: 125mm; + font-size: 15pt; + line-height: 1.4; + color: #435264; + } + .cover-dl { + display: grid; + grid-template-columns: 42mm 1fr; + border-block: 0.25mm solid #cbd5dc; + margin: 22mm 0 0; + font-size: 11pt; + } + .cover-dl dt, + .cover-dl dd { + padding: 3mm 0; + border-bottom: 0.25mm solid #cbd5dc; + } + .cover-dl dt { + font-weight: 600; + color: #435264; + } + .cover-dl dd { + margin: 0; + font-weight: 600; + } + .cover-dl dt:last-of-type, + .cover-dl dd:last-of-type { + border-bottom: 0; + } + .cover-grid { + display: grid; + grid-template-columns: 1fr 56mm; + gap: 5mm; + margin-top: 6mm; + } + .how-to { + border-radius: 0.25rem; + background: #e8f0f4; + padding: 4mm; + } + .how-to-title { + margin: 0; + font-size: 10.5pt; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #315c75; } .asset-box { border: 0.35mm dashed #80919d; color: #4c5c68; background: #f7f9fa; + padding: 3mm; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + min-height: 30mm; + border-radius: 0.25rem; + font-size: 8pt; + font-weight: 600; } - .empty-panel { - border: 0.45mm dashed #b18549; - color: #684819; - background: var(--warning-soft); + .cover-foot { + margin-top: auto; + } + .ink-rule { + height: 0.5mm; + background: #172033; + margin-bottom: 5mm; + } + .cover-foot-row { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 8mm; + } + .opening-paragraph { + margin: 0 0 3.4mm; + font-size: 11pt; + line-height: 1.45; + } + .summary-top { + display: grid; + grid-template-columns: 58mm 1fr; + gap: 4mm; + margin-top: 4mm; + } + .id-card { + border: 0.25mm solid #cbd5dc; + border-radius: 0.25rem; + background: #f3f6f8; + padding: 3mm; + } + .id-card dl { + display: grid; + grid-template-columns: 22mm 1fr; + gap: 1mm 2mm; + font-size: 9pt; + } + .id-card dt { + font-weight: 600; + color: #435264; + } + .id-card dd { + margin: 0; + } + .vinc-timeline { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2mm; + border-top: 0.25mm solid #cbd5dc; + padding-top: 3mm; + margin-top: 3mm; } .timeline-item { position: relative; @@ -244,12 +467,165 @@ left: 0; width: 2.6mm; height: 2.6mm; - border: 0.5mm solid var(--paper); + border: 0.5mm solid #fff; border-radius: 99px; - background: var(--accent); - box-shadow: 0 0 0 0.35mm var(--accent); + background: #315c75; + box-shadow: 0 0 0 0.35mm #315c75; + } + .deliveries { + border-block: 0.25mm solid #cbd5dc; + margin-top: 1.8mm; + } + .delivery-row { + display: grid; + grid-template-columns: 26mm 1fr 42mm; + gap: 3mm; + padding: 2mm 0; + border-bottom: 0.25mm solid #cbd5dc; + } + .delivery-row:last-child { + border-bottom: 0; + } + .page1-grid, + .synthesis-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3mm; + margin-top: 3mm; + } + .synthesis-lines { + margin: 0; + padding-left: 4.5mm; + font-size: 9.5pt; + line-height: 1.35; + } + .synthesis-lines li { + margin-bottom: 1.8mm; + } + .chart-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3mm; + margin-top: 3mm; + } + .chart-card { + border: 0.25mm solid #cbd5dc; + border-radius: 0.25rem; + padding: 3mm; + break-inside: avoid; + } + .chart { + display: block; + width: 100%; + height: auto; + } + .chart-title { + margin: 0 0 2mm; + font-size: 9.5pt; + font-weight: 600; + } + .chart-label { + font-size: 7.4pt; + fill: #435264; + } + .chart-value { + font-size: 7.6pt; + fill: #1f2933; + font-weight: 700; + } + .chart-legend { + margin: 1.5mm 0 0; + font-size: 7.6pt; + color: #435264; + } + .value-list { + list-style: none; + margin: 0; + padding: 0; + } + .value-list li { + display: grid; + grid-template-columns: 1fr 20mm 18mm; + gap: 1.6mm; + align-items: center; + padding: 0.9mm 0; + border-bottom: 0.25mm solid #e3e9ec; + font-size: 8.2pt; + } + .value-list-bar { + display: block; + height: 2.2mm; + background: #eef2f5; + border-radius: 1mm; + overflow: hidden; + } + .value-list-bar span { + display: block; + height: 100%; + background: #315c75; + } + .value-list-value { + text-align: right; + font-weight: 700; + white-space: nowrap; + } + .era-method { + display: grid; + grid-template-columns: 1fr 55mm; + gap: 4mm; + margin-top: 4mm; + } + .era-summary { + margin-top: 3.4mm; + border-left: 1.2mm solid #315c75; + background: #f7fafb; + padding: 2.6mm 3mm; + } + .era-summary p { + margin: 0; + font-size: 10pt; + line-height: 1.4; + } + .action-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3mm; + margin-top: 3mm; + } + .action-head { + display: flex; + justify-content: space-between; + gap: 3mm; + } + .action-title { + margin: 2mm 0 0; + font-size: 10.5pt; + font-weight: 600; + } + .scope-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 3mm; + margin-top: 4mm; + } + .scope-card { + border: 0.25mm solid #cbd5dc; + border-radius: 0.25rem; + padding: 3mm; + } + .card-sector { + border-color: #c9b483; + background: #fffcf6; + } + .card-network { + border-color: #b9acc3; + background: #fcf9fd; + } + .scope-count { + margin: 3mm 0 0; + font-size: 16pt; + font-weight: 600; } - @page { size: A4; margin: 0; @@ -269,15 +645,8 @@ .scene { margin: 0 !important; break-after: page; - page-break-after: always; - } - .scene:last-child { - break-after: auto; - page-break-after: auto; } .sheet { - width: 210mm; - height: 297mm; box-shadow: none; } } @@ -286,98 +655,68 @@

- Gate visual · Impeccable C + Gate visual · re-sincronizado com o port de 18/09/2026

Dossiê Solla por instituição — relatório A4

- Documento interno para preparar agendas institucionais com lastro. Oito páginas-base e uma - variante de lacuna explícita; conteúdo e valores são placeholders deliberados. + Documentação hi-fi do layout entregue: folhas correntes, síntese, gráficos, consolidação por + era e listas completas. Conteúdo abaixo é deliberadamente genérico.

-
- A4 · 210 × 297 mm - corpo 10,5 pt - instituição ≠ setor ≠ rede - fontes item a item - sem CTA de campanha -
-
- -
-

- Cena 01 · Capa / identificação institucional +

+

+ Cena 01 · Capa

-
-
-
+
+
+
-

Núcleo de comunicação · pesquisa documental

-

- Série institucional · agenda com lastro -

+

Comunicação · pesquisa documental

+

Série institucional · agenda com lastro

-
+
INSUMO INTERNO — defeso 2026
Não circular · não publicar
- -
+

Dossiê de atuação pública

-

+

Dossiê Solla —
Instituição Exemplo

-

+

O que Jorge Solla fez por, na e com a instituição ao longo da carreira

- -
-
Identificação
-
+
+
Identificação
+
Universidade Federal BA
-
Data de geração
-
DD de mês de AAAA · HHhMM
-
Escopo e versão
-
- Eras A / B / C · instituição, setor e rede separados · versão 01 -
+
Data de geração
+
DD/MM/AAAA · HHhMM
+
Escopo e versão
+
Eras A / B / C · instituição, setor e rede separados · versão 01
- -
-
-

- Como usar -

-

+

+
+

Como usar

+

Localize o vínculo ou a entrega, confira fonte, data, fase e abrangência. Transforme - toda lacuna em tarefa de apuração. Este arquivo não é fala pronta nem peça pública. + toda lacuna em tarefa de apuração.

-
- NEEDS ASSET
selo da instituição
uso autorizado e origem -
+
NEEDS ASSET
selo autorizado
e origem
- -
-
-
-

- Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara dos - Deputados 2015–2027. Sem fonte, não entra como entrega. +

+
+
+

+ Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara + 2015–2027.

- -
-

- Cena 02 · Página 1 — resumo de uma olhada +

+

+ Cena 02 · A contribuição (carta)

-
+

Dossiê institucional · Instituição Exemplo

-

- Resumo de uma olhada -

-

- Universidade · Federal · BA · informação datada e conferível +

O que Jorge Solla fez pela e na instituição

+

+ Redação de síntese sobre os pontos com fonte — nada aqui acrescenta fato novo

-

INSUMO INTERNO — defeso 2026

+

INSUMO INTERNO

Gerado em DD/MM/AAAA

-

pág. 1/7

+

pág. 2/N

+
+

+ A primeira fase reúne formação, vínculos técnicos e responsabilidades documentadas. A + redação conecta apenas itens que aparecem nas seções por era e preserva o alcance + atribuído pela fonte. +

+

+ Na gestão estadual, convênios, equipamentos e políticas são descritos com objeto, + período e fase. A instituição, o setor e a rede permanecem separados. +

+

+ No mandato federal, recursos, proposições e articulações mantêm autoria, ano e estágio + de execução visíveis. O texto não converte proposta em entrega nem empenho em + pagamento. +

+
+ +
+ Dossiê institucional · A contribuição · INSUMO INTERNOpág. 2/N +
+
+
-
-
+
+

+ Cena 03 · Resumo de uma olhada +

+
+
+
+

Dossiê institucional · Instituição Exemplo

+

Resumo de uma olhada

+

Informação datada e pronta para conferência

+
+
+

INSUMO INTERNO

+

Gerado em DD/MM/AAAA

+

pág. 3/N

+
+
+
+

Identificação

-
-
Nome
-
Instituição Exemplo
-
Tipo
-
Universidade
-
Esfera
-
Federal
-
Alcance
-
BA
-
Alias
-
Sigla Exemplo
+
+
Nome
+
Instituição Exemplo
+
Tipo
+
Universidade
+
Esfera
+
Federal
+
Alcance
+
BA

01 · vínculo com a instituição

Linha do tempo documentada

-
+
-

AAAA–AAAA

-

- Vínculo acadêmico de exemplo -

-

Fonte oficial · data

+ AAAA +

Vínculo documentado

+ fonte · data
-

AAAA

-

Articulação registrada

-

Documento · data

+ AAAA +

Articulação registrada

+ fonte · data
-

AAAA

-

- Reconhecimento institucional -

-

Ato primário · data

+ AAAA +

Entrega confirmada

+ fonte · data
- -
-
+
+

02 · destaques com lastro

-

- Principais entregas localizadas -

+

Principais entregas localizadas

-

- Valores ilustram o formato · não são fatos -

+ Cada valor mantém fase e abrangência
-
-
+
+
instituição -

Era C · AAAA

+

Era C · AAAA

-

Objeto documentado da Instituição Exemplo

-

- Finalidade e autoria só entram após conferência da fonte primária. -

+ Objeto documentado da instituição +

Descrição conferível e sem atribuição ampliada.

-

R$ 0,0 mi

- empenhado - + R$ —
pago
(fonte)
-
+
setor -

Era B · AAAA

+

Era B · AAAA

-

Ação de exemplo no setor relacionado

-

- Alcance documentado, sem atribuição exclusiva à instituição. -

+ Ação de alcance compartilhado +

Não somar ao recorte institucional.

-

R$ 0,0 mi

- liquidado - -
-
-
-
- rede -

Era A · AAAA

-
-
-

Articulação de exemplo com rede correlata

-

- A instituição aparece no contexto, não como beneficiária única. -

-
-
-

R$ 0,0 mi

- pago - + R$ —
não informada
(fonte)
-

- Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. Restos a pagar também - aparecem como fase própria. -

- -
-
+
+

Gancho para a agenda

-

- Ponto de conversa: vínculo institucional documentado + entrega com - estágio confirmado. Abrir a fonte antes de adaptar para fala. -

+

Vínculo + entrega + estágio confirmado, sempre com a fonte aberta.

-
-

O que falta

-
    -
  • Confirmar fase atual do valor.
  • -
  • Recuperar ato primário do vínculo.
  • -
  • Delimitar alcance do item de rede.
  • +
    +

    O que falta

    +
      +
    • Confirmar fase atual.
    • +
    • Recuperar ato primário.
    -

    - Ausência é resultado: não completar por inferência. -

-
- Dossiê Solla por instituição · Instituição Exemplo · INSUMO INTERNO — defeso - 2026pág. 1/7 + Dossiê institucional · Resumopág. 3/N
- -
-

- Cena 03 · Era A — vínculo técnico e formação +

+

+ Cena 04 · Síntese

-
+

Dossiê institucional · Instituição Exemplo

-

- Era A · Formação e gestão pública -

-

- Até 2006 · vínculos, pesquisa, gestão municipal e federal +

Síntese do que foi localizado

+

+ Leitura dos números com lastro — contagens por recorte, nunca soma entre eles

-

INSUMO INTERNO — defeso 2026

+

INSUMO INTERNO

Gerado em DD/MM/AAAA

-

pág. 2/7

+

pág. 4/N

-
-
-

Recorte e método

-

- Registros anteriores a 2007 que citem nominalmente a instituição ou comprovem - vínculo. Cargo geral não prova ação institucional. -

+
+

Leitura rápida

+

O que os pontos com fonte mostram

+
    +
  • + Foram localizados N pontos com fonte no recorte institucional, distribuídos pelas + eras A, B e C. +
  • +
  • Abrangência: instituição, setor e rede permanecem em contagens separadas.
  • +
  • + Itens com valor mantêm a fase: autorizado, empenhado, liquidado, pago, restos ou não + informada. +
  • +
  • Recursos executados e propostas sem fase aparecem em leituras distintas.
  • +
  • + Temas com mais registros orientam a leitura, sem substituir as listas completas. +
  • +
  • O acervo interno informa universo e amostra com links diretos.
  • +
  • Lacunas declaradas aparecem por era e viram tarefas de apuração.
  • +
+
+
+
+

Cobertura das eras

+
    +
  • Era A — N pontos nesta seção
  • +
  • Era B — N pontos nesta seção
  • +
  • Era C — N pontos nesta seção
  • +
- -
-
-
-
-

Entregas localizadas

-

Objeto, valor, fase e alcance

-
-

Placeholders de estrutura

+
+

Guardas de leitura

+
    +
  • Setor e rede nunca somados à instituição.
  • +
  • Empenho ≠ pagamento.
  • +
  • Sem fonte, não publica.
  • +
- - - - - - - - - - + +
+

Recursos por fase

+

Valores localizados, sem consolidar fases

+
- Entregas da Era A -
- - - - - + + - - - - - - + + + - - - - - - + + + + + + + +
ObjetoValorAno FaseAbrangênciaFonteItensValor
Objeto documentado de exemploR$ 0,0 miAAAAautorizadoinstituição - - fonte · DD/MM - pagoNR$ —
Ação técnica com setor relacionadoR$ —AAAAnão informadosetor - - fonte · DD/MM - autorizadoNR$ —
não informadaNR$ —
-

- Sem ato contemporâneo, registrar como lacuna — nunca atribuir pela plausibilidade do - cargo. +

+ Soma por fase apenas; fases não são equivalentes entre si.

-
+
+ Dossiê institucional · Síntesepág. 4/N +
+
+
+ +
+

+ Cena 05 · Gráficos consolidados +

+
+
+
+

Dossiê institucional · Instituição Exemplo

+

Gráficos consolidados

+

Recursos e entregas — valores sempre com a fase

+
+
+

INSUMO INTERNO

+

Gerado em DD/MM/AAAA

+

pág. 5/N

+
+
+
+
+

Recursos com execução por ano, empilhados por fase (R$)

+ + + + + + + + AAAA + AAAA + AAAA + +

verde: pago/liquidado · âmbar: autorizado/empenhado.

+
+
+

Propostas sem fase informada (R$)

+
    +
  • + AAAA · Proposta documentada AR$ — +
  • +
  • + AAAA · Articulação documentada BR$ — +
  • +
  • + AAAA · Convênio citado CR$ — +
  • +
+

propostas citadas, sem execução declarada.

+
+
+

Abrangência dos pontos com fonte

+ + instituição + + setor + + rede + + +

recortes não somáveis.

+
+
+

Trajetória: pontos com fonte por ano

+ + + + + + + + + + +
+
+

Áreas com mais registros

+ + Área A + + Área B + + Área C + + Área D + + +
+
+

Lacunas declaradas por era

+ + Era A + + Era B + + Era C + + +

cada lacuna é tarefa de apuração.

+
+
+

Acervo interno (read-only)

+

N

+

falas no recorte temático (2011+)

+
    +
  • Amostra declarada de N falas com link direto.
  • +
  • Recorte por tema; não somar à instituição.
  • +
+
+
+
+ Dossiê institucional · Gráficospág. 5/N +
+
+
+ +
+

+ Cena 06 · Era A +

+
+
+
+

Dossiê institucional · Instituição Exemplo

+

Era A · Carreira técnica e gestão até 2006

+

Formação, vínculos e gestão pública

+
+
+

Gerado em DD/MM/AAAA

+

pág. 6/N

+
+
+
+
+

Recorte e método

+

+ Registros anteriores a 2007 que citem a instituição ou comprovem vínculo. +

+
+ +
+
+

O que esta era entrega

+

+ Parágrafo de consolidação: formação, vínculo e papéis documentados aparecem antes da + lista detalhada; itens de setor não são somados à instituição. +

+
+

Atuação / vínculo registrado

Papéis com evidência visível

-
-
-
- instituiçãoAAAA–AAAA +
+
+
+ instituiçãoAAAA–AAAA
-

- Professor ou pesquisador — exemplo de preenchimento -

-

- Descrever unidade, papel e período apenas como constam no registro. -

- - fonte · data +

Vínculo documentado A

+

Descrição objetiva do papel e período.

+ (fonte)
-
-
- redeAAAA +
+
+ setorAAAA
-

- Participação em rede acadêmica — exemplo -

-

- Nomear a rede e explicar por que ela não equivale à instituição. -

- - fonte · data +

Atuação setorial B

+

Alcance separado da instituição.

+ (fonte)
-
- Dossiê institucional · Era A · INSUMO INTERNO — defeso 2026pág. 2/7 + Dossiê institucional · Era Apág. 6/N
-
-

- Cena 04 · Era B — gestão estadual +

+

+ Cena 07 · Era B

-
+

Dossiê institucional · Instituição Exemplo

-

- Era B · Secretaria da Saúde da Bahia -

-

- 2007–2014 · parceria, gestão e articulação institucional -

+

Era B · Secretaria Estadual de Saúde da Bahia

+

2007–2014 · parceria, gestão e articulação institucional

-

INSUMO INTERNO — defeso 2026

Gerado em DD/MM/AAAA

-

pág. 3/7

+

pág. 7/N

-
+

Recorte e método

-

- Ações da gestão estadual com menção nominal à instituição, ao setor ou à rede. - Diferenciar decisão, parceria anunciada e entrega executada. +

+ Equipamento, programa, convênio e obra, cada um com abrangência e fonte.

-
-
+
+

O que esta era entrega

+

+ Parágrafo de consolidação: entregas, parcerias e resultados da gestão estadual são + resumidos antes dos registros completos, sem converter anúncio em execução. +

+
+

Entregas localizadas

-

Objetos da era com fase explícita

- - - - - - - - - - +

Objeto, valor, fase e alcance

+
- Entregas da Era B -
@@ -816,168 +1236,67 @@ - - - - + + + + - + - - - + + + - - - - - - - - - +
Objeto
Parceria institucional de exemploR$ 0,0 miAAAAliquidadoConvênio documentadoR$ —AAAAnão informada instituição - - fonte · DD/MM - fonte
Programa setorial de exemploR$ 0,0 miAAAAPrograma em redeR$ —AAAA pagosetor - - fonte · DD/MM -
Cooperação em rede de exemploR$ —AAAAnão informado rede - - fonte · DD/MM - fonte
-

- Não converter anúncio, convênio ou liquidação em pagamento sem evidência da fase - correspondente. -

-
-

Atuação / vínculo registrado

-

Articulação e parceria documentadas

-
-
-
- instituiçãoAAAA -
-

- Articulação institucional de exemplo -

-

- Registrar participantes, objeto e resultado sem inferir causalidade. -

- - fonte · data -
-
-
- setorAAAA -
-

Ação para o setor de exemplo

-

- Explicar o alcance e por que não constitui entrega exclusiva à instituição. -

- - fonte · data -
-
-
-
- Dossiê institucional · Era B · INSUMO INTERNO — defeso 2026pág. 3/7 + Dossiê institucional · Era Bpág. 7/N
-
-

- Cena 05 · Era C — mandato federal +

+

+ Cena 08 · Era C

-
+

Dossiê institucional · Instituição Exemplo

-

- Era C · Deputado Federal -

-

- 2015–2027 · recursos, proposições, relatorias e articulação -

+

Era C · Deputado Federal

+

2015–2027 · recursos, proposições, relatorias e articulação

-

INSUMO INTERNO — defeso 2026

Gerado em DD/MM/AAAA

-

pág. 4/7

+

pág. 8/N

-
+

Recorte e método

-

- Itens ligados nominalmente à instituição e registros temáticos cuja abrangência - esteja comprovada. Autoria de emenda e relatoria sempre checadas. -

+

Autoria, ano, fase e abrangência checados item a item.

-
-
+
+

O que esta era entrega

+

+ Parágrafo de consolidação: o mandato reúne recursos com execução, propostas sem fase, + proposições e articulações. Cada valor conserva seu estágio e cada afirmação aponta + para o lastro. +

+
+

Entregas localizadas

Recursos e ações quantificáveis

- - - - - - - - - - +
- Entregas da Era C -
@@ -990,736 +1309,530 @@ - - - - + + + + - + - - - - + + + + - + - - - - + + + + - +
Objeto
Recurso institucional de exemploR$ 0,0 miAAAAempenhadoRecurso institucional AR$ —AAAApago instituição - - fonte · DD/MM - fonte
Ação parlamentar setorialR$ 0,0 miAAAArestosAção parlamentar BR$ —AAAAempenhado setor - - fonte · DD/MM - fonte
Projeto em rede de exemploR$ —AAAAnão informadoProjeto em rede CR$ —AAAAnão informada rede - - fonte · DD/MM - fonte
-

- Autorizado, empenhado, liquidado, pago e restos não formam um total único. -

-
-

Atuação / vínculo registrado

-

Mandato, relatoria e articulação

-
-
-
- instituiçãoCâmara · AAAA -
-

- Relatoria ou proposição de exemplo -

-

- A tramitação prova o papel exercido; não converter proposição em entrega - concluída. -

- - fonte · data -
-
-
- redeAgenda · AAAA -
-

- Audiência ou articulação de exemplo -

-

- Registrar pauta e participantes; resultado material fica separado. -

- - fonte · data -
-
-
-
- Dossiê institucional · Era C · INSUMO INTERNO — defeso 2026pág. 4/7 + Dossiê institucional · Era Cpág. 8/N
- -
-

- Cena 06 · Abrangência — instituição × setor × rede +

+

+ Cena 09 · Era C — continuação 2

-
+

Dossiê institucional · Instituição Exemplo

-

- Abrangência: instituição × setor × rede -

-

- Três recortes, três leituras, nenhuma soma combinada +

Era C · Deputado Federal — continuação 2

+

+ Folha corrente · a lista flui sem corte nem descarte silencioso

-

INSUMO INTERNO — defeso 2026

Gerado em DD/MM/AAAA

-

pág. 5/7

+

pág. 9/N

-
-
- - - - -
-

- Setor/rede não é a instituição. Não some os recortes. -

-

- 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. -

-
-
-
-
-

Leitura lado a lado

-

O alcance acompanha cada evidência

-
-
-
- instituiçãoDIRETO +
+

Atuação / vínculo registrado

+

Mandato, relatoria e articulação

+
+
+
+ instituiçãoAAAA
-

R$ 0,0 mi

-

exemplo · fase identificada

-
    -
  • - Objeto nomeia a Instituição Exemplo. - fonte -
  • -
  • - Vínculo registrado em unidade identificada. - fonte -
  • -
+

Proposição documentada

+

Tramitação visível, sem converter proposição em entrega.

+ (fonte)
-
-
- setorNÃO SOMAR +
+
+ redeAAAA
-

R$ 0,0 mi

-

exemplo · alcance compartilhado

-
    -
  • - Ação para a área profissional relacionada. - fonte -
  • -
  • - Evidência cita o setor, não a entidade. - fonte -
  • -
+

Articulação documentada

+

+ Pauta e participantes registrados; resultado material separado. +

+ (fonte)
-
-
- redeNÃO SOMAR +
+
+ setorAAAA
-

R$ 0,0 mi

-

exemplo · entidades correlatas

-
    -
  • - Rede parceira com vínculo documentado. - fonte -
  • -
  • - Instituição integra o contexto, não o total. - fonte -
  • -
+

Audiência setorial

+

Alcance descrito conforme a fonte.

+ (fonte) +
+
+
+ instituiçãoAAAA +
+

Defesa institucional

+

Registro e contexto completos.

+ (fonte)
-
-
-
-

Evidência de alcance

-

Por que cada item está neste recorte

-
-

Sem evidência, vira lacuna

+
+ Dossiê institucional · Era C · continuação 2pág. 9/N +
+
+
+ +
+

+ Cena 10 · Abrangência — lista completa +

+
+
+
+

Dossiê institucional · Instituição Exemplo

+

Abrangência · instituição

+

+ Lista completa do recorte direto — setor e rede ficam fora desta conta +

- - - - - - - - +
+

Gerado em DD/MM/AAAA

+

pág. 10/N

+
+ +
+
+ instituição +

N

+

itens diretos

+
+
+ setor +

N

+

não somar

+
+
+ rede +

N

+

não somar

+
+
+
+
+
+

Instituição

+

Todos os itens do recorte

+
+ N de N +
+
- Itens de setor e rede com evidência de alcance -
- - + + + - - - - + + + + + - - - - + + + + + - - - - + + + + + + + + + + + + + + + + + + +
ItemAbrangênciaEvidência de alcanceEra / anoValor / faseEvidência Fonte
Ação setorial de exemplosetorDocumento nomeia a área, mas não individualiza a instituição.fonte · dataItem institucional 01Era A · AAAAR$ —Vínculo direto comprovado.fonte
Articulação em rederedeA instituição consta como integrante entre outras entidades.fonte · dataItem institucional 02Era B · AAAAnão informadaObjeto nomeia a instituição.fonte
Entrega institucionalinstituiçãoObjeto e destinatário aparecem nominalmente no ato.fonte · dataItem institucional 03Era C · AAAApagoBeneficiário nominal conferido.fonte
Item institucional 04Era C · AAAAautorizadoFase mantida junto ao valor.fonte
Item institucional 05Era C · AAAAR$ —Articulação documentada.fonte
-
-
-
-

Gancho possível

-

- Ângulo: partir do vínculo direto e explicar, em separado, como - setor e rede ampliam o contexto da agenda. -

-
-
-

Lacuna prioritária

-

- Obter documento que individualize a Instituição Exemplo. Até lá, manter o item - apenas em setor/rede. -

-
+

+ Lista completa no documento gerado; se exceder a folha, continua em “Abrangência · + instituição — continuação N”. +

- Dossiê institucional · Abrangência · INSUMO INTERNO — defeso 2026pág. 5/7 + Dossiê institucional · Abrangênciapág. 10/N
- -
-

- Cena 07 · Títulos, honrarias e vínculos +

+

+ Cena 11 · Lacunas — lista corrente

-
+

Dossiê institucional · Instituição Exemplo

-

- Títulos, honrarias e vínculos -

-

- Reconhecimento não substitui entrega; vínculo não prova resultado -

+

Lacunas explícitas

+

O que não foi localizado vira tarefa de apuração

-

INSUMO INTERNO — defeso 2026

Gerado em DD/MM/AAAA

-

pág. 6/7

+

pág. 11/N

-
-

Registros confirmados

-

Data, natureza e fonte primária

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Títulos, honrarias e vínculos por data e fonte -
DataNaturezaRegistroFonte
DD/MM/AAAAvínculo - Papel institucional de exemplo
Unidade e período conforme ato. -
- Ato institucional · URL
consulta DD/MM/AAAA -
DD/MM/AAAAhonraria - Título honorífico de exemplo
Nome oficial, órgão concedente e motivação. -
- Resolução ou diploma · URL
consulta DD/MM/AAAA -
DD/MM/AAAApesquisa - Atividade acadêmica de exemplo
Papel exato sem ampliar autoria ou duração. -
- Repositório institucional · URL
consulta DD/MM/AAAA -
DD/MM/AAAAhomenagem - Homenagem registrada em sessão
Evento, papel e contexto documentados. -
- Ata ou notícia oficial · URL
consulta DD/MM/AAAA -
-
-
-
-

Como ler

-
-
-

Vínculo documentado

-

- Informa papel, unidade e período. Não autoriza atribuir toda ação posterior da - instituição. -

-
-
-

Honraria documentada

-

- Registra reconhecimento e órgão concedente. Não vira prova de recurso ou - entrega. -

-
-
-
-
- NEEDS ASSET
reprodução do ato,
diploma ou clipping
origem, autoria e licença -
-
-
-
-

Gancho para a agenda

-

- Mencionar um vínculo só na formulação que a fonte sustenta e conectá-lo a uma - entrega comprovada, se houver. -

-
-
-

Pendência documental

-

- Clipping cita uma homenagem, mas ato ou diploma não foi localizado. Manter fora da - fala até confirmação. -

-
-
-
- Dossiê institucional · Títulos e vínculos · INSUMO INTERNO — defeso 2026pág. 6/7 -
-
-
- - -
-

- Cena 08 · Fontes, limites e defeso -

-
-
-
-

Dossiê institucional · Instituição Exemplo

-

- Fontes e limites -

-

- O que foi buscado, o que não foi achado e o que não se pode afirmar -

-
-
-

INSUMO INTERNO — defeso 2026

-

Gerado em DD/MM/AAAA

-

pág. 7/7

-
-
-
-
+
+
-

Lacunas explícitas

-

O que não foi encontrado

+

Ausência é resultado

+

Lista completa de lacunas

-

- Não completar por memória ou inferência -

+ N de N
- - - - - - - - +
- Lacunas e próximos passos -
+ - - - + + + - - - - + + + + + - - - - + + + + + - - - - + + + + + + + + + + + +
Era LacunaOnde foi buscadoLimite editorialPróximo passoJá consultadoPróxima buscaStatus
Fase do valorPortal oficial e documento financeiro · DD/MM/AAAA.Não usar “pago” nem “entregue”.Reconsultar execução.Era AAto primário do vínculoBiografias oficiaisDiário e acervo institucionalaberta
Ato do vínculoSite institucional, diário e repositório.Não afirmar período completo.Solicitar acervo físico.Era BInstrumento do convênioNotícias e transparênciaDOE-BAaberta
Alcance da redeAtas, notícias e documentos correlatos.Não atribuir à instituição.Obter lista oficial.Era CFase atual do recursoPortal consultadoDocumento financeiroprioridade
Fora de eraRegistro anterior a 2011Acervo read-onlyAcervo físicoaberta
-
-

Lista clicável

-

Notícias e documentos consultados

- - - - - - - - + +
+ Dossiê institucional · Lacunaspág. 11/N +
+ + + +
+

+ Cena 12 · Notícias e documentos — lista corrente +

+
+
+
+

Dossiê institucional · Instituição Exemplo

+

Notícias e documentos consultados

+

Inventário datado que sustenta as seções anteriores

+
+
+

Gerado em DD/MM/AAAA

+

pág. 12/N

+
+
+
+
+
+

Corpus consultado

+

Lista completa, em folhas correntes

+
+ N de N +
+
- Fontes consultadas com larguras fixas -
- - + + + - - - - + + + + + - - - - + + + + + - - - - + + + + + - - - - + + + + + + + + + + + +
DataVeículo / acervoTítuloDocumento / notíciaOrigemUso no dossiê Link
DD/MM/AAAAFonte oficialDocumento de exemplo sobre a Instituição ExemploURL da fonteDD/MM/AAAATítulo do documento oficial AÓrgão oficialEra A · vínculoabrir
DD/MM/AAAAAcervo institucionalAto de exemplo — título a confirmarURL da fonteDD/MM/AAAATítulo da notícia institucional BInstituiçãoEra B · entregaabrir
DD/MM/AAAADados públicosConsulta de execução do objeto de exemploURL da fonteDD/MM/AAAARegistro financeiro CPortal oficialEra C · faseabrir
DD/MM/AAAAAcervo read-onlyRegistro temático insuficiente para atribuição diretaReferência internaDD/MM/AAAATramitação legislativa DCâmaraEra C · atuaçãoabrir
DD/MM/AAAAAto administrativo EDiário oficialAbrangênciaabrir
+

+ Continuação automática quando a lista excede a folha; nenhum documento é descartado + para caber. +

-
-
-

Limites de cobertura

-
    -
  • - O acervo interno cobre 2011+; períodos anteriores dependem de fontes externas. -
  • -
  • Resultado de busca não prova ausência histórica.
  • -
  • Fonte temática não individualiza automaticamente a instituição.
  • -
  • Autoria de emenda e relatoria exige confirmação própria.
  • -
-
-
-

Regras para uso editorial

-
    -
  • Sem fonte, não publica.
  • -
  • URL e data acompanham afirmações não triviais.
  • -
  • Instituição, setor e rede permanecem separados.
  • -
  • Todo valor informa sua fase de execução.
  • -
  • PII mínima; nada de contato pessoal no dossiê.
  • -
-
-
-
- Dossiê institucional · Fontes e limites · INSUMO INTERNO — defeso 2026pág. 7/7 + Dossiê institucional · Notícias e documentospág. 12/N
- -
-

- Cena 09 · Estado crítico — era sem fonte / sem pesquisa concluída +

+

+ Cena 13 · Acervo — amostra declarada

-
+

Dossiê institucional · Instituição Exemplo

-

- Era A · Pesquisa sem evidência suficiente -

-

- Variante que substitui a página da era — nunca esconder a ausência +

Acervo interno de falas

+

Amostra declarada do recorte temático read-only (2011+)

+
+
+

Gerado em DD/MM/AAAA

+

pág. 13/N

+
+
+
+
+

Universo recuperado

+

N falas

+

+ Recorte temático; não equivale a menção nominal à instituição. +

+
+
+

Amostra exibida

+

N de N

+

Quantidade e critério permanecem explícitos.

+
+
+
+

Amostra com link direto

+

Falas selecionadas para conferência

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DataTemaTrecho / contextoRelaçãoLink
DD/MM/AAAAÁrea temática AResumo fiel da fala selecionada.setorabrir
DD/MM/AAAAÁrea temática BResumo fiel da fala selecionada.redeabrir
DD/MM/AAAAÁrea temática CMenção nominal confirmada.instituiçãoabrir
DD/MM/AAAAÁrea temática DContexto da atuação parlamentar.setorabrir
+
+
+ Dossiê institucional · Acervo · amostra declaradapág. 13/N +
+
+
+ +
+

+ Cena 14 · Fontes e limites +

+
+
+
+

Dossiê institucional · Instituição Exemplo

+

Fontes e limites

+

+ O que foi consultado, o que não foi achado e o que não se pode afirmar

-

INSUMO INTERNO — defeso 2026

+

INSUMO INTERNO

Gerado em DD/MM/AAAA

-

estado de lacuna

+

pág. N/N

-
-
-

Recorte previsto

-

- Até 2006 · formação, vínculos técnicos e registros institucionais nominalmente - ligados à Instituição Exemplo. +

+

Hierarquia de fontes

+

Lastro visível, item a item

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CamadaUsoLimite
Fontes primáriasAtos, portais, tramitações e documentos oficiais.Fase e autoria só conforme o registro.
Fontes institucionaisNotícias e acervos da entidade.Anúncio não prova execução.
Acervo internoFalas e recortes de 2011+.Recorte temático não é menção nominal.
Busca web datadaLocalização e triangulação.Sem fonte suficiente vira lacuna.
+
+
+
+

Limites

+
    +
  • Instituição, setor e rede não se somam.
  • +
  • Autorizado, empenhado, liquidado e pago não são equivalentes.
  • +
  • Nenhum “não localizado” significa zero.
  • +
+
+
+

Defeso 2026

+

+ Documento interno de pesquisa. Não circular, publicar ou converter automaticamente + em peça de campanha.

- -
-
- -

Lacuna explícita · não é “zero”

-

- Nenhuma fonte suficiente foi localizada para esta era. -

-

- A pesquisa não encontrou documento que sustente uma afirmação institucional. Isso não - prova que não houve vínculo ou atuação; prova apenas que o dossiê ainda não pode - afirmá-los. -

-
-
-
-

O que foi feito

-
    -
  • Busca nominal por instituição e aliases.
  • -
  • Consulta a fontes oficiais e acervos datados.
  • -
  • Triagem de resultados por vínculo e abrangência.
  • -
  • Descarte de menções sem lastro suficiente.
  • -
-
-
-

O que falta fazer

-
    -
  • Solicitar consulta ao arquivo físico.
  • -
  • Validar nome histórico e unidade da instituição.
  • -
  • Recuperar ato, ata ou documento contemporâneo.
  • -
  • Repetir busca com alias confirmado.
  • -
-
-
-
-

Regra editorial

-

- Não preencher a seção por memória, cargo provável ou texto de outra era. - Na síntese, registrar “Era A: evidência institucional não localizada nas fontes - consultadas até DD/MM/AAAA”. -

- Dossiê institucional · Lacuna explícita · INSUMO INTERNO — defeso 2026variante de página de era + Dossiê institucional · Fontes e limitespág. N/N
diff --git a/scripts/build-dossie-solla-cidade.mjs b/scripts/build-dossie-solla-cidade.mjs index 229153ad..f0d6a00f 100644 --- a/scripts/build-dossie-solla-cidade.mjs +++ b/scripts/build-dossie-solla-cidade.mjs @@ -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) { diff --git a/scripts/build-dossie-solla-instituicao.mjs b/scripts/build-dossie-solla-instituicao.mjs index 8b92b781..acbae771 100644 --- a/scripts/build-dossie-solla-instituicao.mjs +++ b/scripts/build-dossie-solla-instituicao.mjs @@ -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) { diff --git a/scripts/lib/buildPdf.mjs b/scripts/lib/buildPdf.mjs index 4c8a8882..0f2909ec 100644 --- a/scripts/lib/buildPdf.mjs +++ b/scripts/lib/buildPdf.mjs @@ -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 (;;) { diff --git a/scripts/lib/dossieBlocks.mjs b/scripts/lib/dossieBlocks.mjs index 7855591a..1698b325 100644 --- a/scripts/lib/dossieBlocks.mjs +++ b/scripts/lib/dossieBlocks.mjs @@ -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 ?? '—', diff --git a/scripts/lib/dossieBulletin.mjs b/scripts/lib/dossieBulletin.mjs index f5fa0e26..964be594 100644 --- a/scripts/lib/dossieBulletin.mjs +++ b/scripts/lib/dossieBulletin.mjs @@ -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, diff --git a/scripts/lib/dossiePack.mjs b/scripts/lib/dossiePack.mjs new file mode 100644 index 00000000..b03c04c8 --- /dev/null +++ b/scripts/lib/dossiePack.mjs @@ -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} + */ +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, + * probe: Array<{ key: string, rows: Array<{ units: number[], cost: number }> }>, + * sheets: Array<{ page: string, height: number, used: number }>, + * anchors: Record, + * budgetPx: number, + * lockedAnchors?: Set, + * }} params + * @returns {Record | 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 +} diff --git a/scripts/lib/dossieRender.mjs b/scripts/lib/dossieRender.mjs index 9771d2eb..a3245b9f 100644 --- a/scripts/lib/dossieRender.mjs +++ b/scripts/lib/dossieRender.mjs @@ -5,7 +5,7 @@ * shared escaping/source-token contract lives in `reportText.mjs`. */ -import { formatDateBr, formatDateTimeBr } from './cityReportFormat.mjs' +import { formatDateBr, formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs' import { dossierPhaseLabel, dossierSphereBadgeClass, dossierSphereLabel } from './dossieBlocks.mjs' import { MUNICIPALITY_UNIT, isInstitutionUnit } from './dossieUnit.mjs' import { htmlEscape, moreItemsLabel, showingLabel, stripInlineSources } from './reportText.mjs' @@ -67,55 +67,9 @@ const assetBox = (title, lines) => .map((line) => htmlEscape(line)) .join('
')}

` -/** cover + trajetória + resumo + região + fontes (eras and gap sheets added separately). */ -const SHEET_COUNT_BASE = 5 +/** Total of sheets rendered so far — the shell's `pág. N/M` reads it. */ const pageCount = (report) => report.meta.pageTotal -/** Gaps are paginated, never truncated: 7 full-text rows per A4 sheet. */ -const GAPS_PER_PAGE = 7 - -/** News are paginated, never truncated: 10 full-title rows per A4 sheet. */ -const NEWS_PER_PAGE = 10 - -/** Era "Atuação registrada" cards: 2 on the first sheet, 6 per continuation sheet. */ -const ERA_ACTIONS_FIRST_SHEET = 2 -const ERA_ACTIONS_PER_SHEET = 6 - -const chunkGaps = (gaps) => { - const list = Array.isArray(gaps) ? gaps : [] - if (list.length === 0) return [[]] - const chunks = [] - for (let index = 0; index < list.length; index += GAPS_PER_PAGE) { - chunks.push(list.slice(index, index + GAPS_PER_PAGE)) - } - return chunks -} - -const chunkNews = (news) => { - const list = Array.isArray(news) ? news : [] - const chunks = [] - for (let index = 0; index < list.length; index += NEWS_PER_PAGE) { - chunks.push(list.slice(index, index + NEWS_PER_PAGE)) - } - return chunks -} - -/** - * Era action cards, paginated so no sourced fact is dropped: the first sheet - * carries the design's 2 cards (with method + numbers + honors), and the rest - * flow to continuation sheets. - */ -const chunkEraActions = (era) => { - const actions = Array.isArray(era.actions) ? era.actions : [] - const first = actions.slice(0, ERA_ACTIONS_FIRST_SHEET) - const rest = actions.slice(ERA_ACTIONS_FIRST_SHEET) - const chunks = [first] - for (let index = 0; index < rest.length; index += ERA_ACTIONS_PER_SHEET) { - chunks.push(rest.slice(index, index + ERA_ACTIONS_PER_SHEET)) - } - return chunks -} - const timelineGrid = (rows) => `
${rows @@ -280,137 +234,7 @@ const renderSummary = (report, pageNo) => ` ${sheetFooter(report, 'Resumo', pageNo)} ` -const renderActionGrid = (actions) => ` -
- ${actions - .map( - (action) => `
-
${scopeBadge(action.sphere)}${htmlEscape(action.year ?? '—')}
-

${copyHtml(briefOr(action.brief, action.title))}

- ${noteOr(action.brief, action.detail) ? `

${copyHtml(noteOr(action.brief, action.detail))}

` : ''} - ${sourceLink(action.sourceUrl)} -
`, - ) - .join('')} -
` - -const renderEraPage = (report, era, actions, index, pageNo) => { - const first = index === 0 - const slug = era.id.toLowerCase() - const anchor = first ? `era-${slug}` : `era-${slug}-${index + 1}` - const subtitle = first - ? `${era.period} · ${era.subtitle}` - : `Atuação registrada · continuação ${index + 1}` - return ` -
- ${sheetHeader(report, era.label, subtitle, pageNo)} - - ${ - first - ? `
-
-

Recorte e método

-

${htmlEscape(era.method)}

-
- -
- - ${ - era.numbers.items.length - ? `
-
-

Números com fase

Recursos e entregas quantificáveis

-
- - - - - ${era.numbers.items - .map( - (row) => ` - - - - - - - `, - ) - .join('')} -
Valores por ano, fase, esfera e fonte
ObjetoValorAnoFaseEsferaFonte
${htmlEscape(row.object)}${valueCell(row.value, 'Valor a recuperar')}${htmlEscape(row.year ?? '—')}${phaseBadge(row.phase)}${scopeBadge(row.sphere)}${sourceLink(row.sourceUrl)}${row.sphere !== 'municipio' ? '
não somar à cidade' : ''}
- ${era.numbers.remaining > 0 ? `

${htmlEscape(moreItemsLabel(era.numbers.remaining, 'número com fase', 'números com fase'))} — a lista completa está no arquivo de pesquisa da era.

` : ''} -

Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.

-
` - : '' - }` - : '' - } - - ${ - actions.length - ? `
-

Atuação registrada

-

O que fez — item, alcance e lastro

- ${renderActionGrid(actions)} - ${era.camaraActionsRemaining > 0 ? `

${htmlEscape(moreItemsLabel(era.camaraActionsRemaining, 'ação da Câmara', 'ações da Câmara'))} — a coleta é limitada às páginas consultadas; o restante está no acervo da Câmara.

` : ''} -
` - : '' - } - - ${ - first && era.honors.length - ? `
-
-

Títulos, honrarias e vínculos locais

-
    ${era.honors - .map( - (honor) => - `
  • ${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}
  • `, - ) - .join('')}
-
- ${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])} -
` - : '' - } - - ${sheetFooter(report, era.label, pageNo)} -
` -} - -const renderScopeLists = (report) => ` -
-

Leitura lado a lado

-

Duas listas, dois totais, nenhuma soma

-
-
-
${scopeBadge('municipio')}${report.region.municipal.total} ${report.region.municipal.total === 1 ? 'item' : 'itens'} com fonte
-
    ${report.region.municipal.items - .map( - (item) => - `
  • ${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}
  • `, - ) - .join( - '', - )}${report.region.municipal.items.length === 0 ? '
  • Nenhum item municipal com fonte.
  • ' : ''}
- ${report.region.municipal.remaining > 0 ? `

${htmlEscape(moreItemsLabel(report.region.municipal.remaining, 'item municipal com fonte', 'itens municipais com fonte'))} — ver a tabela de evidências abaixo.

` : ''} -
- -
-
${scopeBadge('regiao', 'região / polo')}SEM TOTAL COMBINADO · ${report.region.regional.total} ${report.region.regional.total === 1 ? 'item' : 'itens'}
-
    ${report.region.regional.items - .map( - (item) => - `
  • ${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}
  • `, - ) - .join( - '', - )}${report.region.regional.items.length === 0 ? '
  • Nenhum item regional com fonte.
  • ' : ''}
- ${report.region.regional.remaining > 0 ? `

${htmlEscape(moreItemsLabel(report.region.regional.remaining, 'item regional com fonte', 'itens regionais com fonte'))} — ver a tabela de evidências abaixo.

` : ''} -
-
-
` - +/** Region cards (fixed sheet): counts per recorte, no combined total. */ const renderRegion = (report, pageNo) => `
${sheetHeader(report, 'Região / polo', 'Impacto compartilhado, apresentado fora da conta municipal', pageNo)} @@ -433,31 +257,21 @@ const renderRegion = (report, pageNo) => ` : '' } - ${renderScopeLists(report)} - - ${ - report.region.evidence.items.length - ? `
-

Itens regionais recuperados

Evidência de alcance, sem apropriação local

${scopeBadge('regiao')}
- - - - - ${report.region.evidence.items - .map( - (item) => ` - - - - - `, - ) - .join('')} -
Itens do recorte regional com evidência e fonte
ItemEsferaEvidência de alcanceFonte
${copyHtml(briefOr(item.brief, item.item))}${scopeBadge(item.sphere)}${copyHtml(noteOr(item.brief, item.evidence))}${sourceLink(item.sourceUrl)}
- ${report.region.evidence.remaining > 0 ? `

${htmlEscape(showingLabel(report.region.evidence.items.length, report.region.evidence.total, 'item regional recuperado', 'itens regionais recuperados'))} em evidência; o restante está no arquivo de pesquisa da era.

` : ''} -
` - : '' - } +
+

Leitura lado a lado

+

Duas listas, dois totais, nenhuma soma

+
+
+
${scopeBadge('municipio', null, report.unit)}${report.region.municipal.total} ${report.region.municipal.total === 1 ? 'item' : 'itens'} com fonte · DIRETO
+

lista completa nas páginas seguintes

+
+ +
+
${scopeBadge('regiao', 'região / polo', report.unit)}SEM TOTAL COMBINADO · ${report.region.regional.total} ${report.region.regional.total === 1 ? 'item' : 'itens'}
+

lista completa nas páginas seguintes

+
+
+
${ report.region.hook || report.region.priorityGap @@ -479,85 +293,58 @@ const renderRegion = (report, pageNo) => ` ${sheetFooter(report, 'Região/polo · recortes não somáveis', pageNo)}
` -const renderGapsPage = (report, chunk, index, pageNo) => { - const rows = chunk.length - ? chunk - .map( - (gap) => ` - ${htmlEscape(gap.label)} - ${htmlEscape(gap.reason)} - Não completar por inferência. - ${htmlEscape(gap.nextStep)} - `, - ) - .join('') - : 'Nenhuma lacuna declarada — confira a cobertura das eras.' - return ` -
- ${sheetHeader(report, 'Lacunas explícitas', `O que não foi encontrado${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)} -
-

O que não foi encontrado

Lacunas explícitas

Não completar por memória ou inferência

- - - - - ${rows} -
Tabela de lacunas explícitas e próximos passos de apuração
LacunaSituação da buscaLimite editorialPróximo passo
-
- ${sheetFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)} -
` -} - -const renderNewsPage = (report, chunk, index, pageNo) => ` -
- ${sheetHeader(report, 'Notícias e documentos consultados', `Fontes externas datadas${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)} -
-

Lista clicável

-

Notícias e documentos consultados

- - - - - ${chunk - .map( - (row) => ` - - - - +/** Full lists of the region recorte, uncapped: each one flows across sheets. */ +const regionListUnits = (report, list, probe) => + list.items.map((item, index) => ({ + layout: 'tr', + html: ` + + + + `, - ) - .join('')} -
Fontes consultadas com largura fixa: data 12%, veículo 13%, título 47% e link 28%
DataVeículoTítuloLink
${htmlEscape(formatShortDateBr(row.date))}${htmlEscape(row.outlet)}${htmlEscape(row.title)}${sourceLink(row.url, '(fonte)')}${copyHtml(briefOr(item.brief, item.answer))}${scopeBadge(item.sphere, null, report.unit)}${copyHtml(item.details ?? item.brief?.note ?? '—')}${sourceLink(item.sourceUrl)}
-
- ${sheetFooter(report, 'Notícias e documentos consultados · INSUMO INTERNO', pageNo)} -
` + })) -const renderSourcesPage = (report, pageNo) => ` -
- ${sheetHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)} - -
-

Limites de cobertura

    ${report.limits.coverage - .map((item) => `
  • ${htmlEscape(item)}
  • `) - .join('')}
-

Regras para uso editorial

    ${report.limits.editorial - .map((item) => `
  • ${htmlEscape(item)}
  • `) - .join('')}
-
- - - - ${sheetFooter(report, 'Fontes e limites · INSUMO INTERNO', pageNo)} +
` + : '' + } +
+ + + + ${tableHead(['Item', 'Esfera', 'Evidência de alcance', 'Fonte'])} + ${entry.chunk.map((unit) => unit.html).join('')} +
Itens do recorte com evidência de alcance e fonte
+
+ ${sheetFooter(report, `Região/polo · ${list.label}`, pageNo)} ` +const renderRegionListSheets = ({ report, key, anchor, list, pack, probe, nextPage }) => { + if (list.items.length === 0) return [] + const units = regionListUnits(report, list, probe) + return packedSection({ key, anchor, units, pack, probe }).map((entry) => + renderRegionListSheet({ report, list, entry, probe, pageNo: nextPage() }), + ) +} + const PRINT_CSS = ` * { box-sizing: border-box; } html, body { margin: 0; padding: 0; } @@ -664,29 +451,83 @@ const PRINT_CSS = ` .cover-version { margin: 0; text-align: right; font-size: 8pt; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: #315c75; } .cover-accent { color: #315c75; } .defeso { margin-top: auto; } + .chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin-top: 3mm; } + .chart { display: block; width: 100%; height: auto; } + .chart-card { border: .25mm solid #cbd5dc; border-radius: .25rem; padding: 3mm; break-inside: avoid; } + .chart-title { margin: 0 0 2mm; font-size: 9.5pt; font-weight: 600; } + .chart-label { font-size: 7.4pt; fill: #435264; } + .chart-value { font-size: 7.6pt; fill: #1f2933; font-weight: 700; } + .chart-legend { margin: 1.5mm 0 0; font-size: 7.6pt; line-height: 1.3; color: #435264; } + .synthesis-lines { margin: 0; padding-left: 4.5mm; font-size: 9.5pt; line-height: 1.35; } + .synthesis-lines li { margin: 0 0 1.8mm; } + .synthesis-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin-top: 3mm; } + .opening-paragraph { margin: 0 0 3.4mm; font-size: 11pt; line-height: 1.45; } + .opening-paragraph:last-child { margin-bottom: 0; } + .era-summary { margin-top: 3.4mm; border-left: 1.2mm solid #315c75; background: #f7fafb; padding: 2.6mm 3mm; } + .era-summary p { margin: 0; font-size: 10pt; line-height: 1.4; } + .value-list { list-style: none; margin: 0; padding: 0; } + .value-list li { display: grid; grid-template-columns: 1fr 20mm 18mm; gap: 1.6mm; align-items: center; padding: 0.9mm 0; border-bottom: .25mm solid #e3e9ec; font-size: 8.2pt; line-height: 1.22; } + .value-list li:last-child { border-bottom: 0; } + .value-list-bar { display: block; height: 2.2mm; background: #eef2f5; border-radius: 1mm; overflow: hidden; } + .value-list-bar span { display: block; height: 100%; background: #315c75; } + .value-list-value { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; } ` -const renderMunicipalityDossierHtml = (report) => { - const gapChunks = chunkGaps(report.gaps) - const newsChunks = chunkNews(report.news) - const eraSheets = report.eras.map((era) => ({ era, chunks: chunkEraActions(era) })) - const eraSheetCount = eraSheets.reduce((sum, entry) => sum + entry.chunks.length, 0) - report.meta.pageTotal = SHEET_COUNT_BASE + eraSheetCount + gapChunks.length + newsChunks.length - let page = 0 - const parts = [] - parts.push(renderCover(report)) - page += 1 - parts.push(renderTrajectory(report, ++page)) - parts.push(renderSummary(report, ++page)) - for (const { era, chunks } of eraSheets) { - chunks.forEach((actions, index) => - parts.push(renderEraPage(report, era, actions, index, ++page)), +const renderMunicipalityDossierHtml = (report, { pack = null, probe = false } = {}) => { + const buildParts = () => { + let page = 0 + const nextPage = () => ++page + const parts = [] + parts.push(renderCover(report)) + page += 1 + const opening = renderUnitOpeningPage(report, page + 1) + if (opening) { + parts.push(opening) + page += 1 + } + parts.push(renderTrajectory(report, nextPage())) + parts.push(renderSummary(report, nextPage())) + parts.push(renderUnitSynthesisPage(report, nextPage())) + parts.push(renderUnitChartsPage(report, nextPage())) + for (const era of report.eras) { + parts.push(...renderEraSheets({ report, era, pack, probe, nextPage, includeHonors: true })) + } + parts.push(renderRegion(report, nextPage())) + parts.push( + ...renderRegionListSheets({ + report, + key: 'region:municipio', + anchor: 'regiao-municipio', + list: report.region.municipal, + pack, + probe, + nextPage, + }), ) + parts.push( + ...renderRegionListSheets({ + report, + key: 'region:regional', + anchor: 'regiao-regional', + list: report.region.regional, + pack, + probe, + nextPage, + }), + ) + parts.push(...renderGapSheets({ report, pack, probe, nextPage })) + parts.push(...renderNewsSheets({ report, pack, probe, nextPage })) + parts.push(...renderAcervoSheets({ report, pack, probe, nextPage })) + parts.push(renderUnitSourcesPage(report, nextPage())) + return parts } - parts.push(renderRegion(report, ++page)) - gapChunks.forEach((chunk, index) => parts.push(renderGapsPage(report, chunk, index, ++page))) - newsChunks.forEach((chunk, index) => parts.push(renderNewsPage(report, chunk, index, ++page))) - parts.push(renderSourcesPage(report, ++page)) + + // Two passes: the shell's `pág. N/M` needs the final sheet count, and the + // count only exists after the pack plan is applied. + report.meta.pageTotal = 0 + let parts = buildParts() + report.meta.pageTotal = parts.length + parts = buildParts() return ` @@ -705,37 +546,28 @@ const mdEra = (era) => { '', `Trilha: ${era.recovery}`, ] - if (era.numbers.items.length) { + if (era.narrative || era.summary) { + lines.push('', `> ${era.narrative ?? era.summary}`) + } + if (listOf(era.numbers).length) { lines.push( '', '| Objeto | Valor | Ano | Fase | Esfera | Fonte |', '| --- | --- | --- | --- | --- | --- |', ) - for (const row of era.numbers.items) { + for (const row of listOf(era.numbers)) { lines.push( `| ${row.object} | ${row.value ?? '—'} | ${row.year ?? '—'} | ${dossierPhaseLabel(row.phase)} | ${row.sphere} | ${row.sourceUrl ?? '—'} |`, ) } - if (era.numbers.remaining > 0) { - lines.push( - '', - `_${moreItemsLabel(era.numbers.remaining, 'número com fase', 'números com fase')} não exibidos — ver as fontes._`, - ) - } } - for (const action of era.actions) { + for (const action of listOf(era.actions)) { lines.push( '', `- **${stripInlineSources(action.title)}** (${action.sphere}${action.year ? ` · ${action.year}` : ''})${action.detail ? ` — ${stripInlineSources(action.detail)}` : ''} [fonte](${action.sourceUrl})`, ) } - if (era.camaraActionsRemaining > 0) { - lines.push( - '', - `_${moreItemsLabel(era.camaraActionsRemaining, 'ação da Câmara', 'ações da Câmara')} — a coleta é limitada às páginas consultadas._`, - ) - } - for (const honor of era.honors) + for (const honor of listOf(era.honors)) lines.push('', `- Honraria: ${stripInlineSources(honor.text)} [fonte](${honor.sourceUrl})`) return lines.join('\n') } @@ -749,9 +581,20 @@ const renderMunicipalityDossierMd = (report) => { '', '> INSUMO INTERNO — não circular, não publicar. Documento de trabalho.', '', - '## Linha do tempo da carreira', - '', ] + if (report.opening?.paragraphs?.length) { + lines.push(`## ${report.opening.title}`, '') + for (const paragraph of report.opening.paragraphs) lines.push(paragraph, '') + lines.push( + `_Redação de síntese sobre os pontos com fonte; ${ + report.opening.authored + ? 'escrita a partir dos itens datados do dossiê' + : 'repetição da leitura dos números (sem redação autoral disponível)' + } — nada aqui acrescenta fato novo._`, + '', + ) + } + lines.push('## Linha do tempo da carreira', '') for (const row of report.trajectory) { lines.push( `- **${row.period}** — ${row.role}${row.uncertain ? ` _(${row.uncertain})_` : ''} · ${row.recovery}`, @@ -778,6 +621,29 @@ const renderMunicipalityDossierMd = (report) => { lines.push('', mdEra(era)) } + if (report.synthesis?.lines?.length) { + lines.push('', '## Síntese do que foi localizado', '') + for (const line of report.synthesis.lines) lines.push(`- ${line}`) + lines.push('', '### Números por recorte', '') + lines.push('| Era | Município | Região/polo | Total |', '| --- | --- | --- | --- |') + for (const row of report.synthesis.byEraSphere) { + const countFor = (key) => row.segments.find((segment) => segment.key === key)?.count ?? 0 + lines.push( + `| ${row.label} | ${countFor('municipio')} | ${countFor('regiao') + countFor('polo')} | ${row.segments.reduce((sum, segment) => sum + segment.count, 0)} |`, + ) + } + if (report.synthesis.moneyByPhase.length) { + lines.push('', '### Recursos por fase', '') + for (const row of report.synthesis.moneyByPhase) { + lines.push(`- ${row.label}: ${row.count} · ${formatMoneyCompact(row.amount)}`) + } + } + if (report.synthesis.byArea.length) { + lines.push('', '### Temas com mais registros', '') + for (const row of report.synthesis.byArea) lines.push(`- ${row.key}: ${row.count}`) + } + } + lines.push( '', '## Região / polo', @@ -788,33 +654,28 @@ const renderMunicipalityDossierMd = (report) => { ) lines.push('### Município', '') for (const item of report.region.municipal.items) { - lines.push(`- ${stripInlineSources(item.answer)} — [fonte](${item.sourceUrl})`) - } - if (report.region.municipal.remaining > 0) { lines.push( - `- _${moreItemsLabel(report.region.municipal.remaining, 'item municipal com fonte', 'itens municipais com fonte')} — ver a tabela de evidências._`, + `- **${stripInlineSources(briefOr(item.brief, item.answer))}** (${item.sphere})${item.details ? ` — ${stripInlineSources(item.details)}` : ''} [fonte](${item.sourceUrl})`, ) } lines.push('', '### Região / polo (não somável)', '') for (const item of report.region.regional.items) { - lines.push(`- ${stripInlineSources(item.answer)} — [fonte](${item.sourceUrl})`) - } - if (report.region.regional.remaining > 0) { lines.push( - `- _${moreItemsLabel(report.region.regional.remaining, 'item regional com fonte', 'itens regionais com fonte')} — ver a tabela de evidências._`, + `- **${stripInlineSources(briefOr(item.brief, item.answer))}** (${item.sphere})${item.details ? ` — ${stripInlineSources(item.details)}` : ''} [fonte](${item.sourceUrl})`, ) } - if (report.region.evidence.items.length) { - lines.push('', '| Item | Esfera | Evidência | Fonte |', '| --- | --- | --- | --- |') - for (const item of report.region.evidence.items) { + + if (report.acervo?.items?.length) { + lines.push( + '', + '## Acervo interno (read-only)', + '', + `_Amostra de ${report.acervo.items.length} de ${report.acervo.recorte} falas do recorte, com link; o acervo completo fica na base Teqo. ${report.unit?.acervoNote ?? ''}_`, + '', + ) + for (const row of report.acervo.items) { lines.push( - `| ${stripInlineSources(item.item)} | ${item.sphere} | ${stripInlineSources(item.evidence)} | ${item.sourceUrl} |`, - ) - } - if (report.region.evidence.remaining > 0) { - lines.push( - '', - `_${showingLabel(report.region.evidence.items.length, report.region.evidence.total, 'item regional recuperado', 'itens regionais recuperados')} em evidência; o restante está no arquivo de pesquisa da era._`, + `- **${row.period}** — ${stripInlineSources(row.text)}${row.excerpt ? ` _(${stripInlineSources(row.excerpt)})_` : ''} [fonte](${row.sourceUrl})`, ) } } @@ -888,6 +749,52 @@ const INSTITUTION_PRINT_CSS = ` .more-note { margin: 1.5mm 0 0; font-size: 8pt; font-weight: 600; color: #8a5a18; } ` +/** + * Anchor → pack-section key of the flowing sheets. The builder maps an + * overflowing sheet back to its pack entry and shrinks that chunk (fallback of + * the measured pack plan). + */ +const MUNICIPALITY_PACK_ANCHORS = { + 'era-a': 'era:A', + 'era-b': 'era:B', + 'era-c': 'era:C', + 'regiao-municipio': 'region:municipio', + 'regiao-regional': 'region:regional', + lacunas: 'gaps', + noticias: 'news', + acervo: 'acervo', +} + +export const INSTITUTION_PACK_ANCHORS = { + 'era-a': 'era:A', + 'era-b': 'era:B', + 'era-c': 'era:C', + titulos: 'honors', + 'abrangencia-instituicao': 'scope:instituicao', + 'abrangencia-setor': 'scope:setor', + 'abrangencia-rede': 'scope:rede', + lacunas: 'gaps', + noticias: 'news', + acervo: 'acervo', +} + +/** + * Probe-only CSS: the packed sections measure their true content height, with + * no page clamp and no inter-row gaps (the packer adds the card-row gap back). + */ +const INSTITUTION_PROBE_CSS = ` + .sheet { height: auto; min-height: 0; overflow: visible; } + [data-pack-section] .block, + [data-pack-section] .action-grid, + [data-pack-section] .document-table, + [data-pack-section] .tight-list, + [data-pack-section] .era-method, + [data-pack-section] .page1-grid { margin-top: 0; } + [data-pack-section] .action-grid, + [data-pack-section] .era-method { gap: 0; } + [data-pack-section] .tight-list li { margin-bottom: 0; } +` + const instHeader = (report, title, subtitle, pageNo) => `
@@ -902,6 +809,17 @@ const instHeader = (report, title, subtitle, pageNo) => `
` +/** Shared sheets (síntese, gráficos, carta, eras correntes) use the unit's shell. */ +const unitHeader = (report, title, subtitle, pageNo) => + isInstitutionUnit(report.unit) + ? instHeader(report, title, subtitle, pageNo) + : sheetHeader(report, title, subtitle, pageNo) + +const unitFooter = (report, left, pageNo) => + isInstitutionUnit(report.unit) + ? instFooter(report, left, pageNo) + : sheetFooter(report, left, pageNo) + const instFooter = (report, left, pageNo) => `
Dossiê institucional · ${htmlEscape(report.meta.subjectName)} · ${htmlEscape(left)} @@ -915,10 +833,163 @@ const CAPPED_NOUNS = { vínculos: 'vínculo', } -const moreNote = (cappedList, noun = 'itens') => { +const moreNote = ( + cappedList, + noun = 'itens', + hint = 'com fonte fora desta página — nada foi descartado.', +) => { if (cappedList.omitted <= 0) return '' const label = cappedList.omitted === 1 ? (CAPPED_NOUNS[noun] ?? noun) : noun - return `

e mais ${cappedList.omitted} ${label} com fonte fora desta página — nada foi descartado.

` + return `

e mais ${cappedList.omitted} ${label} ${htmlEscape(hint)}

` +} + +/* ---------------------------------------------------------------- * + * Flowing sheets (C187): packed sections render as many sheets as * + * the content needs — nothing is capped. In probe mode a section * + * renders one sheet with every unit tagged (`data-pack-unit`), so * + * the builder measures real heights and returns a pack plan. * + * ---------------------------------------------------------------- */ + +const splitUnits = (units, sizes) => { + const chunks = [] + let offset = 0 + for (const size of sizes) { + chunks.push(units.slice(offset, offset + size)) + offset += size + } + if (offset < units.length) chunks.push(units.slice(offset)) + return chunks +} + +/** Sheet plans of one section: `[{ key, anchor, chunk, index }]`. */ +const packedSection = ({ key, anchor, units, pack, probe }) => { + const sizes = probe ? null : pack?.[key] + const chunks = sizes ? splitUnits(units, sizes) : [units] + return chunks.map((chunk, index) => ({ + key, + chunk, + index, + anchor: index === 0 ? anchor : `${anchor}-${index + 1}`, + })) +} + +const unitAttrs = (probe, index, layout) => + probe ? ` data-pack-unit="${index}" data-pack-layout="${layout}"` : '' + +const groupUnits = (chunk) => { + const groups = [] + for (const unit of chunk) { + const last = groups[groups.length - 1] + if (last && last.layout === unit.layout) last.units.push(unit) + else groups.push({ layout: unit.layout, units: [unit] }) + } + return groups +} + +const tableHead = (columns) => + `${columns.map((column) => `${htmlEscape(column)}`).join('')}` + +/* ---------------------------------------------------------------- * + * Synthesis charts (inline SVG, print-safe): aggregations of the * + * sourced rows only — never a new fact, never a combined total * + * across abrangências. * + * ---------------------------------------------------------------- */ + +const CHART_COLORS = { + municipio: '#315c75', + regiao: '#9d8b64', + polo: '#8f819c', + instituicao: '#315c75', + setor: '#9d8b64', + rede: '#8f819c', + empenhado: '#6b4918', + liquidado: '#4a6b7c', + pago: '#285338', + autorizado: '#8a5a18', + restos: '#5b6470', + nao_informado: '#8b96a2', + default: '#315c75', +} + +const barChart = ({ rows, width = 330, rowHeight = 19, labelWidth = 118 }) => { + const max = Math.max(1, ...rows.map((row) => row.count)) + const barMax = width - labelWidth - 26 + const height = rows.length * rowHeight + 4 + return ` + ${rows + .map((row, index) => { + const y = index * rowHeight + 3 + const barWidth = Math.max(2, Math.round((row.count / max) * barMax)) + return `${htmlEscape(row.label)} + + ${row.count}` + }) + .join('')} + ` +} + +/** HTML list with a proportional bar per row — long labels wrap, nothing overlaps. */ +const valueList = ({ rows, format }) => { + const max = Math.max(1, ...rows.map((row) => row.value)) + return `
    + ${rows + .map( + (row) => `
  • + ${htmlEscape(row.label)} + + ${htmlEscape(format(row))} +
  • `, + ) + .join('')} +
` +} + +/** Vertical columns with the value label above and the year label below. */ +const columnChart = ({ rows, width = 330, height = 132, format = (row) => String(row.count) }) => { + const max = Math.max(1, ...rows.map((row) => row.value ?? row.count)) + const baseY = height - 18 + const plotHeight = baseY - 16 + const columnWidth = (width - 8) / Math.max(1, rows.length) + return ` + ${rows + .map((row, index) => { + const value = row.value ?? row.count + const barHeight = Math.max(2, Math.round((value / max) * plotHeight)) + const x = 4 + index * columnWidth + const y = baseY - barHeight + return ` + ${htmlEscape(format(row))} + ${htmlEscape(String(row.key).slice(-2))}` + }) + .join('')} + ` +} + +/** Vertical columns stacked by phase (money by year), total label above. */ +const stackedColumnChart = ({ rows, width = 330, height = 132, format }) => { + const max = Math.max(1, ...rows.map((row) => row.value)) + const baseY = height - 18 + const plotHeight = baseY - 16 + const columnWidth = (width - 8) / Math.max(1, rows.length) + return ` + ${rows + .map((row, index) => { + const x = 4 + index * columnWidth + let y = baseY + const bars = row.segments + .map((segment) => { + const segmentHeight = Math.max(1, Math.round((segment.amount / max) * plotHeight)) + y -= segmentHeight + return `` + }) + .join('') + const top = baseY - Math.round((row.value / max) * plotHeight) + return `${bars} + ${htmlEscape(format(row))} + ${htmlEscape(String(row.key).slice(-2))}` + }) + .join('')} + ` } const identityBadgeList = (report) => @@ -995,7 +1066,7 @@ const renderInstitutionVincTimeline = (report) => { ) .join('')}
- ${moreNote(timeline, 'vínculos')}` + ${moreNote(timeline, 'vínculos', 'com fonte — lista completa nas seções por era.')}` } const renderInstitutionDeliveries = (report) => { @@ -1025,7 +1096,7 @@ const renderInstitutionDeliveries = (report) => { ) .join('')} - ${moreNote(deliveries, 'entregas')} + ${moreNote(deliveries, 'entregas', 'com fonte — lista completa nas seções por era e de abrangência.')}

Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor.

` } @@ -1077,9 +1148,9 @@ const renderInstitutionSummary = (report, pageNo) => ` ${instFooter(report, 'Resumo · INSUMO INTERNO', pageNo)} ` -const renderInstitutionEraEmpty = (report, era, pageNo) => ` +const renderEraEmptySheet = (report, era, pageNo) => `
- ${instHeader(report, `${era.label} · Pesquisa sem evidência suficiente`, era.period, pageNo)} + ${unitHeader(report, `${era.label} · Pesquisa sem evidência suficiente`, era.period, pageNo)}

Recorte previsto

@@ -1120,93 +1191,158 @@ const renderInstitutionEraEmpty = (report, era, pageNo) => `

Não preencher a seção por memória, cargo provável ou texto de outra era. Registrar “${htmlEscape(era.label)}: evidência institucional não localizada nas fontes consultadas”.

- ${instFooter(report, `${era.label} · lacuna explícita`, pageNo)} + ${unitFooter(report, `${era.label} · lacuna explícita`, pageNo)}
` -const renderInstitutionEra = (report, era, pageNo) => { - if (era.empty) return renderInstitutionEraEmpty(report, era, pageNo) - return ` -
- ${instHeader(report, era.label, `${era.period} · ${era.subtitle}`, pageNo)} +/** Accepts the institution's capped objects and the city's plain arrays. */ +const listOf = (value) => (Array.isArray(value) ? value : (value?.items ?? [])) -
+/** + * Era units: value rows (table), action cards (2-col grid) and — city only, which + * keeps honors in the era sheet — the honor list items. Honors have their own + * sheet in the institution dossiê, so `includeHonors` stays false there. + */ +const eraUnits = (report, era, probe, { includeHonors = false } = {}) => { + const unit = report.unit ?? MUNICIPALITY_UNIT + const units = [] + for (const row of listOf(era.numbers)) { + units.push({ + layout: 'tr', + html: ` + ${htmlEscape(row.object)} + ${valueCell(row.value, 'Valor a recuperar')} + ${htmlEscape(row.year ?? '—')} + ${phaseBadge(row.phase)} + ${scopeBadge(row.sphere, null, unit)} + ${sourceLink(row.sourceUrl)}${ + row.sphere !== unit.defaultSphere + ? `
${htmlEscape(unit.notSummedInline ?? 'não somar')}` + : '' + } + `, + }) + } + for (const action of listOf(era.actions)) { + units.push({ + layout: 'card', + html: `
+
${scopeBadge(action.sphere, null, unit)}${htmlEscape(action.year ?? '—')}
+

${copyHtml(briefOr(action.brief, action.title))}

+ ${noteOr(action.brief, action.detail) ? `

${copyHtml(noteOr(action.brief, action.detail))}

` : ''} + ${sourceLink(action.sourceUrl)} +
`, + }) + } + if (includeHonors) { + for (const honor of listOf(era.honors)) { + units.push({ + layout: 'li', + html: `${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}`, + }) + } + } + return units +} + +const eraNumbersTable = (units, unit) => ` + + + + ${tableHead(['Objeto', 'Valor', 'Ano', 'Fase', unit.sphereColumnLabel ?? 'Esfera', 'Fonte'])} + ${units.map((entry) => entry.html).join('')} +
Valores por ano, fase, esfera e fonte
` + +const eraHonorsPanel = (units, unit) => ` +
+
+

${htmlEscape(unit.honorsTitle ?? 'Títulos, honrarias e vínculos')}

+
    ${units.map((entry) => entry.html).join('')}
+
+ ${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])} +
` + +const renderEraSheet = ({ report, era, entry, probe, pageNo }) => { + const unit = report.unit ?? MUNICIPALITY_UNIT + const body = groupUnits(entry.chunk) + .map((group) => { + if (group.layout === 'tr') { + return `
+
+

Entregas localizadas

${htmlEscape(unit.eraNumbersTitle ?? 'Objeto, valor, fase e alcance')}

+
+ ${eraNumbersTable(group.units, unit)} +

Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.

+
` + } + if (group.layout === 'li') return eraHonorsPanel(group.units, unit) + return `
+

Atuação registrada

+

${htmlEscape(unit.eraActionsTitle ?? 'Papéis com evidência visível')}

+
${group.units.map((item) => item.html).join('')}
+
` + }) + .join('') + return ` +
+ ${unitHeader(report, era.label, entry.index === 0 ? `${era.period} · ${era.subtitle}` : `continuação ${entry.index + 1} · ${era.period}`, pageNo)} + + ${ + entry.index === 0 + ? `

Recorte e método

${htmlEscape(era.method)}

-
- - ${ - era.numbers.items.length - ? `
-
-

Entregas localizadas

Objeto, valor, fase e alcance

-
- - - - - ${era.numbers.items - .map( - (row) => ` - - - - - - - `, - ) - .join('')} -
Valores por ano, fase, abrangência e fonte
ObjetoValorAnoFaseAbrangênciaFonte
${htmlEscape(row.object)}${valueCell(row.value, 'Valor a recuperar')}${htmlEscape(row.year ?? '—')}${phaseBadge(row.phase)}${scopeBadge(row.sphere, null, report.unit)}${sourceLink(row.sourceUrl)}${row.sphere !== 'instituicao' ? '
não somar à instituição' : ''}
-

Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.

-
` +
` : '' } ${ - era.actions.items.length - ? `
-

Atuação / vínculo registrado

-

Papéis com evidência visível

-
- ${era.actions.items - .map( - (action) => `
-
${scopeBadge(action.sphere, null, report.unit)}${htmlEscape(action.year ?? '—')}
-

${copyHtml(briefOr(action.brief, action.title))}

- ${noteOr(action.brief, action.detail) ? `

${copyHtml(noteOr(action.brief, action.detail))}

` : ''} - ${sourceLink(action.sourceUrl)} -
`, - ) - .join('')} -
-
` + entry.index === 0 && (era.narrative || era.summary) + ? `
+

O que esta era entrega

+

${copyHtml(era.narrative ?? era.summary)}

+
` : '' } - ${ - era.honors.length - ? `
-
-

Títulos, honrarias e vínculos institucionais

-
    ${era.honors - .map( - (honor) => - `
  • ${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}
  • `, - ) - .join('')}
-
- ${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])} -
` - : '' - } + ${body} - ${instFooter(report, era.label, pageNo)} + ${unitFooter(report, era.label, pageNo)}
` } +const renderEraSheets = ({ report, era, pack, probe, nextPage, includeHonors = false }) => { + if (era.empty) return [renderEraEmptySheet(report, era, nextPage())] + const units = eraUnits(report, era, probe, { includeHonors }) + return packedSection({ + key: `era:${era.id}`, + anchor: `era-${era.id.toLowerCase()}`, + units, + pack, + probe, + }).map((entry) => renderEraSheet({ report, era, entry, probe, pageNo: nextPage() })) +} + +/** Anchor map of the unit's packed sections (the builder inverts it). */ +export const dossierPackAnchors = (unit) => + isInstitutionUnit(unit) ? INSTITUTION_PACK_ANCHORS : MUNICIPALITY_PACK_ANCHORS + +const INSTITUTION_SCOPE_WORDS = { + institution: 'instituicao', + sector: 'setor', + network: 'rede', +} + +const listYearRange = (items) => { + const years = items + .map((item) => item.year) + .filter(Boolean) + .sort() + return years.length ? `${years[0]}–${years[years.length - 1]}` : null +} + const renderInstitutionScope = (report, pageNo) => { const reach = report.reach const cardClass = { institution: '', sector: 'card-sector', network: 'card-network' } @@ -1231,46 +1367,22 @@ const renderInstitutionScope = (report, pageNo) => { (list) => `
${scopeBadge(list.sphere, null, report.unit)}${list.sphere === 'instituicao' ? 'DIRETO' : 'NÃO SOMAR'}

${list.total} ${list.total === 1 ? 'item' : 'itens'}

-

com fonte

+

com fonte${listYearRange(list.items) ? ` · ${listYearRange(list.items)}` : ''} · lista completa nas páginas seguintes

    ${list.items + .slice(0, 2) .map( (item) => - `
  • ${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}
  • `, + `
  • ${copyHtml(briefOr(item.brief, item.title))} ${sourceLink(item.sourceUrl)}
  • `, ) .join( '', )}${list.items.length === 0 ? '
  • Nenhum item com fonte.
  • ' : ''}
- ${moreNote(list, 'itens')}
`, ) .join('')}
- ${ - reach.evidence.items.length - ? `
-

Evidência de alcance

Por que cada item está neste recorte

Sem evidência, vira lacuna

- - - - - ${reach.evidence.items - .map( - (row) => ` - - - - - `, - ) - .join('')} -
Itens de setor e rede com evidência de alcance
ItemAbrangênciaEvidência de alcanceFonte
${copyHtml(briefOr(row.brief, row.item))}${scopeBadge(row.sphere, null, report.unit)}${copyHtml(row.evidence)}${sourceLink(row.sourceUrl)}
- ${moreNote(reach.evidence, 'itens')} -
` - : '' - } - ${ reach.hook || reach.priorityGap ? `
@@ -1292,44 +1404,114 @@ const renderInstitutionScope = (report, pageNo) => { ` } +/** Sphere lists are uncapped: each one flows across as many sheets as it needs. */ +const institutionScopeUnits = (report, list, probe) => + list.items.map((item, index) => ({ + layout: 'tr', + html: ` + ${copyHtml(briefOr(item.brief, item.title))} + ${scopeBadge(item.sphere, null, report.unit)} + ${copyHtml(noteOr(item.brief, item.evidence))} + ${sourceLink(item.sourceUrl)} + `, + })) + +const renderInstitutionScopeListSheet = ({ report, list, entry, probe, pageNo }) => { + const word = INSTITUTION_SCOPE_WORDS[list.key] ?? list.key + const notSummable = list.sphere !== 'instituicao' + return ` +
+ ${instHeader( + report, + `Abrangência · ${list.label}`, + entry.index === 0 + ? `${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte · ${notSummable ? 'não somar à instituição' : 'recorte direto'}` + : `continuação ${entry.index + 1} · ${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte`, + pageNo, + )} + ${ + entry.index === 0 + ? `
+ ${WARNING_ICON} +
+

${htmlEscape(report.reach.ruleTitle)}

+

Cada linha informa sua abrangência; setor e rede não são somados à instituição.

+
+
` + : '' + } +
+ + + + ${tableHead(['Item', 'Abrangência', 'Evidência de alcance', 'Fonte'])} + ${entry.chunk.map((unit) => unit.html).join('')} +
Itens do recorte com evidência de alcance e fonte
+
+ ${instFooter(report, `Abrangência · ${list.label}`, pageNo)} +
` +} + +const renderInstitutionScopeListSheets = ({ report, list, pack, probe, nextPage }) => { + if (list.items.length === 0) return [] + const word = INSTITUTION_SCOPE_WORDS[list.key] ?? list.key + const units = institutionScopeUnits(report, list, probe) + return packedSection({ + key: `scope:${word}`, + anchor: `abrangencia-${word}`, + units, + pack, + probe, + }).map((entry) => + renderInstitutionScopeListSheet({ report, list, entry, probe, pageNo: nextPage() }), + ) +} + const honorDate = (honor) => { if (honor.date) return formatDateBr(honor.date) if (honor.year) return String(honor.year) return '—' } -const institutionHonors = (report) => report.eras.find((era) => era.id === 'C')?.honors ?? [] +const institutionHonors = (report) => report.eras.find((era) => era.id === 'C')?.honors?.items ?? [] -const hasInstitutionHonors = (report) => institutionHonors(report).length > 0 +const institutionHonorUnits = (probe) => + institutionHonors(probe.report).map((honor, index) => ({ + layout: 'tr', + html: ` + ${htmlEscape(honorDate(honor))} + reconhecimento + ${copyHtml(briefOr(honor.brief, honor.text))}${noteOr(honor.brief, honor.detail) ? `
${copyHtml(noteOr(honor.brief, honor.detail))}` : ''} + ${sourceLink(honor.sourceUrl)} + `, + })) -const renderInstitutionHonors = (report, pageNo) => { - const honors = institutionHonors(report) - if (honors.length === 0) return '' +const renderInstitutionHonorSheet = ({ report, entry, probe, pageNo }) => { const gap = report.gaps?.[0] ?? null const hook = report.reach.hook ?? report.page1.hooks.items[0] ?? null return ` -
- ${instHeader(report, 'Títulos, honrarias e vínculos', 'Reconhecimento não substitui entrega; vínculo não prova resultado', pageNo)} +
+ ${instHeader( + report, + 'Títulos, honrarias e vínculos', + entry.index === 0 + ? 'Reconhecimento não substitui entrega; vínculo não prova resultado' + : `continuação ${entry.index + 1}`, + pageNo, + )}

Registros confirmados

Data, natureza e fonte primária

- - ${honors - .map( - (honor) => ` - - - - - `, - ) - .join('')} + ${tableHead(['Data', 'Natureza', 'Registro', 'Fonte'])} + ${entry.chunk.map((unit) => unit.html).join('')}
Títulos, honrarias e vínculos por data e fonte
DataNaturezaRegistroFonte
${htmlEscape(honorDate(honor))}reconhecimento${copyHtml(briefOr(honor.brief, honor.text))}${noteOr(honor.brief, honor.detail) ? `
${copyHtml(noteOr(honor.brief, honor.detail))}` : ''}
${sourceLink(honor.sourceUrl)}
-
+ ${ + entry.index === 0 + ? `

Como ler

@@ -1354,80 +1536,165 @@ const renderInstitutionHonors = (report, pageNo) => {

Pendência documental

${gap ? `${htmlEscape(gap.label)}: ${htmlEscape(gap.reason)}` : 'Nenhuma pendência declarada para os registros desta página.'}

-
+
` + : '' + } ${instFooter(report, 'Títulos e vínculos · INSUMO INTERNO', pageNo)}
` } -const renderInstitutionGapsPage = (report, chunk, index, pageNo) => { - const rows = chunk.length - ? chunk - .map( - (gap) => ` +const renderInstitutionHonorSheets = ({ report, pack, probe, nextPage }) => { + const units = institutionHonorUnits({ report, probe }) + if (units.length === 0) return [] + return packedSection({ key: 'honors', anchor: 'titulos', units, pack, probe }).map((entry) => + renderInstitutionHonorSheet({ report, entry, probe, pageNo: nextPage() }), + ) +} + +/** + * Gaps and news are long full-text tables: packed in continuation sheets, so + * the sources sheet keeps only the acervo, limits and defeso. + */ +const gapUnits = (report, probe) => + report.gaps.map((gap, index) => ({ + layout: 'tr', + html: ` ${htmlEscape(gap.label)} ${htmlEscape(gap.reason)} Não completar por inferência. ${htmlEscape(gap.nextStep)} `, - ) - .join('') - : 'Nenhuma lacuna declarada — confira a cobertura das eras.' - return ` -
- ${instHeader(report, 'Lacunas explícitas', `O que não foi encontrado${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)} + })) + +const renderGapSheet = ({ report, entry, probe, pageNo }) => ` +
+ ${unitHeader(report, 'Lacunas explícitas', `O que não foi encontrado${entry.index > 0 ? ` · continuação ${entry.index + 1}` : ''}`, pageNo)}

O que não foi encontrado

Lacunas explícitas

Não completar por memória ou inferência

- - ${rows} + ${tableHead(['Lacuna', 'Situação da busca', 'Limite editorial', 'Próximo passo'])} + ${ + entry.chunk.length + ? entry.chunk.map((unit) => unit.html).join('') + : '' + }
Tabela de lacunas explícitas e próximos passos de apuração
LacunaSituação da buscaLimite editorialPróximo passo
Nenhuma lacuna declarada — confira a cobertura das eras.
- ${instFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)} + ${unitFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)}
` + +const renderGapSheets = ({ report, pack, probe, nextPage }) => { + const units = gapUnits(report, probe) + if (units.length === 0) { + return [ + renderGapSheet({ + report, + entry: { anchor: 'lacunas', chunk: [], index: 0 }, + probe: false, + pageNo: nextPage(), + }), + ] + } + return packedSection({ key: 'gaps', anchor: 'lacunas', units, pack, probe }).map((entry) => + renderGapSheet({ report, entry, probe, pageNo: nextPage() }), + ) } -const renderInstitutionSourcesPage = (report, pageNo) => ` +const newsUnits = (report, probe) => + report.news.map((row, index) => ({ + layout: 'tr', + html: ` + ${htmlEscape(formatShortDateBr(row.date))} + ${htmlEscape(row.outlet)} + ${htmlEscape(row.title)} + ${sourceLink(row.url, '(fonte)')} + `, + })) + +const renderNewsSheet = ({ report, entry, probe, pageNo }) => ` +
+ ${unitHeader(report, 'Notícias e documentos consultados', `Fontes externas datadas${entry.index > 0 ? ` · continuação ${entry.index + 1}` : ''}`, pageNo)} +
+

Lista clicável

+

Notícias e documentos consultados

+ + + + ${tableHead(['Data', 'Veículo', 'Título', 'Link'])} + ${entry.chunk.map((unit) => unit.html).join('')} +
Fontes consultadas: data 12%, veículo 13%, título 47% e link 28%
+
+ ${unitFooter(report, 'Notícias e documentos · INSUMO INTERNO', pageNo)} +
` + +const renderNewsSheets = ({ report, pack, probe, nextPage }) => { + const units = newsUnits(report, probe) + if (units.length === 0) return [] + return packedSection({ key: 'news', anchor: 'noticias', units, pack, probe }).map((entry) => + renderNewsSheet({ report, entry, probe, pageNo: nextPage() }), + ) +} + +/** Acervo sample (source panel): declared sample with the total carried by the header. */ +const acervoUnits = (report, probe) => + report.acervo.items.map((row, index) => ({ + layout: 'tr', + html: `${htmlEscape(row.period)} — ${copyHtml(row.text)} ${sourceLink(row.sourceUrl)}`, + })) + +const renderAcervoSheet = ({ report, entry, probe, pageNo, isLast }) => ` +
+ ${unitHeader( + report, + 'Acervo interno de falas (amostra)', + entry.index === 0 + ? `Base Teqo 2011+ · ${report.acervo.items.length} de ${report.acervo.recorte} falas do recorte, com link · ${htmlEscape(report.unit.acervoNote ?? '')}` + : `continuação ${entry.index + 1}`, + pageNo, + )} +
+

Falas do mandato sobre o recorte

+

Amostra com link direto

+
    ${entry.chunk.map((unit) => unit.html).join('')}
+ ${ + isLast + ? `

Amostra das falas mais recentes do recorte (${report.acervo.items.length} de ${report.acervo.recorte}); o acervo completo fica na base Teqo. ${htmlEscape(report.unit.acervoNote ?? '')}

` + : '' + } +
+ ${unitFooter(report, 'Acervo interno · amostra', pageNo)} +
` + +const renderAcervoSheets = ({ report, pack, probe, nextPage }) => { + const units = acervoUnits(report, probe) + if (units.length === 0) return [] + const entries = packedSection({ key: 'acervo', anchor: 'acervo', units, pack, probe }) + return entries.map((entry, index) => + renderAcervoSheet({ + report, + entry, + probe, + pageNo: nextPage(), + isLast: index === entries.length - 1, + }), + ) +} + +const renderUnitSourcesPage = (report, pageNo) => `
- ${instHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)} + ${unitHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)} ${ - report.news.length - ? `
-

Lista clicável

-

Notícias e documentos consultados

- - - - - ${report.news - .map( - (row) => ` - - - - - `, - ) - .join('')} -
Fontes consultadas: data 12%, veículo 13%, título 47% e link 28%
DataVeículoTítuloLink
${htmlEscape(formatShortDateBr(row.date))}${htmlEscape(row.outlet)}${htmlEscape(row.title)}${sourceLink(row.url, '(fonte)')}
-
` - : '' - } - - ${ - report.acervo?.items?.length - ? `
-

Acervo interno (read-only)

Falas do mandato sobre o recorte

Base Teqo, 2011+

-
    ${report.acervo.items - .map( - (row) => - `
  • ${htmlEscape(row.period)} — ${copyHtml(row.text)}${row.excerpt ? ` (${copyHtml(row.excerpt)})` : ''} ${sourceLink(row.sourceUrl)}
  • `, - ) - .join('')}
- ${moreNote(report.acervo, 'itens')} -
` + report.acervo?.recorte + ? `
+ ${WARNING_ICON} +
+

Acervo interno: ${report.acervo.recorte} falas no recorte.

+

A amostra com link (${report.acervo.items.length} de ${report.acervo.recorte}) está na seção anterior; o acervo completo fica na base Teqo. ${htmlEscape(report.unit.acervoNote ?? '')}

+
+
` : '' } @@ -1449,35 +1716,210 @@ const renderInstitutionSourcesPage = (report, pageNo) => ` - ${instFooter(report, 'Fontes e limites · INSUMO INTERNO', pageNo)} + ${unitFooter(report, 'Fontes e limites · INSUMO INTERNO', pageNo)}
` -const renderInstitutionDossierHtml = (report) => { - const gapChunks = chunkGaps(report.gaps) - report.meta.pageTotal = - 1 + // capa - 1 + // resumo - report.eras.length + - 1 + // abrangência - (hasInstitutionHonors(report) ? 1 : 0) + - gapChunks.length + - 1 // fontes - let page = 0 - const parts = [] - parts.push(renderInstitutionCover(report)) - page += 1 - parts.push(renderInstitutionSummary(report, ++page)) - for (const era of report.eras) parts.push(renderInstitutionEra(report, era, ++page)) - parts.push(renderInstitutionScope(report, ++page)) - if (hasInstitutionHonors(report)) parts.push(renderInstitutionHonors(report, ++page)) - gapChunks.forEach((chunk, index) => - parts.push(renderInstitutionGapsPage(report, chunk, index, ++page)), +const renderUnitSynthesisPage = (report, pageNo) => ` +
+ ${unitHeader(report, 'Síntese do que foi localizado', 'Leitura dos números com lastro — contagens por recorte, nunca soma entre eles', pageNo)} +
+

Leitura rápida

+

O que os pontos com fonte mostram

+
    ${report.synthesis.lines.map((line) => `
  • ${htmlEscape(line)}
  • `).join('')}
+
+
+
+

Cobertura das eras

+
    ${report.eras + .map( + (era) => + `
  • ${htmlEscape(era.label)} — ${ + era.empty + ? 'sem evidência suficiente (lacuna explícita)' + : `${listOf(era.numbers).length + listOf(era.actions).length} pontos nesta seção` + }
  • `, + ) + .join('')}
+
+
+

Guardas de leitura

+
    +
  • Contagens por recorte: setor e rede nunca somados à instituição.
  • +
  • Cada valor mantém a fase — empenho ≠ pagamento.
  • +
  • Sem fonte, não publica: ausência vira lacuna explícita.
  • +
+
+
+ + ${ + report.synthesis.moneyByPhase.length + ? `
+

Recursos por fase

+

Valores localizados, sem consolidar fases

+ + + + ${tableHead(['Fase', 'Itens', 'Valor'])} + ${report.synthesis.moneyByPhase + .map( + (row) => + ``, + ) + .join('')} +
Valores localizados por fase de execução
${phaseBadge(row.key)}${row.count}${htmlEscape(formatMoneyCompact(row.amount))}
+

Soma por fase apenas; fases não são equivalentes entre si.

+
` + : '' + } + ${unitFooter(report, 'Síntese · INSUMO INTERNO', pageNo)} +
` + +const renderUnitChartsPage = (report, pageNo) => { + const synthesis = report.synthesis + const executedPhases = new Set(['pago', 'liquidado', 'autorizado', 'empenhado']) + const executedYears = synthesis.moneyByYear.filter((row) => + row.segments.some((segment) => executedPhases.has(segment.key)), ) - parts.push(renderInstitutionSourcesPage(report, ++page)) + return ` +
+ ${unitHeader(report, 'Gráficos consolidados', 'Como os recursos e as entregas chegaram à instituição — valores sempre com a fase', pageNo)} +
+
+

Recursos com execução por ano (R$)

+ ${ + executedYears.length + ? stackedColumnChart({ + rows: executedYears.map((row) => ({ ...row, value: row.amount })), + height: 136, + format: (row) => formatMoneyCompact(row.value), + }) + : '

Nenhum valor com fase de execução informada.

' + } +

verde: pago/liquidado · âmbar: autorizado/empenhado — empenho não é pagamento.

+
+
+

Propostas e articulações sem fase informada (R$)

+ ${ + synthesis.moneyProposals.length + ? valueList({ + rows: synthesis.moneyProposals.map((row) => ({ + label: `${row.year ?? '—'} · ${row.label}`, + value: row.amount, + })), + format: (row) => formatMoneyCompact(row.value), + }) + : '

Nenhuma proposta sem fase informada.

' + } +

propostas e convênios citados nas fontes, sem fase de execução declarada.

+
+
+

Abrangência: ${report.unit.spheres + .map((sphere) => htmlEscape(report.unit.sphereLabels[sphere])) + .join(' × ')}

+ ${barChart({ + rows: synthesis.bySphere.map((row) => ({ + label: row.label, + count: row.count, + color: CHART_COLORS[row.key], + })), + })} +

${htmlEscape(report.unit.sumGuardNote ?? '')}

+
+
+

Trajetória: pontos com fonte por ano (20xx)

+ ${columnChart({ rows: synthesis.byYear, height: 156 })} +
+
+

Áreas com mais registros

+ ${barChart({ + rows: synthesis.byArea.slice(0, 6).map((row) => ({ label: row.key, count: row.count })), + })} +
+
+

Lacunas declaradas por era

+ ${barChart({ + rows: synthesis.gapsByEra.map((row) => ({ label: row.label, count: row.count })), + })} +

ausência é resultado: cada lacuna é uma tarefa de apuração.

+
+
+

Acervo interno (read-only)

+

${synthesis.totals.acervoRecorte}

+

falas no recorte temático (2011+)

+
    +
  • Amostra de ${synthesis.totals.acervo} falas com link direto (seção do acervo).
  • +
  • Temas: ${report.acervo?.topics?.join(', ') || '—'} — ${htmlEscape(report.unit.acervoNote ?? '')}
  • +
+
+
+ ${unitFooter(report, 'Gráficos · INSUMO INTERNO', pageNo)} +
` +} + +const renderUnitOpeningPage = (report, pageNo) => { + const opening = report.opening + if (!opening?.paragraphs?.length) return '' + return ` +
+ ${unitHeader(report, opening.title, 'Redação de síntese sobre os pontos com fonte — nada aqui acrescenta fato novo', pageNo)} +
+ ${opening.paragraphs.map((paragraph) => `

${copyHtml(paragraph)}

`).join('')} +
+ + ${unitFooter(report, 'A contribuição · INSUMO INTERNO', pageNo)} +
` +} + +const renderInstitutionDossierHtml = (report, { pack = null, probe = false } = {}) => { + const buildParts = () => { + let page = 0 + const nextPage = () => ++page + const parts = [] + parts.push(renderInstitutionCover(report)) + page += 1 + const opening = renderUnitOpeningPage(report, page + 1) + if (opening) { + parts.push(opening) + page += 1 + } + parts.push(renderInstitutionSummary(report, ++page)) + parts.push(renderUnitSynthesisPage(report, nextPage())) + parts.push(renderUnitChartsPage(report, nextPage())) + for (const era of report.eras) { + parts.push(...renderEraSheets({ report, era, pack, probe, nextPage, includeHonors: false })) + } + parts.push(renderInstitutionScope(report, nextPage())) + for (const list of report.reach.lists) { + parts.push(...renderInstitutionScopeListSheets({ report, list, pack, probe, nextPage })) + } + parts.push(...renderInstitutionHonorSheets({ report, pack, probe, nextPage })) + parts.push(...renderGapSheets({ report, pack, probe, nextPage })) + parts.push(...renderNewsSheets({ report, pack, probe, nextPage })) + parts.push(...renderAcervoSheets({ report, pack, probe, nextPage })) + parts.push(renderUnitSourcesPage(report, nextPage())) + return parts + } + + // Two passes: the shell's `pág. N/M` needs the final sheet count, and the + // count only exists after the pack plan is applied. + report.meta.pageTotal = 0 + let parts = buildParts() + report.meta.pageTotal = parts.length + parts = buildParts() return ` -${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.subjectName)} +${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.subjectName)}${probe ? `` : ''} ${parts.join('\n')} @@ -1492,6 +1934,9 @@ const institutionMdEra = (era) => { '', `Trilha: ${era.recovery}`, ] + if (era.narrative || era.summary) { + lines.push('', `> ${era.narrative ?? era.summary}`) + } if (era.empty) { lines.push( '', @@ -1510,6 +1955,8 @@ const institutionMdEra = (era) => { `| ${row.object} | ${row.value ?? '—'} | ${row.year ?? '—'} | ${dossierPhaseLabel(row.phase)} | ${row.sphere} | ${row.sourceUrl ?? '—'} |`, ) } + if (era.numbers.omitted > 0) + lines.push('', `_e mais ${era.numbers.omitted} item(ns) de valor com fonte._`) } for (const action of era.actions.items) { lines.push( @@ -1517,8 +1964,12 @@ const institutionMdEra = (era) => { `- **${stripInlineSources(action.title)}** (${action.sphere}${action.year ? ` · ${action.year}` : ''})${action.detail ? ` — ${stripInlineSources(action.detail)}` : ''} [fonte](${action.sourceUrl})`, ) } - for (const honor of era.honors) + if (era.actions.omitted > 0) + lines.push('', `_e mais ${era.actions.omitted} item(ns) de atuação com fonte._`) + for (const honor of era.honors.items) lines.push('', `- Honraria: ${stripInlineSources(honor.text)} [fonte](${honor.sourceUrl})`) + if (era.honors.omitted > 0) + lines.push('', `_e mais ${era.honors.omitted} honraria(s) com fonte._`) return lines.join('\n') } @@ -1539,9 +1990,20 @@ const renderInstitutionDossierMd = (report) => { `- **Alcance:** ${report.meta.identity?.scopeLabel ?? '—'}`, `- **Alias:** ${(report.meta.identity?.aliases ?? []).join(' · ') || '—'}`, '', - '## Linha do tempo documentada', - '', ] + if (report.opening?.paragraphs?.length) { + lines.push(`## ${report.opening.title}`, '') + for (const paragraph of report.opening.paragraphs) lines.push(paragraph, '') + lines.push( + `_Redação de síntese sobre os pontos com fonte; ${ + report.opening.authored + ? 'escrita a partir dos itens datados do dossiê' + : 'repetição da leitura dos números (sem redação autoral disponível)' + } — nada aqui acrescenta fato novo._`, + '', + ) + } + lines.push('## Linha do tempo documentada', '') if (report.page1.timeline.items.length === 0) lines.push('_Nenhum vínculo datado com fonte._') for (const row of report.page1.timeline.items) { lines.push( @@ -1565,35 +2027,60 @@ const renderInstitutionDossierMd = (report) => { for (const era of report.eras) lines.push('', institutionMdEra(era)) + if (report.synthesis?.lines?.length) { + lines.push('', '## Síntese do que foi localizado', '') + for (const line of report.synthesis.lines) lines.push(`- ${line}`) + lines.push('', '### Números por recorte', '') + lines.push('| Era | Instituição | Setor | Rede | Total |', '| --- | --- | --- | --- | --- |') + for (const row of report.synthesis.byEraSphere) { + const countFor = (key) => row.segments.find((segment) => segment.key === key)?.count ?? 0 + lines.push( + `| ${row.label} | ${countFor('instituicao')} | ${countFor('setor')} | ${countFor('rede')} | ${row.segments.reduce((sum, segment) => sum + segment.count, 0)} |`, + ) + } + if (report.synthesis.byPhase.length) { + lines.push('', '### Itens com valor por fase', '') + for (const row of report.synthesis.byPhase) lines.push(`- ${row.label}: ${row.count}`) + } + if (report.synthesis.byArea.length) { + lines.push('', '### Temas com mais registros', '') + for (const row of report.synthesis.byArea) lines.push(`- ${row.key}: ${row.count}`) + } + if (report.synthesis.acervoByYear.length) { + lines.push('', '### Acervo interno por ano', '') + lines.push(report.synthesis.acervoByYear.map((row) => `${row.key}: ${row.count}`).join(' · ')) + } + } + lines.push('', '## Abrangência: instituição × setor × rede', '') lines.push(`> ${report.reach.ruleTitle}`, `> ${report.reach.ruleBody}`, '') for (const list of report.reach.lists) { lines.push(`### ${list.label}`, '') if (list.items.length === 0) lines.push('- Nenhum item com fonte.') for (const item of list.items) { - lines.push(`- ${stripInlineSources(item.answer)} — [fonte](${item.sourceUrl})`) + lines.push( + `- **${stripInlineSources(briefOr(item.brief, item.title))}** (${item.sphere}${item.year ? ` · ${item.year}` : ''})${noteOr(item.brief, item.evidence) ? ` — ${stripInlineSources(noteOr(item.brief, item.evidence))}` : ''} [fonte](${item.sourceUrl})`, + ) } if (list.omitted > 0) lines.push(`- _e mais ${list.omitted} item(ns) com fonte._`) lines.push('') } - if (report.reach.evidence.items.length) { - lines.push('| Item | Abrangência | Evidência | Fonte |', '| --- | --- | --- | --- |') - for (const item of report.reach.evidence.items) { - lines.push( - `| ${stripInlineSources(item.item)} | ${item.sphere} | ${stripInlineSources(item.evidence)} | ${item.sourceUrl} |`, - ) - } - } if (report.acervo?.items?.length) { - lines.push('', '## Acervo interno (read-only)', '') + lines.push( + '', + `## Acervo interno (read-only)`, + '', + `_Amostra de ${report.acervo.items.length} de ${report.acervo.recorte} falas do recorte temático, com link; o acervo completo fica na base Teqo. Recorte por tema, não nominal._`, + '', + ) for (const row of report.acervo.items) { lines.push( `- **${row.period}** — ${stripInlineSources(row.text)}${row.excerpt ? ` _(${stripInlineSources(row.excerpt)})_` : ''} [fonte](${row.sourceUrl})`, ) } if (report.acervo.omitted > 0) - lines.push(`- _e mais ${report.acervo.omitted} fala(s) com fonte._`) + lines.push(`- _e mais ${report.acervo.omitted} fala(s) com fonte na base._`) } lines.push('', '## Lacunas explícitas', '') @@ -1629,10 +2116,10 @@ const renderInstitutionDossierMd = (report) => { } /** Dispatch: institution reports render with the institutional sections. */ -export const renderDossierHtml = (report) => +export const renderDossierHtml = (report, options) => isInstitutionUnit(report.unit) - ? renderInstitutionDossierHtml(report) - : renderMunicipalityDossierHtml(report) + ? renderInstitutionDossierHtml(report, options) + : renderMunicipalityDossierHtml(report, options) export const renderDossierMd = (report) => isInstitutionUnit(report.unit) diff --git a/scripts/lib/dossieUnit.mjs b/scripts/lib/dossieUnit.mjs index 4c2e270b..1d82bdf3 100644 --- a/scripts/lib/dossieUnit.mjs +++ b/scripts/lib/dossieUnit.mjs @@ -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 = { diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index b7e9b9f2..ed28ae7f 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -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', diff --git a/tests/unit/dossieBatchSkill.unit.spec.ts b/tests/unit/dossieBatchSkill.unit.spec.ts index d2c676b9..b4b88334 100644 --- a/tests/unit/dossieBatchSkill.unit.spec.ts +++ b/tests/unit/dossieBatchSkill.unit.spec.ts @@ -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/) + }) }) diff --git a/tests/unit/dossieBlocks.unit.spec.ts b/tests/unit/dossieBlocks.unit.spec.ts index 6d7cdc8a..f306f2c3 100644 --- a/tests/unit/dossieBlocks.unit.spec.ts +++ b/tests/unit/dossieBlocks.unit.spec.ts @@ -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', () => { diff --git a/tests/unit/dossieBulletin.unit.spec.ts b/tests/unit/dossieBulletin.unit.spec.ts index dcc7b806..6cda5df9 100644 --- a/tests/unit/dossieBulletin.unit.spec.ts +++ b/tests/unit/dossieBulletin.unit.spec.ts @@ -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 = {}) => ({ 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) diff --git a/tests/unit/dossieInstitution.unit.spec.ts b/tests/unit/dossieInstitution.unit.spec.ts index 8da16cf2..cbe132cf 100644 --- a/tests/unit/dossieInstitution.unit.spec.ts +++ b/tests/unit/dossieInstitution.unit.spec.ts @@ -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') diff --git a/tests/unit/dossieInstitutionBatchSkill.unit.spec.ts b/tests/unit/dossieInstitutionBatchSkill.unit.spec.ts index 71283117..ed3ddc5e 100644 --- a/tests/unit/dossieInstitutionBatchSkill.unit.spec.ts +++ b/tests/unit/dossieInstitutionBatchSkill.unit.spec.ts @@ -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/) + }) }) diff --git a/tests/unit/dossiePack.unit.spec.ts b/tests/unit/dossiePack.unit.spec.ts new file mode 100644 index 00000000..55392fb1 --- /dev/null +++ b/tests/unit/dossiePack.unit.spec.ts @@ -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() + }) +}) diff --git a/tests/unit/dossieRender.unit.spec.ts b/tests/unit/dossieRender.unit.spec.ts index 4dc67b12..afbfa9cb 100644 --- a/tests/unit/dossieRender.unit.spec.ts +++ b/tests/unit/dossieRender.unit.spec.ts @@ -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"')