From 3a1878691b38f8825ac81ecce8f2847d482f200a Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 22 Sep 2026 21:56:35 -0300 Subject: [PATCH] =?UTF-8?q?feat(C209):=20dossi=C3=AAs=20e=20boletins=20Sol?= =?UTF-8?q?la=20s=C3=B3brios=20e=20anal=C3=ADticos=20=E2=80=94=20O=20essen?= =?UTF-8?q?cial,=20defesa=20e=20consulta=20r=C3=A1pida=20(Closes=20#1251)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/dossie-solla-cidade/SKILL.md | 139 +- .../skills/dossie-solla-instituicao/SKILL.md | 127 +- .agents/skills/dossie-solla-tema/SKILL.md | 156 +- .../agent/dossie-solla-cidade-redacao.md | 11 +- .../agent/dossie-solla-instituicao-redacao.md | 11 +- .opencode/agent/dossie-solla-tema-redacao.md | 11 +- docs/changelog/2026-09-22-c209.md | 1 + docs/plans/dossies-sobrios-analiticos-impl.md | 188 + docs/plans/escala-dry-pos-c209.md | 64 + scripts/build-dossie-solla-cidade.mjs | 46 +- scripts/build-dossie-solla-instituicao.mjs | 43 +- scripts/build-dossie-solla-tema.mjs | 25 +- scripts/lib/buildPdf.mjs | 32 +- scripts/lib/dossieBlocks.mjs | 463 ++- scripts/lib/dossieBulletin.mjs | 59 +- scripts/lib/dossieBulletinRender.mjs | 519 +-- scripts/lib/dossieCareer.mjs | 17 - scripts/lib/dossiePack.mjs | 2 +- scripts/lib/dossieRender.mjs | 3087 +++++++---------- scripts/lib/dossieResearch.mjs | 43 +- scripts/lib/dossieUnit.mjs | 87 +- tests/unit/dossieBatchSkill.unit.spec.ts | 15 +- tests/unit/dossieBlocks.unit.spec.ts | 227 +- tests/unit/dossieBulletin.unit.spec.ts | 98 +- tests/unit/dossieInstitution.unit.spec.ts | 105 +- .../dossieInstitutionBatchSkill.unit.spec.ts | 15 +- tests/unit/dossieRender.unit.spec.ts | 146 +- tests/unit/dossieResearch.unit.spec.ts | 26 +- tests/unit/dossieTheme.unit.spec.ts | 111 +- tests/unit/dossieThemeBatchSkill.unit.spec.ts | 15 +- 30 files changed, 2952 insertions(+), 2937 deletions(-) create mode 100644 docs/changelog/2026-09-22-c209.md create mode 100644 docs/plans/dossies-sobrios-analiticos-impl.md create mode 100644 docs/plans/escala-dry-pos-c209.md diff --git a/.agents/skills/dossie-solla-cidade/SKILL.md b/.agents/skills/dossie-solla-cidade/SKILL.md index 93481fea..527666f6 100644 --- a/.agents/skills/dossie-solla-cidade/SKILL.md +++ b/.agents/skills/dossie-solla-cidade/SKILL.md @@ -192,7 +192,7 @@ consolidação determinística. { "id": "era_c_emendas", // id do checklist da era "answer": "registro integral; aceita {{fonte}} / {{fonte:N}} — vai para o .md e para o lastro", - "summary": "opcional — versão curta, completa e auto-contida do answer, redigida para caber no resumo/boletim (C188)", + "position": "opcional — só em item `*_defesas`: rótulo curto da posição (ex. \"Ensino superior\")", "details": "opcional; aceita {{fonte}} / {{fonte:N}}", "brief": { // obrigatório no item publicado: copy reformulada, curta "title": "≤80 chars — manchete (o quê + onde)", @@ -218,10 +218,18 @@ consolidação determinística. Checklist por era (ids): - **A:** `era_a_formacao`, `era_a_sesab`, `era_a_consultor_ms`, `era_a_conquista`, - `era_a_sas_ms`. -- **B:** `era_b_sesab`, `era_b_equipamentos`, `era_b_programas`, `era_b_obras`. + `era_a_sas_ms`, `era_a_defesas`. +- **B:** `era_b_sesab`, `era_b_equipamentos`, `era_b_programas`, `era_b_obras`, + `era_b_defesas`. - **C:** `era_c_discursos`, `era_c_proposicoes`, `era_c_emendas`, - `era_c_titulos`, `era_c_atuacao`. + `era_c_titulos`, `era_c_atuacao`, `era_c_defesas`. + +Os itens `era_X_defesas` são a dimensão **"O que Solla defende"** (`kind: +defense`): `answer` = frase da posição, `details` = lastro identificado +(ato/proposição, "Notícia: veículo · data" ou trecho de fala) e +`sourceUrl`/`sourceDate` da fonte do lastro — **sem registro datado vira lacuna**, +nunca posição inferida. Os demais itens (`kind: evidence`) alimentam as eras, a +região e a síntese; itens de defesa **não** entram nessas listas. Item sem `sourceUrl`/`sourceDate`, item ausente ou item de outra era vira **lacuna** (`Não pesquisado.` / `Sem fonte: …`); `sphere` inválida vira lacuna @@ -229,20 +237,6 @@ Item sem `sourceUrl`/`sourceDate`, item ausente ou item de outra era vira (default `nao_informado`). O `bulletinFacts` (ledger do boletim) só é populado por item **com fonte** — o boletim não introduz fato novo. -**`summary` (C188):** o resumo de uma olhada e o boletim **nunca cortam texto no -meio**. Essas superfícies imprimem o `summary` do item quando existe; sem ele, -imprimem o `answer` integral e, se a página não couber, o builder re-renderiza -apontando o aprofundamento (as páginas das eras, que sempre guardam -`answer`/`details` integrais) — **sem "…"**. Escreva `summary` curto e -auto-contido para os itens que viram entrega/gancho no resumo; é a única forma de -o texto que você redigiu aparecer no resumo. Listas capadas (entregas, ganchos, -números, itens regionais, fatos do boletim) sempre exibem o contador `e mais N` / -`Mostrando X de Y` — nenhum item some em silêncio. `summary` e `brief` coexistem: -o `brief` é a copy reformulada que o PDF imprime nas páginas de era/cartões; o -`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): @@ -252,75 +246,78 @@ orquestrador): "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" } + "eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" }, + "betweenEras": ["bullet", "bullet"] // opcional — leitura entre eras } ``` 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. +falha**: a abertura repete a leitura dos números, cada era usa a consolidação +determinística e a leitura entre eras é derivada dos registros. `opening`, +`eras` e `betweenEras` 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: +**`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 -item `regiao`/`polo` e nunca troca a fase (empenho ≠ pagamento). O cartão/linha -do PDF imprime `brief.title`/`brief.note`; o `answer`/`details` integrais ficam -no registro e no companion `.md`. Sem `brief`, o renderer cai no texto integral e -a guarda de fit A4 do builder **falha fechado** (não corta em silêncio). Tabelas +item `regiao`/`polo` e nunca troca a fase (empenho ≠ pagamento). As folhas de era +imprimem `brief.title`/`brief.note`; o `answer`/`details` integrais ficam no +registro e no companion `.md`. Sem `brief`, o renderer cai no texto integral e a +guarda de fit A4 do builder **falha fechado** (não corta em silêncio). Tabelas longas (lacunas, notícias) não usam `brief`: são **paginadas** em folhas de 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. -- **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. + data, "como ler", escopo/versão. +- **O essencial** — abre o documento: leitura do recorte (a redação de abertura), + fatos-chave (pontos com fonte por era, abrangência sem soma, valores por fase, + lacunas) e o **índice** com o número de página de cada seção. É a leitura antes + do inventário. +- **Leitura entre eras** — bullets de concentração, instrumentos, continuidade, + alcance e lacunas que pesam (`betweenEras` do `narrative.json` ou derivação + determinística) + tabela **Era / Onde está a evidência / Como citar com + segurança / Limite**. +- **Trajetória completa** — a linha do tempo da carreira em tabela + (período/papel/como recuperar), incertezas na própria linha. +- **Seção por era (A/B/C)** — leitura da era ("Leitura da era", do + `narrative.json` ou determinística) + tabela **Objeto/Valor/Ano/Fase/Esfera/ + Fonte** + lista **"O que fez · item — alcance — lastro"**. Fase e esfera são + texto, nunca selo colorido. **Sem cap**: a era flui por quantas folhas precisar + (continuações com cabeçalho "continuação N"). Uma era sem evidência é omitida + como seção e aparece no índice e na leitura entre eras com a nota "sem + evidência nominal suficiente; consulte as lacunas" — nunca inventada. +- **Região / polo** — callout "Não somar." + tabela de leitura lado a lado + (**nenhum total combinado**) e, em seguida, **as duas listas completas** em + tabela (item/esfera/evidência/fonte), cada uma fluindo por quantas folhas + precisar. +- **O que Solla defende** — seção própria e indexável: leitura dos registros + (não é opinião), lista de posições (`position-list`: rótulo da posição, leitura + com lastro, fonte + data + era) e, para cada item `era_X_defesas` sem registro, + a linha "Sem registro localizado" com a lacuna explícita. Só entra posição com + ato, notícia datada ou fala identificada como lastro. - **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. + interno** — tabelas/listas completas, também correntes (sem cap). As notícias + ganham a coluna "Uso no dossiê" (era) e o resumo datado; o acervo é **amostra + declarada** (as falas mais recentes com link, de N do recorte municipal); + 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á. + único "e mais N" é o da amostra do acervo. ## Conteúdo do Boletim modelo (1 página A4) -- Cabeçalho com município + rótulo **"Modelo — insumo interno"**; abertura - "O que Jorge Solla fez por "; **≤6 destaques** (eyebrow "Área · - cidade/região", número em destaque, título curto, nota); timeline de 4 passos - da trajetória; **"E mais" com ≤14 itens** em duas colunas; rodapé com defeso. +- Cabeçalho com município + rótulo **"Modelo — insumo interno"**; título + "O que Jorge Solla fez por "; lede curta; **≤6 destaques** em lista + (rótulo + texto, com o valor e a fase quando houver); **"O que Solla defende"** + com **≤2 defesas curtas** com lastro do ledger (bloco omitido quando não há + registro); trajetória em quatro períodos; **"E mais" com ≤14 itens** em lista; + rodapé com defeso. - **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem CTA de campanha; herda **apenas** fatos com fonte do dossiê. @@ -352,7 +349,7 @@ 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 +- **Redação e parágrafos com lastro**: a leitura de abertura 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 é @@ -379,8 +376,8 @@ continuação com o texto inteiro. - **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. + layout. Página **não-packed** (O essencial, leitura entre eras, trajetória, + defende, fontes) estourando = corte copy ou enxugue o índice. - **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. @@ -406,10 +403,10 @@ 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` — **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. +- Designs hi-fi (fonte de verdade do port): `docs/plans/dossies-sobrios-analiticos-ui-design.html` + (C209 — cenas do dossiê e do boletim, um artefato da família). Os hi-fi antigos + (`dossie-solla-cidade-ui-design.html` e `-boletim-ui-design.html`) ficam no repo + como registro e estão **superados**. - Precedente: `.agents/skills/relatorio-cidade/SKILL.md`, `.opencode/agent/relatorio-cidade.md`, `scripts/build-city-report.mjs`. - Scripts: `scripts/build-dossie-solla-cidade.mjs`, diff --git a/.agents/skills/dossie-solla-instituicao/SKILL.md b/.agents/skills/dossie-solla-instituicao/SKILL.md index de7d8674..ce9dd8d2 100644 --- a/.agents/skills/dossie-solla-instituicao/SKILL.md +++ b/.agents/skills/dossie-solla-instituicao/SKILL.md @@ -19,9 +19,9 @@ 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. +base vêm **O essencial** (redação de abertura + fatos-chave + índice), a +**leitura entre eras**, a abrangência e o **O que Solla defende**, e cada era +abre com a **leitura da era**. ## Quando usar @@ -199,6 +199,7 @@ consolidação determinística. { "id": "era_c_emendas", // id do checklist da era "answer": "R$ 5,0 milhões para estrutura de ensino e pesquisa", + "position": "opcional — só em item `*_defesas`: rótulo curto da posição (ex. \"Financiamento\")", "details": "opcional; aceita {{fonte}} / {{fonte:N}}", "sphere": "instituicao", // instituicao | setor | rede (default instituicao) "numbers": [ // opcional @@ -220,11 +221,18 @@ consolidação determinística. Checklist por era (ids): - **A:** `era_a_formacao`, `era_a_sesab`, `era_a_consultor_ms`, `era_a_conquista`, - `era_a_sas_ms`, `era_a_vinculo`. + `era_a_sas_ms`, `era_a_vinculo`, `era_a_defesas`. - **B:** `era_b_sesab`, `era_b_equipamentos`, `era_b_programas`, `era_b_obras`, - `era_b_convenios`. + `era_b_convenios`, `era_b_defesas`. - **C:** `era_c_discursos`, `era_c_proposicoes`, `era_c_emendas`, - `era_c_titulos`, `era_c_atuacao`, `era_c_parcerias`. + `era_c_titulos`, `era_c_atuacao`, `era_c_parcerias`, `era_c_defesas`. + +Os itens `era_X_defesas` são a dimensão **"O que Solla defende"** (`kind: +defense`): `answer` = frase da posição, `details` = lastro identificado +(ato/proposição, "Notícia: veículo · data" ou trecho de fala) e +`sourceUrl`/`sourceDate` da fonte do lastro — **sem registro datado vira lacuna**, +nunca posição inferida. Os demais itens (`kind: evidence`) alimentam as eras, a +abrangência e a síntese; itens de defesa **não** entram nessas listas. A **abrangência** (`sphere`) é `instituicao` | `setor` | `rede`; `setor` e `rede` não são a instituição e **nunca são somados** a ela (cada linha informa sua @@ -243,67 +251,71 @@ orquestrador): "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" } + "eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" }, + "betweenEras": ["bullet", "bullet"] // opcional — leitura entre eras } ``` 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. +falha**: a abertura repete a leitura dos números, cada era usa a consolidação +determinística e a leitura entre eras é derivada dos registros. `opening`, +`eras` e `betweenEras` 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**. É 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." 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. + (nome/tipo/esfera/alcance em tabela), data, "como usar", escopo/versão. +- **O essencial** — abre o documento: leitura do recorte (a redação de abertura), + identificação da instituição, fatos-chave (pontos com fonte por era, + abrangência sem soma, valores por fase, lacunas) e o **índice** com o número de + página de cada seção. +- **Leitura entre eras** — bullets de concentração, instrumentos, continuidade, + alcance e lacunas que pesam (`betweenEras` do `narrative.json` ou derivação + determinística) + tabela **Era / Onde está a evidência / Como citar com + segurança / Limite**. +- **Trajetória completa** — a linha do tempo da carreira em tabela + (período/papel/como recuperar), incertezas na própria linha. +- **Seção por era (A/B/C)** — leitura da era ("Leitura da era", do + `narrative.json` ou determinística) + tabela **Objeto/Valor/Ano/Fase/ + Abrangência/Fonte** + lista **"O que fez · item — alcance — lastro"**. Fase e + abrangência são texto, nunca selo colorido. **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** — callout "Não somar." + tabela de + leitura lado a lado (**nenhum total combinado**) e, em seguida, **as três + listas completas** em tabela (item/abrangência/evidência/fonte), cada uma + fluindo por quantas folhas precisar. +- **Títulos, honrarias e vínculos** — tabela data/natureza/registro/fonte, com a + regra de leitura (vínculo não prova resultado). +- **O que Solla defende** — seção própria e indexável: leitura dos registros + (não é opinião), lista de posições (`position-list`: rótulo da posição, leitura + com lastro, fonte + data + era) e, para cada item `era_X_defesas` sem registro, + a linha "Sem registro localizado" com a lacuna explícita. Só entra posição com + ato, notícia datada ou fala identificada como lastro. - **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. + interno** — tabelas/listas completas, também correntes (sem cap). As notícias + ganham a coluna "Uso no dossiê" (era) e o resumo datado; o acervo é **amostra + declarada** (as falas mais recentes com link, de N do recorte temático); 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á. + inteiro. O único "e mais N" é o da amostra do acervo. ## Conteúdo do Boletim modelo (1 página A4) -- Cabeçalho com o nome da instituição + identity pills + rótulo **"Modelo — - insumo interno"**; faixa de **defeso** ("sem CTA · sem propaganda"); abertura - "O que Jorge Solla fez pela e na "; **≤6 destaques** (eyebrow - "Área · abrangência", número em destaque, título curto, nota); timeline de 4 - passos da trajetória; **"E mais" com ≤14 itens** em duas colunas; rodapé com - controle editorial e defeso. Com **poucos fatos**, usa a variação de lacuna - (não cria cards vazios para chegar a seis). +- Cabeçalho com o nome da instituição + rótulo **"Modelo — insumo interno"**; + título "O que Jorge Solla fez pela e na "; lede curta; **≤6 + destaques** em lista (rótulo + texto, com o valor e a fase quando houver); + **"O que Solla defende"** com **≤2 defesas curtas** com lastro do ledger (bloco + omitido quando não há registro); trajetória em quatro períodos; **"E mais" com + ≤14 itens** em lista; rodapé com controle editorial e defeso. Com **poucos + fatos**, a página termina com espaço — sem preencher por inferência nem + inventar número. - **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem CTA de campanha; herda **apenas** fatos com fonte do dossiê. @@ -335,7 +347,7 @@ nenhum número, data, nome ou órgão novo. ## 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 +- **Redação e parágrafos com lastro**: a leitura de abertura 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. @@ -359,8 +371,8 @@ nenhum número, data, nome ou órgão novo. - **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. + o layout. Página **não-packed** (O essencial, leitura entre eras, trajetória, + títulos, defende, fontes) estourando = corte copy ou enxugue o índice. - **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. @@ -387,11 +399,10 @@ nenhum número, data, nome ou órgão novo. - Intenção: `docs/plans/dossie-solla-instituicao.md`; impl: `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` — **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. +- Designs hi-fi (fonte de verdade do port): `docs/plans/dossies-sobrios-analiticos-ui-design.html` + (C209 — cenas do dossiê e do boletim, um artefato da família). Os hi-fi antigos + (`dossie-solla-instituicao-ui-design.html` e `-boletim-ui-design.html`) ficam no + repo como registro e estão **superados**. - 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`, diff --git a/.agents/skills/dossie-solla-tema/SKILL.md b/.agents/skills/dossie-solla-tema/SKILL.md index 5bdd0792..adf5ab86 100644 --- a/.agents/skills/dossie-solla-tema/SKILL.md +++ b/.agents/skills/dossie-solla-tema/SKILL.md @@ -24,9 +24,9 @@ token fora dos 18 canônicos **falha fechado**, nunca vira slug inventado. O dossiê temático **não usa caps**: cada seção (eras, abrangência, lacunas, notícias, acervo) flui por quantas páginas precisar, com quebra só entre linhas — o orquestrador não decide o corte, o builder mede e pagina. Sobre essa base -vêm a **redação de abertura** (carta), a **síntese** dos números e os **gráficos -consolidados**, e cada era abre com um **parágrafo de consolidação** do que ela -entrega. +vêm **O essencial** (redação de abertura + fatos-chave + índice), a **leitura +entre eras**, a abrangência e o **O que Solla defende**, e cada era abre com a +**leitura da era**. ## Quando usar @@ -203,7 +203,7 @@ consolidação determinística. { "id": "era_c_emendas", // id do checklist da era "answer": "R$ 5,0 milhões para a rede de ensino", - "summary": "opcional — versão curta, completa e auto-contida do answer, redigida para caber no resumo/boletim (C188)", + "position": "opcional — só em item `*_defesas`: rótulo curto da posição (ex. \"Educação básica\")", "details": "opcional; aceita {{fonte}} / {{fonte:N}}", "brief": { // obrigatório no item publicado: copy reformulada, curta "title": "≤80 chars — manchete (o quê + onde)", @@ -229,11 +229,19 @@ consolidação determinística. Checklist por era (ids): - **A:** `era_a_formacao`, `era_a_sesab`, `era_a_consultor_ms`, `era_a_conquista`, - `era_a_sas_ms`, `era_a_vinculo`. + `era_a_sas_ms`, `era_a_vinculo`, `era_a_defesas`. - **B:** `era_b_sesab`, `era_b_politicas`, `era_b_investimentos`, - `era_b_convenios`, `era_b_articulacao`. + `era_b_convenios`, `era_b_articulacao`, `era_b_defesas`. - **C:** `era_c_discursos`, `era_c_proposicoes`, `era_c_relatorias`, - `era_c_emendas`, `era_c_programas`, `era_c_audiencias`, `era_c_titulos`. + `era_c_emendas`, `era_c_programas`, `era_c_audiencias`, `era_c_titulos`, + `era_c_defesas`. + +Os itens `era_X_defesas` são a dimensão **"O que Solla defende"** (`kind: +defense`): `answer` = frase da posição, `details` = lastro identificado +(ato/proposição, "Notícia: veículo · data" ou trecho de fala) e +`sourceUrl`/`sourceDate` da fonte do lastro — **sem registro datado vira lacuna**, +nunca posição inferida. Os demais itens (`kind: evidence`) alimentam as eras, a +abrangência e a síntese; itens de defesa **não** entram nessas listas. A **abrangência** (`sphere`) é `area` | `segmento` | `rede`; `segmento` e `rede` não são a área e **nunca são somados** a ela (cada linha informa sua @@ -243,16 +251,16 @@ 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. -**`brief` (copy reformulada, sem reticências) e `summary` (C188).** As páginas -A4 têm altura fixa: o `brief` é a copy que o PDF imprime nas páginas de era, nas -listas de abrangência e nos cartões do boletim (o `answer`/`details` integrais -ficam no `.md` e no lastro); sem ele o builder imprime o texto integral e a -página pode estourar. Escreva `brief.title` ≤ 80 e `brief.note` ≤ 120 chars para -**todo item publicado**; o `summary` alimenta as superfícies de resumo. O -boletim de uma página **mede e reduz por medição**: o painel do acervo (falas -com link) entra no contador "e mais N fatos com fonte", nunca nos destaques; se -ainda não couber, o builder re-renderiza com menos fatos impressos e declara o -resto — sem truncar texto e sem descarte silencioso. +**`brief` (copy reformulada, sem reticências).** As páginas A4 têm altura fixa: +o `brief` é a copy que o PDF imprime nas páginas de era, nas listas de +abrangência e na lista do boletim (o `answer`/`details` integrais ficam no `.md` +e no lastro); sem ele o builder imprime o texto integral e a página pode +estourar. Escreva `brief.title` ≤ 80 e `brief.note` ≤ 120 chars para **todo item +publicado**. O boletim de uma página **mede e reduz por medição**: o painel do +acervo (falas com link) entra no contador "e mais N fatos com fonte", nunca nos +destaques; se ainda não couber, o builder re-renderiza com menos fatos impressos +e depois com menos defesas, declarando o resto — sem truncar texto e sem +descarte silencioso. `.narrative.json` (opcional; escrito pelo redator, auditado pelo orquestrador): @@ -263,71 +271,71 @@ orquestrador): "generatedAt": "2026-09-18T00:26:19.000Z", // obrigatório "title": "O que Jorge Solla fez pela Educação", "opening": ["parágrafo", "parágrafo", "parágrafo"], - "eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" } + "eras": { "A": "parágrafo", "B": "parágrafo", "C": "parágrafo" }, + "betweenEras": ["bullet", "bullet"] // opcional — leitura entre eras } ``` Sem o arquivo (ou com `themeSlug` diferente do snapshot), o builder **não -falha**: a abertura repete a leitura dos números e cada era usa a consolidação -determinística. `opening` e `eras` só podem conter fatos dos itens com fonte — -nenhum número, data, nome ou órgão novo. +falha**: a abertura repete a leitura dos números, cada era usa a consolidação +determinística e a leitura entre eras é derivada dos registros. `opening`, +`eras` e `betweenEras` só podem conter fatos dos itens com fonte — nenhum número, +data, nome ou órgão novo. ## Conteúdo do dossiê - **Capa** — série temática, "INSUMO INTERNO — defeso 2026", identificação da - área (label `Educação` + valor canônico `educacao` + nota da taxonomia), data, - "como usar", escopo/versão. -- **A contribuição (carta)** — redação de abertura sobre o que Solla fez pela - área ao longo das eras, em prosa, com a nota "Como ler" (cada afirmação tem - lastro em item datado; a redação não preenche por inferência). -- **Resumo de uma olhada** — identificação (área/token/origem/filtro) + linha do - tempo documentada do vínculo; principais entregas localizadas (badge de - abrangência + valor + badge de fase + fonte); gancho para a agenda; o que - falta. Guarda: **autorizado ≠ empenhado ≠ liquidado ≠ pago**. É a única página - com listas capadas — e o "e mais N" aponta para a seção onde a lista completa - está. -- **Síntese** — leitura dos números com lastro (pontos por era e abrangência, - concentração relativa, itens com valor por fase, recursos por fase, temas, - lacunas) + guardas de leitura; nada que não venha dos itens. -- **Gráficos consolidados** — recursos **com execução por ano** (empilhado por - fase), **propostas/articulações sem fase informada**, abrangência, trajetória - por ano, áreas e lacunas por era, painel do acervo. Valores sempre em R$ com a - fase; recortes nunca somados. -- **Seção por era (A/B/C)** — parágrafo de consolidação ("O que esta era - entrega") + recorte e método + trilha de recuperação; tabela - **Objeto/Valor/Ano/Fase/Abrangência/Fonte**; cards "Papéis e iniciativas com - evidência visível". **Sem cap**: a era flui por quantas folhas precisar - (continuações com cabeçalho "continuação N"). Uma era **sem evidência** vira - **página de lacuna explícita** (nunca seção em branco nem "zero"). -- **Abrangência: área × segmento × rede** — painel "Segmento/rede não é a área. - Não some os recortes." com contagem por recorte (**nenhum total combinado**) e - prévia; em seguida **as três listas completas** em tabela - (item/abrangência/evidência/fonte), cada uma fluindo por quantas folhas - precisar; gancho e lacuna prioritária. + área (label + valor canônico + nota da taxonomia), data, "como usar", + escopo/versão. +- **O essencial** — abre o documento: leitura do recorte (a redação de abertura), + identificação da área, fatos-chave (pontos com fonte por era, abrangência sem + soma, valores por fase, lacunas) e o **índice** com o número de página de cada + seção. +- **Leitura entre eras** — bullets de concentração, instrumentos, continuidade, + alcance e lacunas que pesam (`betweenEras` do `narrative.json` ou derivação + determinística) + tabela **Era / Onde está a evidência / Como citar com + segurança / Limite**. +- **Trajetória completa** — a linha do tempo da carreira em tabela + (período/papel/como recuperar), incertezas na própria linha. +- **Seção por era (A/B/C)** — leitura da era ("Leitura da era", do + `narrative.json` ou determinística) + tabela **Objeto/Valor/Ano/Fase/ + Abrangência/Fonte** + lista **"O que fez · item — alcance — lastro"**. Fase e + abrangência são texto, nunca selo colorido. **Sem cap**: a era flui por quantas + folhas precisar (continuações com cabeçalho "continuação N"). Uma era **sem + evidência** vira **página de lacuna explícita** (nunca seção em branco nem + "zero"). +- **Abrangência: área × segmento × rede** — callout "Não somar." + tabela de + leitura lado a lado (**nenhum total combinado**) e, em seguida, **as três + listas completas** em tabela (item/abrangência/evidência/fonte), cada uma + fluindo por quantas folhas precisar; a cena temática acrescenta Era/ano, + Valor/fase e a evidência temática. +- **O que Solla defende** — seção própria e indexável: leitura dos registros + (não é opinião), lista de posições (`position-list`: rótulo da posição, leitura + com lastro, fonte + data + era) e, para cada item `era_X_defesas` sem registro, + a linha "Sem registro localizado" com a lacuna explícita. Só entra posição com + ato, notícia datada ou fala identificada como lastro. - **Lacunas explícitas**, **Notícias e documentos consultados** e **Acervo - interno por tema** — tabelas/listas completas, também correntes (sem cap). O - acervo é **amostra declarada** (as falas mais recentes com link, de N do - recorte `Speech.topics = `) com painel do total e do universo. -- **Fontes e limites** — limites de cobertura; regras para uso editorial; nota de - defeso eleitoral 2026. + interno por tema** — tabelas/listas completas, também correntes (sem cap). As + notícias ganham a coluna "Uso no dossiê" (era) e o resumo datado; o acervo é + **amostra declarada** (as falas mais recentes com link, de N do recorte + `Speech.topics = `). +- **Fontes e limites** — hierarquia de fontes; limites de cobertura; regras para + uso editorial; nota de defeso eleitoral 2026. - **Nada some e nada é cortado:** o dossiê temático **não usa caps** — o builder mede a altura real de cada linha e pagina (grow/shrink) até a página ficar cheia sem estourar; listas continuam em folhas de continuação com o texto - inteiro. O único "e mais N" é o do resumo e o da amostra do acervo, ambos - apontando para onde o resto está. + inteiro. O único "e mais N" é o da amostra do acervo. ## Conteúdo do Boletim modelo (1 página A4) -- Cabeçalho com o nome da área + identity pills + rótulo **"Modelo — insumo - interno"**; faixa de **defeso** ("sem CTA · sem propaganda"); abertura "O que - Jorge Solla fez na área de <área>"; **≤6 destaques** (eyebrow "Área · - abrangência", número em destaque, título curto, nota); timeline de 4 passos da - trajetória; **"E mais" com ≤14 itens** em duas colunas; rodapé com controle - editorial e defeso. Com **poucos fatos**, usa a variação de lacuna (não cria - cards vazios para chegar a seis). O painel do acervo **não ocupa vaga de - destaque**: entra no contador "e mais N fatos com fonte"; se os fatos - impressos ainda estourarem a folha, o builder mede e reduz o conjunto - impresso até caber, declarando o resto — nunca corta texto. +- Cabeçalho com o nome da área + rótulo **"Modelo — insumo interno"**; título + "O que Jorge Solla fez pela área de <área>"; lede curta; **≤6 destaques** em + lista (rótulo + texto, com o valor e a fase quando houver); **"O que Solla + defende"** com **≤2 defesas curtas** com lastro do ledger (bloco omitido quando + não há registro); trajetória em quatro períodos; **"E mais" com ≤14 itens** em + lista; rodapé com controle editorial e defeso. Com **poucos fatos**, a página + termina com espaço — sem preencher por inferência nem inventar número. O painel do acervo + **não ocupa vaga de destaque**: entra no contador "e mais N fatos com fonte". - **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem CTA de campanha; herda **apenas** fatos com fonte do dossiê. @@ -359,7 +367,7 @@ nenhum número, data, nome ou órgão novo. ## 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 +- **Redação e parágrafos com lastro**: a leitura de abertura 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. @@ -386,8 +394,8 @@ nenhum número, data, nome ou órgão novo. - **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. + o layout. Página **não-packed** (O essencial, leitura entre eras, trajetória, + defende, fontes) estourando = corte copy ou enxugue o índice. - **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. @@ -410,10 +418,10 @@ nenhum número, data, nome ou órgão novo. - Intenção: `docs/plans/dossie-solla-tema.md`; impl: `docs/plans/dossie-solla-tema-impl.md`. -- Designs hi-fi (fonte de verdade do port): - `docs/plans/dossie-solla-tema-ui-design.html` e - `docs/plans/dossie-solla-tema-boletim-ui-design.html` — variantes da família - C187; um passe do `designer` os mantém sincronizados com a revisão corrente. +- Designs hi-fi (fonte de verdade do port): `docs/plans/dossies-sobrios-analiticos-ui-design.html` + (C209 — cenas do dossiê e do boletim, um artefato da família). Os hi-fi antigos + (`dossie-solla-tema-ui-design.html` e `-boletim-ui-design.html`) ficam no repo + como registro e estão **superados**. - Skills irmãs: `.agents/skills/dossie-solla-cidade/SKILL.md` (dona do pipeline) e `.agents/skills/dossie-solla-instituicao/SKILL.md` (segundo recorte). - Scripts: `scripts/build-dossie-solla-tema.mjs`, diff --git a/.opencode/agent/dossie-solla-cidade-redacao.md b/.opencode/agent/dossie-solla-cidade-redacao.md index b97ca398..10d32206 100644 --- a/.opencode/agent/dossie-solla-cidade-redacao.md +++ b/.opencode/agent/dossie-solla-cidade-redacao.md @@ -17,8 +17,10 @@ terem sido pesquisadas, pelo agente principal. `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. + 110–150 palavras), `eras` (A/B/C, 80–120 palavras cada) — prosa corrida, + contando a contribuição de Solla para a cidade e seu recorte regional — e + `betweenEras` (2–5 bullets curtos de leitura entre eras: concentração, + instrumentos, continuidade, alcance/ruptura e lacunas que pesam). 3. Devolver **apenas o recibo curto** (seção "Recibo do redator" da skill). ## Limites @@ -30,6 +32,9 @@ terem sido pesquisadas, pelo agente principal. - 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. + use as palavras "dossiê", "insumo" ou "pesquisa" dentro dos parágrafos (os + bullets de `betweenEras` seguem a mesma régua). +- **"O que Solla defende" não é sua:** as posições vêm dos itens `era_X_defesas` + com fonte; não atribua defesa sem registro em lugar nenhum da redação. - **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-redacao.md b/.opencode/agent/dossie-solla-instituicao-redacao.md index 7cae0714..da4be892 100644 --- a/.opencode/agent/dossie-solla-instituicao-redacao.md +++ b/.opencode/agent/dossie-solla-instituicao-redacao.md @@ -18,8 +18,10 @@ pesquisadas, pelo agente principal. `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. + 110–150 palavras), `eras` (A/B/C, 80–120 palavras cada) — prosa corrida, + contando a contribuição de Solla para a instituição por era — e + `betweenEras` (2–5 bullets curtos de leitura entre eras: concentração, + instrumentos, continuidade, alcance/ruptura e lacunas que pesam). 3. Devolver **apenas o recibo curto** (seção "Recibo do redator" da skill). ## Limites @@ -31,6 +33,9 @@ pesquisadas, pelo agente principal. - 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. + use as palavras "dossiê", "insumo" ou "pesquisa" dentro dos parágrafos (os + bullets de `betweenEras` seguem a mesma régua). +- **"O que Solla defende" não é sua:** as posições vêm dos itens `era_X_defesas` + com fonte; não atribua defesa sem registro em lugar nenhum da redação. - **Não** rode ssh, extração ou build; **não** edite os `research.json`; **não** commite nada (artefato gitignored, repo público). diff --git a/.opencode/agent/dossie-solla-tema-redacao.md b/.opencode/agent/dossie-solla-tema-redacao.md index cddc6b3f..bfd18fc8 100644 --- a/.opencode/agent/dossie-solla-tema-redacao.md +++ b/.opencode/agent/dossie-solla-tema-redacao.md @@ -17,8 +17,10 @@ três eras terem sido pesquisadas, pelo agente principal. `sourceDate`; `gaps[]` são lacunas explícitas). 2. Escrever `data/dossie-solla-tema/.narrative.json` no contrato da skill: `themeSlug`, `generatedAt`, `title`, `opening` (3 parágrafos, 110–150 - palavras) e `eras` (A/B/C, 80–120 palavras cada) — prosa corrida, contando a - contribuição de Solla para a área por era. + palavras), `eras` (A/B/C, 80–120 palavras cada) — prosa corrida, contando a + contribuição de Solla para a área por era — e `betweenEras` (2–5 bullets + curtos de leitura entre eras: concentração, instrumentos, continuidade, + alcance/ruptura e lacunas que pesam). 3. Devolver **apenas o recibo curto** (seção "Recibo do redator" da skill). ## Limites @@ -30,6 +32,9 @@ três eras terem sido pesquisadas, pelo agente principal. - 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. + use as palavras "dossiê", "insumo" ou "pesquisa" dentro dos parágrafos (os + bullets de `betweenEras` seguem a mesma régua). +- **"O que Solla defende" não é sua:** as posições vêm dos itens `era_X_defesas` + com fonte; não atribua defesa sem registro em lugar nenhum da redação. - **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/docs/changelog/2026-09-22-c209.md b/docs/changelog/2026-09-22-c209.md new file mode 100644 index 00000000..b7339a81 --- /dev/null +++ b/docs/changelog/2026-09-22-c209.md @@ -0,0 +1 @@ +**Dossiês e boletins Solla sóbrios e analíticos (C209, Issue #1251):** a família de dossiês (cidade/instituição/tema) e o boletim modelo foram redesenhados do zero como documento sóbrio, list-based e analítico, portando classe-a-classe o hi-fi `docs/plans/dossies-sobrios-analiticos-ui-design.html`. O dossiê agora abre pela leitura: **O essencial** (redação + fatos-chave + índice com página real por seção), **Leitura entre eras** (concentração/instrumentos/continuidade/alcance/lacunas + síntese operacional), **Trajetória completa** em tabela, eras com leitura + tabela Objeto/Valor/Ano/Fase/Esfera/Fonte + lista "O que fez · item — alcance — lastro", **O que Solla defende** (dimensão nova: itens de checklist `era_X_defesas`, `kind: 'defense'`, com lastro obrigatório — ato, notícia datada ou trecho do acervo; sem registro vira lacuna explícita), região/polo e abrangência em sequências não somáveis, lacunas/notícias (coluna "Uso no dossiê")/acervo/fontes. Morreram os gráficos consolidados, os cartões, os selos coloridos de esfera/fase, os placeholders de imagem e os caps do resumo; fase e esfera são texto. O boletim de 1 página ganhou o bloco "O que Solla defende" (≤2 defesas com lastro, fora do ranking e dos caps, contadas no "e mais N"), destaques em lista e trajetória em quatro períodos. A engine edita os donos (`dossieResearch`/`dossieBlocks`/`dossieRender`/`dossieBulletin*`/`buildPdf`), o `narrative.json` ganhou `betweenEras` opcional (fallback determinístico), o índice do companion `.md` ganhou âncoras reais e a guarda A4 fail-closed foi preservada nos três builders. Sem `src/`, sem schema/migration/URL pública, sem tocar a campanha; débitos maiores triados na Issue #1265. diff --git a/docs/plans/dossies-sobrios-analiticos-impl.md b/docs/plans/dossies-sobrios-analiticos-impl.md new file mode 100644 index 00000000..5f4fd943 --- /dev/null +++ b/docs/plans/dossies-sobrios-analiticos-impl.md @@ -0,0 +1,188 @@ +# Impl: Dossiês e boletins Solla sóbrios e analíticos — listas, análise (o que fez × o que defende) e consulta rápida (cidade · instituição · tema) + +Status: **aprovado** — modo --auto +Atualizado em: 2026-09-22 +Issue: #1251 +Intenção: docs/plans/dossies-sobrios-analiticos.md +Appetite restante: **~4–5 dias eng** (herdado). Se estourar, corta-se o número de página no índice (vira nome de seção, precedente C163) e o `betweenEras` autoral; **nunca** o "defende" com lastro, a remoção de gráficos/cartões/selos, a guarda A4 nem os guardrails de produto. + +## Leitura da intenção + +- **Outcome:** as três variantes de dossiê (cidade/instituição/tema) e o boletim modelo relidos como documento sóbrio, list-based e analítico — "O essencial" abre (fez + defende + destaques + lacunas), cada era mantém anatomia constante, o "O que Solla defende" nasce com lastro (ato/notícia datada/trecho do acervo) e o índice+âncoras dão consulta rápida; nada some e os guardrails seguem intactos. +- **O que NÃO negociar:** sem fonte não publica; fase por valor (autorizado ≠ empenhado ≠ liquidado ≠ pago); esfera/abrangência explícita e nunca somada; leitura relativa (nunca % estadual); atribuição de emenda só com autoria checada; PII mínima; artefato gitignored; defeso (insumo interno/modelo, sem CTA); sem marca de campanha, sem imagens nem placeholders de imagem; boletim 1 página A4 com rótulo "Modelo — insumo interno". Outcome de produto inviolável. +- **O que reavaliar (hipóteses da intenção):** + - "O dossiê precisa de índice com página" — o paginador de 2 passadas (`dossieRender.mjs:535-540`, `:1990-1995`) já conhece a página de cada folha na 2ª passada; o mapa âncora→página é colhido sem reescrever o paginador (Decisão A). + - "'O que defende' reusa a pesquisa existente" — confirmado: item de checklist com `sourceUrl`+`sourceDate` obrigatórios já é o contrato de lastro (`dossieResearch.mjs:478-485`); falta só discriminar `kind: 'defense'` (MUDANÇA) e preservar `news.summary`, hoje normalizado e descartado (`dossieResearch.mjs:554` → `dossieBlocks.mjs:503-508`/`:1140-1145`). + - "'Gráficos consolidados' morre" — confirmado no render; `chartPrimitives.mjs` **não** morre (consumidor Instagram em `graficosInstagramRender.mjs:18`). + - O guard do emit exige `[data-page="resumo"]` (`buildPdf.mjs:242-246`): a folha "O essencial" mantém a âncora `resumo` e só muda título/conteúdo. + +## Abordagem recomendada + +```mermaid +flowchart LR + D["design aprovado
dossies-sobrios-analiticos-ui-design.html
(10 cenas dossiê + cena 12 boletim)"] --> U["dossieUnit (3 variantes)"] + U --> R["dossieResearch
checklist + kind:'defense'
+ news.summary preservado"] + R --> B["dossieBlocks
report: defends / betweenEras / news"] + B --> H["dossieRender
CSS sóbrio + seções novas
+ índice âncora→página"] + B --> MD["companion .md espelhado"] + H --> P["dossiePack + buildPdf
(probe, fit A4, emit)"] + B --> BL["dossieBulletin
defenses fora do ranking"] + BL --> BR["dossieBulletinRender
cena 12 list-based"] + P --> PDF["dossiê PDF + boletim PDF"] + BR --> PDF +``` + +**Opções consideradas:** A) port classe-a-classe do design aprovado sobre a engine existente, editando os donos (`dossie*`, builders, SKILLs) | B) renderer v2 em paralelo (fork com feature flag) | C) uma linguagem por variante (3 redesenhos). +**Recomendação: A** — o design é a restrição aprovada ("portar classe a classe, sem redesenhar") e a engine já é multiplexada por `unit` (`dossieUnit.mjs:440-451`); editar o dono mantém pack/fit/emit e não twina spec. +**Rejeitadas:** B porque duplica render/CSS/specs e deixaria dois caminhos de paginação para manter; C porque a intenção pede **um** desenho para a família e triplicaria a atualização de specs/skills. + +### Componentes / mudanças + +- **`scripts/lib/dossieResearch.mjs`** — checklists por era × variante (`:30-335`) ganham itens de posições/defesas com `kind: 'defense'` (MUDANÇA explícita); normalizer (`:422`) propaga `kind` ao item (`:497-515`); `news.summary` continua normalizado (`:554`) e passa a ser preservado no merge/report. Nenhum schema novo de arquivo. +- **`scripts/lib/dossieBlocks.mjs`** — `report.defends` (defesas com lastro, por variante), `report.news[].summary`, `bulletinFacts[].defense` (MUDANÇA); `buildDossierSynthesis` (`:641`) alimenta a leitura entre eras; `bulletinFacts` cidade `:383-427` e subject `:1049-1083`. +- **`scripts/lib/dossieRender.mjs`** — novo `DOSSIER_PRINT_CSS` sóbrio (substitui `PRINT_CSS :358-484`/`INSTITUTION_PRINT_CSS :732-761`); folhas novas/reescritas: capa, `resumo` = O essencial + índice, `sintese` = Leitura entre eras, `trajetoria` em tabela, eras (leitura + tabela + lista "O que fez"), `defende` (nova), região, lacunas, notícias/acervo/fontes; mapa `anchorPages` colhido na 1ª passada (`:487-533`, `:1950-1988`); MD espelhado (`:585`, `:2053`) com sumário. +- **`scripts/lib/dossieBulletin.mjs` / `dossieBulletinRender.mjs`** — `buildBulletin` (`:42`) separa `defenses` (≤2) do ranking `:18-29`/caps `:13-14`; render da cena 12 (`:118-213` município, `:245-390` subject) list-based, sem `asset-box`/card. +- **`scripts/lib/buildPdf.mjs`** — sem mudança no contrato; os 3 builders alinham `onDossierOverflow`/`onBulletinOverflow` (`emitHtmlPairPdf :224`). +- **Builders** — `build-dossie-solla-cidade.mjs`, `-instituicao.mjs`, `-tema.mjs`: callbacks de fit nos três (hoje só cidade tem dossier-overflow `:289-293` e só tema tem bulletin-shrink `:239-245`). +- **`.agents/skills/dossie-solla-{cidade,instituicao,tema}/SKILL.md`** — "Conteúdo do dossiê" (`cidade:269`, `instituicao:248`, `tema:266`) e "Conteúdo do Boletim modelo" (`:311`/`:291`/`:310`) reescritos; contrato JSON com os ids de defesa. +- **`.opencode/agent/dossie-solla-{cidade,instituicao,tema}-redacao.md`** — `narrative.json` ganha `betweenEras` (MUDANÇA, opcional, fallback determinístico); regras do defende (não é opinião; só registro). +- **Migration:** sem migration — escopo `scripts/` + skills/agentes/specs; nada em `src/`, sem banco. +- **Access / Consent:** não toca. Nenhum write path, nenhum PII novo. +- **UI:** Impeccable C (redesenho de superfície existente) — shape→craft→critique→polish já feitos no gate; o impl **porta o design** e usa os specs de render como prova; o artefato do designer é imutável como registro (mudança material volta ao humano no PR). + +### Dados → forma (se aplicável) + +- **Forma:** listas e tabelas (`.plain-list`, `.index-list`, `.document-table`, `.action-list`, `.position-list`, boletim `.bulletin-list`/`.period-list`) + prosa curta; **sem** gráficos, cartões ou selos coloridos; fase e esfera/abrangência como texto/coluna; números sempre na tabela/prosa com fonte e data. +- **Por quê:** é a forma aprovada no gate e a que sustenta "leitura antes do inventário" com rastreio item a item; a leitura relativa/local se mantém (contagens por recorte, nunca soma entre esferas). +- **Rejeitadas:** gráficos (rejeitado pelo humano; devolveria o inventário visual), cartões lado a lado e selos (idem), e derivar visual novo além do design (decisão do designer, não da engine). + +## Decisões de engenharia + +### A) Índice + âncoras no PDF + +**Opções:** A) mapa âncora→página colhido na 2ª passada do renderer + índice com número de página real | B) índice textual sem página (precedente C163 `cityReportRender.mjs:361,411`) | C) outline/bookmarks do PDF (`page.pdf({ outline })`). + +**Recomendação: A** — o design (cena 02, `ui-design.html:798-910`) mostra o índice com número de página (`index-number`/`index-page`) e o rodapé corrente com a âncora do companion (`#o-essencial`, `:902-908`); a 2ª passada já percorre as partes em ordem com `nextPage()` (`dossieRender.mjs:487-533`/`:1950-1988`), então basta registrar `anchorPages[data-page] = pageNo` na 1ª passada e renderizar o índice na 2ª. Seções omitidas (era vazia) aparecem com o texto do design ("sem evidência nominal suficiente; consulte as lacunas", cena 04 `:1118-1122`), nunca com número inventado. + +**Rejeitadas:** B porque o design aprovado e a intenção ("consulta rápida", linha 42) pedem a página, e o custo de A é local; C porque outline não aparece no documento impresso, não controla o layout e não espelha no `.md` — fica fora (não é o mecanismo de consulta). +**Corte declarado:** se estourar o fit da folha `resumo` ou a quota, cai-se para B (índice só com nome de seção) e o rodapé `#âncora` fica de fora; o índice por seção permanece. + +### B) "O que defende" — origem do dado + +**Opções:** A) item de checklist novo por era (`era_X_defesas`, `kind: 'defense'`) reusando o contrato item→research→report | B) seção nova `defenses[]` no `research.json` com validação própria | C) derivar de `news.summary` + acervo sem campo novo. + +**Recomendação: A** — é o mesmo arquivo e o mesmo pipeline (sem segundo pipeline de pesquisa): o item já exige `answer` + `sourceUrl` + `sourceDate` (`dossieResearch.mjs:471-485`), vira gap fail-closed sem fonte, e o `brief` reformulado (≤80/≤120, `:26-27`) garante o fit. O contrato do item de defesa fica: `answer` = frase da posição ("Defende X"), `details` = lastro identificado (ato/proposição, ou "Notícia: veículo · data", ou trecho de fala), `sourceUrl`/`sourceDate` = fonte do lastro, `area` do checklist = rótulo da posição (ex. "Ensino superior", "Saúde regional"). O report segrega por `item.kind === 'defense'` e publica `report.defends`; `news.summary` é **preservado** (corrige a perda `dossieBlocks.mjs:503-508`/`:1140-1145`) para a redação citar o lastro datado. Sem item pesquisado ou sem fonte → gap com label ("Posições e defesas"), e a seção imprime "Sem registro localizado" com a regra "resultado de busca não prova ausência" (cena 11 `:1903-1918`) — nunca uma defesa inferida. + +**Rejeitadas:** B porque twina normalizer/receipt/merge para ganho nulo (o item já carrega fonte e data) e amplia a superfície dos specs; C porque posição derivada de keyword/LLM é inferência — anti-goal explícito ("não atribuir a Solla uma defesa que não tem registro", intenção linha 21). + +### C) Boletim defende + +**Opções:** A) `bulletinFacts` marca `defense: true`; `buildBulletin` separa `defenses` (≤2) do ranking e dos caps de 6+14; render imprime o bloco no lugar dos cards | B) reusar 1–2 destaques existentes como "defesas" | C) o redator escreve as defesas do boletim. + +**Recomendação: A** — o ledger continua sendo o único insumo (`dossieBulletin.mjs:1-6`), os fatos de defesa já têm `sourceUrl` (exigido no research) e o bloco é **sem linha de fonte e sem CTA**, como as demais linhas (specs pinam `not.toContain('source-link')`, `dossieInstitution.unit.spec.ts:396`, `dossieTheme.unit.spec.ts:490`). As defesas ficam **fora** do ranking (`:18-29`) e não deslocam fatos de "fez"; entram na contagem `factsPrinted`/`factsRemaining` (`:103-109`) para o "e mais N" não mentir. Bloco vazio (nenhuma defesa com lastro) = bloco omitido, nunca inventado. Shrink: `onBulletinOverflow` passa a existir nos 3 builders; a primeira redução tira destaques e, se ainda estourar, tira a 2ª defesa — sempre contabilizada. + +**Rejeitadas:** B porque confunde "fez" com "defende" (a dimensão nova é o ponto do item) e não tem lastro próprio no card; C porque vira opinião editorial sem registro — anti-goal. + +### D) Carta/resumo/anatomia — mapeamento design → código + +| Design (cena) | Hoje | Decisão | +| -------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 01 Capa (`:743-795`) | `renderCover:116` / `renderSubjectCover:920` | portar classes; **morre** o `assetBox` "NEEDS ASSET" (`:141`, `:952`) e o painel de imagem | +| 02 O essencial + índice (`:797-910`) | `renderSummary:238` / `renderSubjectSummary:1056` (deliveries+hooks+pending capados) | vira **O essencial**: lede (redação) + fatos-chave (contagens/valores/lacunas) + índice; as listas capadas saem da impressão (o ledger do boletim continua); âncora `data-page="resumo"` **mantida** (guard do emit) | +| 03 Leitura entre eras (`:912-1017`) | `renderUnitSynthesisPage:1781` (Síntese) | vira **Leitura entre eras** (âncora `sintese` mantida): bullets de concentração/instrumentos/continuidade/ruptura/lacunas + "Síntese operacional" em tabela + callout; a tabela "Recursos por fase" (`:1815-1834`) permanece (número com fase, sem gráfico) | +| 04 Trajetória completa (`:1019-1129`) | `renderTrajectory:162` + `timelineGrid:83` (grid) | vira **tabela** Período/Papel/Como recuperar (com incertezas na própria linha) | +| 05 Era (`:1131-1253`) | `renderEraSheet:1188` | anatomia constante: leitura (`era-summary`/narrative) + tabela Objeto/Valor/Ano/Fase/Esfera\|Abrangência/Fonte + lista "O que fez · item — alcance — lastro" (`action-list`); **morrem** os cards (`action-grid :1202-1206`) e os selos (`scope`/`phase :400-402`) | +| 06 Região/polo (`:1255-1369`) | `renderRegion:248` + listas `:318` | mesmo fluxo (duas listas separadas, "não somar"), sem `scope-compare` decorativo | +| 07 Lacunas (`:1371-1455`) | `renderGapSheet:1536` | mantém tabela + "continuação N" | +| 08 Notícias/acervo/fontes (`:1457-1597`) | `renderNewsSheet:1601`, `renderAcervoSheet:1657`, `renderUnitSourcesPage:1724` | mantém as seções correntes; notícias ganham a coluna "Uso" (Era, dado real `news.era`) e o acervo perde o `acervo-stat-grid:1671-1682` (números viram texto/meta) | +| 09/10 Deltas instituição/tema (`:1599-1848`) | `renderSubjectScope:1270` (`scope-cards`), `renderSubjectHonorSheet:1436` | abrangência vira **tabela** (morrem `scope-cards :1290-1309`); honrarias/títulos em tabela + identificação em `document-table` (morre `identity-badge :734-738`) | +| 11 O que Solla defende (`:1850-1933`) | **não existe** | seção nova (`data-page="defende"`, `#o-que-solla-defende` no MD): lede + `position-list` (label/leitura/fonte+data) + callout "Como usar" | +| 12 Boletim (`:1935-2051`) | `dossieBulletinRender` cards | list-based: destaques (`bulletin-list`) + defesas (`bulletin-defense-list`) + trajetória 4 períodos (`period-list`) + "E mais" (`plain-list`) + rodapé defeso | + +**"Defende" por era?** Não — o design aprovado materializa a seção própria (cena 11) e o essencial a aponta; cada era mantém leitura + tabela + "O que fez". A questão em aberto da intenção (recomendação A, "seção própria indexável") fica confirmada pelo design. +**"O que morre":** Gráficos consolidados (`renderUnitChartsPage:1838-1923`, chamadas `:501`/`:1963`, imports `:8-14`, CSS `:464-470`), cards (`action-grid`), `scope-cards`, `acervo-stat-grid`, selos `scope`/`phase`, `identity-badge`, `asset-box`/NEEDS ASSET e o placeholder de imagem. O `placeholder-bar` de **valor ausente** (`:70-73`) permanece (é dado, não imagem); a célula sem valor pode seguir a nota do design ("Não localizado ≠ zero", cena 05 `:1207-1210`). + +### E) CSS + +**Opções:** A) um bloco sóbrio novo compartilhado pelas 3 variantes + boletim (substitui `PRINT_CSS`/`INSTITUTION_PRINT_CSS`) | B) manter `PRINT_CSS` e sobrepor um "sober" por cima | C) CSS por variante. + +**Recomendação: A** — o design tem um sistema único (tokens `--ink/--accent/--line/--paper`, `ui-design.html:9-30`) e o delta instituição/tema hoje já é pequeno (`INSTITUTION_PRINT_CSS:732-761` é só `PRINT_CSS` + extensões); a substituição encolhe o CSS (morrem `.chart-*`, `.action-grid`, `.scope-cards`, `.highlight-*`, `.asset-box`) e evita duas linguagens. Sem marca de campanha, sem imagens; boletim reescrito no mesmo sistema (cena 12), com bloco próprio porque o layout A4 de 1 página difere do dossiê. +**Rejeitadas:** B porque conviveriam dois estilos e o "sober" venceria por cascata imprevisível; C porque racha a família (anti-goal do design). + +### F) Specs / skills / redação + +**Opções:** A) atualizar os specs para o **novo** contrato e reescrever as seções das SKILLs/agentes no mesmo PR | B) manter os asserts antigos e adaptar o render para continuar passando | C) deixar specs/skills para depois. + +**Recomendação: A** — `dossieRender.unit.spec.ts:94-108,130-132,179-183,215-256`, `dossieInstitution.unit.spec.ts:235-254,256-277,327-346,387-410`, `dossieTheme.unit.spec.ts:247-268,315-335,429-562`, `dossieBlocks.unit.spec.ts:100-263`, `dossieBulletin.unit.spec.ts:23-81` e os 3 specs de lote (`dossieBatchSkill.unit.spec.ts:101-136` e irmãos, que exigem "A contribuição (carta)", "**Síntese**", "Gráficos consolidados") passam a pinar: âncoras preservadas + `defende`; ausência de `graficos`/`chart-grid`/`action-grid`/`scope-cards`/`asset-box`; presença de `index-list`/`document-table`/`position-list`/`bulletin-defense-list`. Nunca afrouxar um assert para "passar". As 3 SKILLs: reescrever "Conteúdo do dossiê"/"Conteúdo do Boletim modelo", o "Contrato dos JSONs" (ids novos de defesa) e o troubleshooting que cita síntese/gráficos (`cidade:281-294`, `:346-355`); os 3 agentes de redação ganham o contrato `betweenEras` e as regras do defende. **C210** toca as mesmas SKILLs (`briefing-capacitacao-solla.md:57`): C209 reescreve conteúdo, C210 adiciona o ponteiro — rebase esperado, sem bloqueio. + +**Rejeitadas:** B porque inverteria o dono (o spec passaria a ditar layout morto) e travaria o redesign; C porque deixaria o CI verde mentindo e a skill ensinando o layout antigo. + +## Fases verificáveis + +1. **Tracer — contrato do defende + `news.summary` ponta a ponta (quota ~1,0 dia).** Checklist `*_defesas` nas 3 variantes com `kind: 'defense'` (`dossieResearch.mjs:30-335`); normalizer propaga `kind` (`:497-515`); `buildDossierReport` publica `report.defends` e `bulletinFacts[].defense`; `news.summary` preservado (`dossieBlocks.mjs:503-508`, `:1140-1145`); uma superfície real: seção `defende` no **companion `.md`** + render mínimo no HTML. **Prova:** units de research (item vira defesa; sem fonte vira gap) + blocks (defends + ledger) + render (seção existe, lacuna aparece). +2. **Dossiê sóbrio — PDF/MD nas 3 variantes (quota ~1,5 dia).** `DOSSIER_PRINT_CSS` novo + folhas do mapeamento D (O essencial, leitura entre eras, trajetória em tabela, eras, defende, região/lacunas/notícias/acervo/fontes), remoção de gráficos/cartões/selos/assets; atualizar os specs de render das 3 variantes. **Prova:** `pnpm test:unit` + build local (snapshot/research existentes de Miguel Calmon; uma instituição e um tema) com guarda A4 verde e inspeção do PDF/MD; nenhuma ocorrência de `chart-grid`/`action-grid`/`scope-cards`/`NEEDS ASSET` no HTML. +3. **Índice + consulta rápida (quota ~0,5 dia).** `anchorPages` na 1ª passada, índice na folha `resumo` (cena 02) e sumário/âncoras no `.md`; era omitida marcada como no design. **Prova:** unit pinando o mapa (`resumo`=2, `trajetoria`=3/4, `era-c`=N) e build real conferindo número×folha no PDF. +4. **Boletim sóbrio + defende + fit (quota ~1,0 dia).** Cena 12 list-based nos 2 renders; `defenses` no `buildBulletin`; `onBulletinOverflow` nos 3 builders + `onDossierOverflow` alinhado; specs do boletim. **Prova:** unit (defesas fora dos caps/ranking, contagem fecha, sparse sem enchimento) + fixture com 2 defesas cabendo em 1 A4. +5. **Skills, redação e gates (quota ~1,0 dia).** 3 SKILLs + 3 agentes (`betweenEras`) + 3 specs de lote + `docs/changelog/2026-09-22-c209.md`; `pnpm gate:fast`; push por `pnpm push`. + +**Rabbit hole declarado dentro do tracer:** se `report.defends` exigir mais que `kind` + `area` (ex. vínculo automático defesa×notícia por URL), para na derivação e deixa o vínculo textual no `details` — sem matching especulativo. + +## Rabbit holes / Não escopo (engenharia) + +- **Reescrever o paginador/emit** para "fazer TOC de verdade" (blocks de página, outline PDF): corte — o mapa da 2ª passada basta; `buildPdf.mjs:200-206` fica sem `outline`. +- **Fork de renderer por variante** ou renderer v2 com feature flag. +- **Gráfico novo / voltar chart** — `chartPrimitives.mjs` só permanece para `graficosInstagramRender.mjs:18`; nenhum consumo novo no dossiê. +- **Inferir defesa por keyword** de notícia ou de acervo (LLM palpite): posição só entra como item de pesquisa com fonte. +- **Migrar `research.json` antigos** (data/ gitignored): sem itens de defesa eles viram lacuna explícita; nenhuma migração/`--force`. +- **Tocar `src/`** (acervo/`Speech`, schema, migration), URL pública, kit de marca, C210 (briefing) e C163 (relatório de cidade). +- **Auditoria de design por variante:** um port, um artefato; sem hi-fi novo. + +## Riscos e mitigação + +- **Fit A4 com mais seções (índice + defende + leitura entre eras):** índice compacto (7–9 linhas) na folha `resumo`; guarda `assertPageFits` segue fail-closed (`buildPdf.mjs:191`); callbacks `onDossierOverflow`/`onBulletinOverflow` nos 3 builders; o corte é de copy/índice, nunca do layout. +- **Perda de informação ao remover gráficos:** os números permanecem em tabela/prosa (`moneyByPhase`, contagens por era/esfera nas eras e na leitura entre eras); spec novo pina que os valores não evaporam. +- **"Defende" virar opinião editorial:** `kind:'defense'` + `sourceUrl`/`sourceDate` obrigatórios (fail-closed no normalizer) + auditoria de redação por citação (a skill já a descreve); sem registro, lacuna explícita. +- **Boletim estourar 1 página com o bloco novo:** defesas ≤2, `brief` curto, shrink determinístico e contagem `factsRemaining` honesta. +- **CI vermelho por specs antigos:** atualização dos specs no mesmo PR (Decisão F); proibido afrouxar asserção para passar. +- **Conflito com C210 nas SKILLs/specs de lote:** rebase com as duas mudanças (conteúdo C209 + ponteiro C210); ordem de merge pela prioridade. +- **Deriva do design:** o artefato aprovado é fonte de verdade do port; divergência material volta ao humano antes de improvisar. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: 3 variantes + boletim sóbrios/list-based; "O essencial"; leitura entre eras; "O que Solla defende" com lastro; índice+âncoras; nada some; sem gráficos/cartões/selos/imagens; boletim 1 página sem CTA/fonte visível com bloco defende. +- [ ] Invariantes AGENTS/engineering-standards: sem `src/`, sem schema/migration/URL, artefato gitignored, defeso, sem marca de campanha, edita o dono (sem twin de engine/pipeline). +- [ ] Testes de domínio previstos (unit): research (defesa/lacuna), blocks (`defends`, `news.summary`, contagens), render das 3 variantes (âncoras, índice, ausência dos artefatos mortos, MD espelhado), boletim (defesas fora dos caps, sparse, contagem) e skills de lote (novo contrato); gates `pnpm gate:fast` verdes. +- [ ] Guarda A4 fail-closed preservada em dossiê e boletim; nenhum "…" e nenhum descarte silencioso. + +## Débitos triados pós-simplify (não reabrir) + +Triagem de `capture-review-debts` (2026-09-22, modo autônomo do `--auto`): + +- **Registrado:** Issue **#1265** (`C209-DRY`, `depends: [1251]`, plano + `docs/plans/escala-dry-pos-c209.md`) — F1 orquestração compartilhada dos 3 builders + (loop probe→pack→measure→emit), F2 par `layout:'card'` morto no packer, F3 séries + mortas da síntese (`byYear`/`acervoByYear`/`moneyByYear`/`moneyProposals`). +- **Adiado com gatilho:** copy do boletim-município hardcoded no render (gatilho: a copy + mudar 2× ou surgir um 4º shape de boletim); builders instituição/tema sem `resumoOnly` + (gatilho: overflow não-resumo neles ou C189 tocar os builders). +- **Descartado:** `BULLETIN_MORE_LIMIT` (fallback defensivo), mutação de + `report.meta.pageTotal` (decisão travada do paginador), `factsPrintable` (contrato pinado), + reconstrução 2× do documento (Decisão A). + +## Critérios de aceite visual (crítica do designer, trigger c) + +Certificado pelo `designer` (tier primário) em 2026-09-22 com ajustes residuais aplicados +(célula "Tema canônico" sem token concatenado). `Design tier: designer` registrado no PR. + + +## Self-score (0–5, gate ≥4) + +| Critério | Nota | Justificativa | +| ---------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Decisões caras com rejeitadas | 5 | A–F deliberam índice/defende/boletim/anatomia/CSS/specs com opções e rejeitadas nomeadas; o guarda-chuva (port vs fork) também. | +| Cabe no appetite (~4–5 dias) | 4 | 5 fases somam ~5 dias; cortes declarados (página no índice, `betweenEras`) e tracer cedo; o custo maior é o port do dossiê nas 3 variantes + o contrato do defende. | +| Rabbit holes nomeados | 5 | Paginador/outline, fork por variante, gráfico novo, inferência de defesa, migração de research e C210 fora. | +| Depth check (reusa shells/helpers) | 5 | Reusa `dossieUnit` (seam por variante), normalizer/checklist, pack/medição/emit e o ledger do boletim; não cria módulo pass-through; `chartPrimitives` preservado para o Instagram. | +| Intenção permanece satisfeita | 5 | O outcome de produto não foi reescrito; a engenharia só escolheu forma (mapa de páginas, `kind:'defense'`, bloco do boletim, CSS único). | + +**Total 24/25 (média 4,8/5) — passa o gate ≥4/5.** diff --git a/docs/plans/escala-dry-pos-c209.md b/docs/plans/escala-dry-pos-c209.md new file mode 100644 index 00000000..6631bf23 --- /dev/null +++ b/docs/plans/escala-dry-pos-c209.md @@ -0,0 +1,64 @@ +# Escala/DRY pós-C209 — orquestração repetida nos 3 builders + resíduos mortos + +Status: rascunho +Atualizado em: 2026-09-22 +Issue: #1251 (débito do C209; a Issue própria nasce `depends: [1251]`) +Priority: P3 +Impeccable: A — só-backend (scripts), sem superfície visual +Appetite: ~0,5–1 dia eng (fill-in) +Responsável: — + +## Intenção + +O C209 (dossiês e boletins sóbrios) entregou o redesenho; a revisão estrutural/qualidade +deixou três resíduos maiores que o cleanup da sessão. Este lote existe para não deixá-los +apodrecendo no diff do C209: (1) o loop probe→pack→measure→emit está triplicado nos três +builders e o C209 acabou de duplicar também os callbacks de fit; (2) o par `layout: 'card'` +do packer/probe ficou inalcançável quando as ações viraram listas; (3) a síntese ainda +calcula e expõe quatro séries que só existiam para os gráficos consolidados, extintos no +redesenho. + +## Fases + +1. **F1 (S4) — orquestração compartilhada dos builders.** Extrair o loop + `probe → packProbeSections → measureDocumentSheets → adjustPackPlan → emitHtmlPairPdf` + para um helper único (ex. `scripts/lib/dossieBuild.mjs`) parametrizado pelos deltas reais: + fallback de índice (cidade), shrink de boletim (cidade/tema/instituição) e log. Opções: + A) helper com options; B) classe; C) manter os três loops. Recomendação: A — os deltas + são dados, não fluxos; rejeitadas B (cerimônia sem volatilidade) e C (o custo cresce a + cada builder novo). Prova: os 3 builders geram os mesmos PDFs/artefatos dos fixtures, + com o log `pack estável` idêntico. +2. **F2 (S3) — par `card` morto no packer.** Decidir entre aposentar o par `card` de + `measureDocumentPackProbe`/`dossiePack` (atualizar o spec do packer) ou documentá-lo + como contrato do packer (nenhuma unit emite hoje). Recomendação: aposentar e ajustar o + spec — o redesign fixou listas/tabelas; um grid de 2 colunas novo pede decisão de design. +3. **F3 (S1) — séries mortas da síntese.** Remover `byYear`, `acervoByYear` (do retorno), + `moneyByYear` e `moneyProposals` de `buildDossierSynthesis` (mantendo os usos internos de + `topYears`), ou re-consumi-las num consumidor real. Recomendação: remover — nenhum + consumidor após a morte dos gráficos; o spec da síntese pina só o que é lido. + +## Já resolvido no simplify/critique (não reabrir) + +- Índice do companion com âncoras reais (`mdAnchor`) e rodapés do PDF alinhados ao `.md`. +- Duplicação de honrarias (era + folha de títulos) no dossiê institucional. +- Região/polo e abrangência consolidadas em uma seção corrente (design cenas 06/09/10). +- Atribuição das ações da Câmara (`Alcance: mandato federal`), era omitida sem número de + seção, estado vazio do "E mais" do boletim, célula "Tema canônico" sem token concatenado. + +## Explicitamente fora (descartes deste triage) + +- **S2 `BULLETIN_MORE_LIMIT`:** fallback defensivo de uma unit futura sem + `bulletinMoreLimit`; remover deixaria `slice(0, NaN)` silencioso. Descartado. +- **S5 mutação de `report.meta.pageTotal` no render:** pré-existente em `main`, decisão + travada do paginador (2ª passada para o índice real). Descartado. +- **S7 `factsPrintable`:** contrato de contagem do boletim pinado por spec (precedente C188). + Descartado. +- **S9 documento reconstruído 2×:** Decisão A do C209 aceita a 2ª passada pelo índice com + página real. Descartado. + +## Adiado com gatilho + +- **S6 copy do boletim-município hardcoded no render:** gatilho — a copy municipal mudar + duas vezes ou surgir um quarto shape de boletim; aí mover para `MUNICIPALITY_UNIT.copy`. +- **S8 builders instituição/tema sem `resumoOnly`:** gatilho — overflow de página não-resumo + nesses dois builders ou quando o C189 tocar os builders. diff --git a/scripts/build-dossie-solla-cidade.mjs b/scripts/build-dossie-solla-cidade.mjs index f0d6a00f..855b8a3b 100644 --- a/scripts/build-dossie-solla-cidade.mjs +++ b/scripts/build-dossie-solla-cidade.mjs @@ -27,7 +27,7 @@ import { } 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 { buildBulletin, nextBulletinFit } from './lib/dossieBulletin.mjs' import { renderBulletinHtml } from './lib/dossieBulletinRender.mjs' import { CAMARA_DEFAULT_FROM, fetchCamaraActivity } from './lib/dossieCamara.mjs' import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs' @@ -194,14 +194,23 @@ if (!narrative) { } /** - * 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. + * Builds the dossiê + boletim for a given pack plan and fit fallback. The + * flowing sections multiply sheets by measurement (`pack`), so nothing is + * capped; if the resumo sheet overflows the A4 guard, the fallback re-renders + * with the index without page numbers (`indexMode: 'labels'`) before failing + * closed. The one-page boletim shrinks by printed facts (`printLimit`) and then + * by defenses (`defenseLimit`) until it fits — never truncated. */ -const buildArtifacts = ({ textFallback = 'full', pack = null, probe = false } = {}) => { +/** Set by the dossier fit fallback; reused by every later rebuild. */ +let dossierIndexMode = 'pages' + +const buildArtifacts = ({ + indexMode = dossierIndexMode, + pack = null, + probe = false, + printLimit = null, + defenseLimit = null, +} = {}) => { const report = buildDossierReport({ snapshot, research, @@ -209,7 +218,6 @@ const buildArtifacts = ({ textFallback = 'full', pack = null, probe = false } = camara, health, generatedAt, - textFallback, narrative, }) const bulletin = buildBulletin({ @@ -217,11 +225,13 @@ const buildArtifacts = ({ textFallback = 'full', pack = null, probe = false } = municipality: snapshot.municipality.name, region: snapshot.municipality.region ?? null, generatedAt, + printLimit, + ...(defenseLimit === null ? {} : { defenseLimit }), }) return { report, bulletin, - dossierHtml: renderDossierHtml(report, { pack, probe }), + dossierHtml: renderDossierHtml(report, { pack, probe, indexMode }), dossierMd: renderDossierMd(report), bulletinHtml: renderBulletinHtml(bulletin), } @@ -284,12 +294,20 @@ try { bulletinHtml: artifacts.bulletinHtml, dossierPdf: resolve(ROOT, dossierPdfFile), bulletinPdf: resolve(ROOT, bulletinPdfFile), - // Budget-gated 2-pass (C188): if the resumo sheet with the full text - // overflows, rebuild with pointer lines (no "…") before failing closed. + // Budget-gated fallback (C188/C209): if the resumo sheet with the full index + // overflows, rebuild with the index without page numbers before failing + // closed. The boletim shrinks its printed facts and then its defenses. onDossierOverflow: async () => { - artifacts = buildArtifacts({ textFallback: 'pointer', pack }) + dossierIndexMode = 'labels' + artifacts = buildArtifacts({ pack }) return artifacts.dossierHtml }, + onBulletinOverflow: async () => { + const next = nextBulletinFit(artifacts.bulletin) + if (!next) return null + artifacts = buildArtifacts({ pack, ...next }) + return artifacts.bulletinHtml + }, resumoOnly: true, }) } catch (error) { @@ -312,7 +330,7 @@ console.log(`[${LABEL}] companion → ${dossierMdFile}`) 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} ` + + `[${LABEL}] ${snapshot.municipality.name}: eras=${report.eras.length} pontos=${report.synthesis.totals.items} defesas=${report.defends.positions.length} ` + `highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} páginas=${report.meta.pageTotal} html_bytes=${Buffer.byteLength(dossierHtml)}`, ) diff --git a/scripts/build-dossie-solla-instituicao.mjs b/scripts/build-dossie-solla-instituicao.mjs index acbae771..356cee5a 100644 --- a/scripts/build-dossie-solla-instituicao.mjs +++ b/scripts/build-dossie-solla-instituicao.mjs @@ -29,7 +29,7 @@ import { } 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 { buildBulletin, nextBulletinFit } from './lib/dossieBulletin.mjs' import { renderBulletinHtml } from './lib/dossieBulletinRender.mjs' import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs' import { adjustPackPlan, packProbeSections } from './lib/dossiePack.mjs' @@ -152,21 +152,23 @@ const report = buildDossierReport({ unit: INSTITUTION_UNIT, narrative, }) -const bulletin = buildBulletin({ - facts: report.bulletinFacts, - identity: { name: report.meta.subjectName, badges: report.meta.identityBadges }, - unit: INSTITUTION_UNIT, - generatedAt, -}) +const buildInstitutionBulletin = ({ printLimit = null, defenseLimit = null } = {}) => + buildBulletin({ + facts: report.bulletinFacts, + identity: { name: report.meta.subjectName, badges: report.meta.identityBadges }, + unit: INSTITUTION_UNIT, + generatedAt, + printLimit, + ...(defenseLimit === null ? {} : { defenseLimit }), + }) + +let bulletin = buildInstitutionBulletin() +let bulletinHtml = renderBulletinHtml(bulletin) const dossierMd = renderDossierMd(report) -const bulletinHtml = renderBulletinHtml(bulletin) const baseName = `${slug}-${generatedAt.toISOString().slice(0, 10)}` -await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true }) -await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml) - await mkdir(resolve(ROOT, outDir), { recursive: true }) const dossierMdFile = join(outDir, `${baseName}-dossie.md`) const dossierPdfFile = join(outDir, `${baseName}-dossie.pdf`) @@ -222,13 +224,28 @@ try { .join(' ')}`, ) - await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml) await emitHtmlPairPdf(browser, { dossierHtml, bulletinHtml, dossierPdf: resolve(ROOT, dossierPdfFile), bulletinPdf: resolve(ROOT, bulletinPdfFile), + // C209: same fit fallbacks as the other builders — index without page + // numbers for the dossiê, fewer printed facts/defenses for the boletim. + onDossierOverflow: async () => { + dossierHtml = renderDossierHtml(report, { pack, indexMode: 'labels' }) + return dossierHtml + }, + onBulletinOverflow: async () => { + const next = nextBulletinFit(bulletin) + if (!next) return null + bulletin = buildInstitutionBulletin(next) + bulletinHtml = renderBulletinHtml(bulletin) + return bulletinHtml + }, }) + // The cache carries the final documents (after any fit fallback), not the first pass. + await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml) + await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml) } catch (error) { die(error instanceof Error ? error.message : String(error)) } finally { @@ -238,7 +255,7 @@ try { 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} ` + + `[${LABEL}] ${report.meta.subjectName}: eras=${report.eras.length} pontos=${report.synthesis.totals.items} defesas=${report.defends.positions.length} ` + `highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} páginas=${report.meta.pageTotal} html_bytes=${Buffer.byteLength(dossierHtml)}`, ) diff --git a/scripts/build-dossie-solla-tema.mjs b/scripts/build-dossie-solla-tema.mjs index 4199733f..9c632f79 100644 --- a/scripts/build-dossie-solla-tema.mjs +++ b/scripts/build-dossie-solla-tema.mjs @@ -29,7 +29,7 @@ import { } 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 { buildBulletin, nextBulletinFit } from './lib/dossieBulletin.mjs' import { renderBulletinHtml } from './lib/dossieBulletinRender.mjs' import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs' import { adjustPackPlan, packProbeSections } from './lib/dossiePack.mjs' @@ -154,13 +154,14 @@ const bulletinIdentity = { value: report.meta.identity?.value ?? null, taxonomyNote: report.meta.identity?.taxonomyNote ?? null, } -const buildThemeBulletin = (printLimit = null) => +const buildThemeBulletin = ({ printLimit = null, defenseLimit = null } = {}) => buildBulletin({ facts: report.bulletinFacts, identity: bulletinIdentity, unit: THEME_UNIT, generatedAt, printLimit, + ...(defenseLimit === null ? {} : { defenseLimit }), }) let bulletin = buildThemeBulletin() @@ -227,23 +228,29 @@ try { .join(' ')}`, ) - await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml) await emitHtmlPairPdf(browser, { dossierHtml, bulletinHtml, dossierPdf: resolve(ROOT, dossierPdfFile), bulletinPdf: resolve(ROOT, bulletinPdfFile), - // One-page fit by measurement (C188/C190): while the boletim overflows, - // rebuild it with two fewer printed facts — the source panel and the facts - // beyond the cap stay counted in the "e mais N" line, never truncated. + // One-page fit by measurement (C188/C190/C209): while the boletim overflows, + // rebuild it with fewer printed facts and then fewer defenses — the source + // panel and the facts beyond the cap stay counted in the "e mais N" line, + // never truncated. + onDossierOverflow: async () => { + dossierHtml = renderDossierHtml(report, { pack, indexMode: 'labels' }) + return dossierHtml + }, onBulletinOverflow: async () => { - const next = bulletin.factsPrinted - 2 - if (bulletin.factsPrinted === 0 || next < 0) return null + const next = nextBulletinFit(bulletin) + if (!next) return null bulletin = buildThemeBulletin(next) bulletinHtml = renderBulletinHtml(bulletin) return bulletinHtml }, }) + // The cache carries the final documents (after any fit fallback), not the first pass. + await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml) await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml) } catch (error) { die(error instanceof Error ? error.message : String(error)) @@ -254,7 +261,7 @@ try { 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} ` + + `[${LABEL}] ${report.meta.subjectName}: eras=${report.eras.length} pontos=${report.synthesis.totals.items} defesas=${report.defends.positions.length} ` + `highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} páginas=${report.meta.pageTotal} html_bytes=${Buffer.byteLength(dossierHtml)}`, ) diff --git a/scripts/lib/buildPdf.mjs b/scripts/lib/buildPdf.mjs index 5732e610..24c8510b 100644 --- a/scripts/lib/buildPdf.mjs +++ b/scripts/lib/buildPdf.mjs @@ -107,7 +107,7 @@ export const measureDocumentSheets = async (browser, html) => { return await page.evaluate(() => [...document.querySelectorAll('[data-page]')].map((sheet) => { const children = [...sheet.children].filter( - (child) => !child.classList.contains('report-footer'), + (child) => !child.classList.contains('running-footer'), ) const used = children.reduce( (max, child) => @@ -127,15 +127,15 @@ export const measureDocumentSheets = async (browser, html) => { } /** - * 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. + * Probe pass of the packed flowing sections: each `[data-pack-section]` sheet + * carries every unit (`[data-pack-unit]` with `data-pack-layout`); 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. + * The probe renders with the renderer's probe CSS (auto height, no margins), so + * the overhead is the true fixed content: headers, section titles and the blocks + * tagged `data-pack-fixed` (absent from continuation sheets). The packer adds + * the row gap back. */ const PACK_CARD_ROW_GAP_PX = Math.round(3 * MM_TO_PX) @@ -342,13 +342,13 @@ export const emitHtmlPairPdf = async ( ) } } - if (!resumoOnly) { - assertPageFits( - await measurePageOverflows(page, A4_PAGE_BUDGET_PX), - 'Página(s) do dossiê', - 'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.', - ) - } + // The whole document is always asserted: the resumo-only fallback shortens + // the resumo, but every other fixed sheet must still fit the A4 (fail-closed). + assertPageFits( + await measurePageOverflows(page, A4_PAGE_BUDGET_PX), + 'Página(s) do dossiê', + 'Corte copy/caps — nenhuma página pode ser cortada pelo overflow.', + ) await printA4Pdf(page, dossierPdf) let currentBulletinHtml = bulletinHtml diff --git a/scripts/lib/dossieBlocks.mjs b/scripts/lib/dossieBlocks.mjs index 21e62218..49371576 100644 --- a/scripts/lib/dossieBlocks.mjs +++ b/scripts/lib/dossieBlocks.mjs @@ -9,36 +9,16 @@ */ import { formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs' -import { - CAREER_TIMELINE, - DOSSIER_ERAS, - DOSSIER_SCOPE, - careerTimelineHighlights, -} from './dossieCareer.mjs' +import { CAREER_TIMELINE, DOSSIER_ERAS, DOSSIER_ERA_IDS, DOSSIER_SCOPE } from './dossieCareer.mjs' +import { dossierChecklistForEra } from './dossieResearch.mjs' import { INSTITUTION_UNIT, MUNICIPALITY_UNIT, isSubjectUnit, resolveDossierUnit, } from './dossieUnit.mjs' -import { capList, stripInlineSources, summarySurfaceText } from './reportText.mjs' +import { stripInlineSources } 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 - -/** @param {string} sphere @param {any} [unit] @returns {string} */ -export const dossierSphereLabel = (sphere, unit = MUNICIPALITY_UNIT) => - resolveDossierUnit(unit).sphereLabels?.[sphere] ?? sphere - -/** @param {string} sphere @param {any} [unit] @returns {string} */ -export const dossierSphereBadgeClass = (sphere, unit = MUNICIPALITY_UNIT) => - resolveDossierUnit(unit).sphereBadgeClass?.[sphere] ?? '' const phaseLabels = { autorizado: 'autorizado', empenhado: 'empenhado', @@ -142,9 +122,14 @@ const healthContext = (health) => })) : [] +/** + * Câmara activity is national mandate context, not municipal evidence: the rows + * carry an explicit scope label and never claim a municipal sphere. + */ const camaraItemsAsActions = (camara, era) => (camara?.status === 'ok' ? (camara.items ?? []) : []).map((item) => ({ - sphere: 'municipio', + sphere: null, + scopeLabel: 'mandato federal', year: item.year ? String(item.year) : null, title: item.title, detail: item.detail, @@ -157,25 +142,22 @@ const camaraItemsAsActions = (camara, era) => * @typedef {Object} DossierReport * @property {any} meta * @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: any, actions: any, honors: any }>} eras + * @property {Array<{ id: string, label: string, period: string, subtitle: string, empty?: boolean, omitted?: boolean, method: string, recovery: string, numbers: any, actions: any, honors: any }>} eras + * @property {{ facts: Array<{ label: string, text: string }> }} essentials + * @property {{ bullets: Array<{ label: string|null, text: string }>, table: any[] }} betweenEras + * @property {{ positions: any[], gaps: any[] }} defends * @property {{ * ruleTitle: string, * ruleBody: string, - * municipal: { label: string, items: any[], total: number, remaining: number }, - * regional: { label: string, items: any[], total: number, remaining: number }, - * evidence: { items: any[], total: number, remaining: number }, + * municipal: { label: string, items: any[], total: number }, + * regional: { label: string, items: any[], total: number }, * context: any[], - * hook: any, - * priorityGap: any, * }} region * @property {{ * ruleTitle: string, * ruleBody: string, * lists: Array<{ key: string, label: string, sphere: string, items: any[], total: number, omitted: number }>, - * hook: any, - * priorityGap: any, * }} reach * @property {{ items: any[], total: number, omitted: number, recorte?: number, topics?: string[] }} acervo * @property {{ @@ -228,7 +210,6 @@ const buildMunicipalityReport = ({ camara = null, health = null, generatedAt = new Date(), - textFallback = 'full', narrative = null, }) => { const municipality = snapshot.municipality ?? {} @@ -238,7 +219,10 @@ const buildMunicipalityReport = ({ ? `Território de Identidade ${region} · recorte regional não somável ao município` : 'Recorte regional não somável ao município' - const items = research.items ?? [] + const allItems = research.items ?? [] + /** C209: defense items are the "O que Solla defende" dimension — they never + * enter the evidence lists, the synthesis or the era tables. */ + const items = allItems.filter((item) => item.kind !== 'defense') const municipalItems = items.filter((item) => item.sphere === 'municipio') const regionalItems = items.filter((item) => item.sphere !== 'municipio') @@ -247,7 +231,7 @@ const buildMunicipalityReport = ({ 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, @@ -275,7 +259,7 @@ const buildMunicipalityReport = ({ })) : [] const actions = [...ownActions, ...extraActions] - if (numbers.length === 0 && actions.length === 0 && honors.length === 0) return null + const empty = numbers.length === 0 && actions.length === 0 && honors.length === 0 return { ...era, method: eraMethod[era.id], @@ -294,46 +278,12 @@ const buildMunicipalityReport = ({ numbers, actions, honors, + empty, + /** C209: a city era without a single sourced item gets no sheet — it stays + * in the index/leitura with the explicit "sem evidência" note. */ + omitted: empty, } - }).filter(Boolean) - - const deliveries = capList( - [...municipalItems] - .sort((left, right) => { - const leftEra = left.era === 'C' ? 0 : 1 - const rightEra = right.era === 'C' ? 0 : 1 - if (leftEra !== rightEra) return leftEra - rightEra - const leftNumber = left.numbers?.length ? 0 : 1 - const rightNumber = right.numbers?.length ? 0 : 1 - return leftNumber - rightNumber - }) - .map((item) => { - const { text, fromSummary } = summarySurfaceText(item, textFallback) - return { - sphere: item.sphere, - era: item.era, - year: item.numbers?.[0]?.year ?? null, - title: text, - detail: fromSummary ? item.details : textFallback === 'pointer' ? null : item.details, - brief: item.brief ?? null, - value: item.numbers?.[0]?.value ?? null, - phase: item.numbers?.[0]?.phase ?? null, - sourceUrl: item.sourceUrl, - sourceDate: item.sourceDate, - } - }), - MAX_DELIVERIES_PAGE_ONE, - ) - - const hooks = capList( - [...municipalItems, ...regionalItems].map((item) => ({ - topic: item.area, - angle: summarySurfaceText(item, textFallback).text, - brief: item.brief ?? null, - sourceUrl: item.sourceUrl, - })), - MAX_HOOKS_PAGE_ONE, - ) + }) const gaps = (research.gaps ?? []).map((gap) => ({ label: gap.label ?? gap.id, @@ -341,19 +291,7 @@ const buildMunicipalityReport = ({ nextStep: 'Apurar em fonte primária.', })) - const pending = capList( - (research.gaps ?? []).map((gap) => gap.label ?? gap.id), - MAX_PENDING_PAGE_ONE, - ) - const context = healthContext(health) - 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 = { @@ -381,14 +319,15 @@ const buildMunicipalityReport = ({ }) const bulletinFacts = [ - ...items + ...allItems .filter((item) => Boolean(item.sourceUrl)) .map((item) => ({ id: item.id, era: item.era, sphere: item.sphere, area: item.area, - headline: stripInlineSources(summarySurfaceText(item).text), + defense: item.kind === 'defense', + headline: stripInlineSources(item.answer), detail: item.details ? stripInlineSources(item.details) : null, brief: item.brief ? { @@ -466,14 +405,27 @@ const buildMunicipalityReport = ({ scope: DOSSIER_SCOPE, version: 'Documento de trabalho · versão 01', }, - page1: { - timeline: careerTimelineHighlights(), - deliveries, - hooks, - pending, - }, trajectory: CAREER_TIMELINE, eras: eraSections, + essentials: buildEssentials({ + items, + gaps: research.gaps ?? [], + synthesis, + unit: MUNICIPALITY_UNIT, + eras: eraSections, + }), + betweenEras: buildBetweenEras({ + items, + gaps: research.gaps ?? [], + synthesis, + unit: MUNICIPALITY_UNIT, + narrative, + }), + defends: buildDefends({ + items: allItems, + gaps: research.gaps ?? [], + defenseIds: defenseChecklistIds(MUNICIPALITY_UNIT), + }), acervo, synthesis, opening, @@ -491,19 +443,15 @@ const buildMunicipalityReport = ({ 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) => ({ + era: row.era ?? null, date: row.publishedAt, outlet: row.outlet ?? '—', title: row.title, + summary: row.summary ?? null, url: row.url, })), limits: { @@ -529,28 +477,6 @@ const buildMunicipalityReport = ({ * the unit descriptor. * * ------------------------------------------------------------------ */ -/** - * 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 - -/** - * Wraps a capped list so the renderer can declare what stayed out ("e mais N") - * instead of dropping it silently — the C188 no-truncation rule, enforced at - * the owner for both recortes. `omitted` is the C187 renderer's name for the - * same count `capList` calls `remaining`. - */ -const capped = (list, max) => { - const { items, total, remaining } = capList(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 @@ -748,7 +674,7 @@ const buildDossierSynthesis = ({ .join(' · ')}.`, ) lines.push( - `Abrangência: ${bySphere.map((row) => `${row.count} ${row.label}`).join(' · ')}. ${unit.synthesisSumGuard ?? 'Setor e rede não são somados à instituição.'}`, + `Abrangência: ${bySphere.map((row) => `${row.count} ${row.label}`).join(' · ')}. ${unit.synthesisSumGuard ?? 'recortes não são somados.'}`, ) if (topEra) { lines.push( @@ -878,6 +804,207 @@ const buildEraSummary = ({ era, items, money, scopeLabel, unit = INSTITUTION_UNI return parts.join(' ') } +/* ---------------------------------------------------------------- * + * C209 analysis layer: "O essencial", "Leitura entre eras" and the * + * "O que Solla defende" section. Everything below is a deterministic * + * reading of the sourced items/gaps — it introduces no fact. * + * ---------------------------------------------------------------- */ + +/** Ids of the `kind: 'defense'` checklist items of a unit (all three eras). */ +const defenseChecklistIds = (unit) => + new Set( + DOSSIER_ERA_IDS.flatMap((era) => + dossierChecklistForEra(era, unit) + .filter((item) => item.kind === 'defense') + .map((item) => item.id), + ), + ) + +/** + * Positions Solla stands for (sourced `kind: 'defense'` items) plus the defense + * checklist gaps — a missing source is an explicit "sem registro localizado", + * never an inferred position. + */ +const buildDefends = ({ items, gaps, defenseIds }) => ({ + positions: items + .filter((item) => item.kind === 'defense') + .map((item) => ({ + id: item.id, + era: item.era, + label: item.position ?? item.area, + reading: item.answer, + details: item.details, + brief: item.brief ?? null, + sourceUrl: item.sourceUrl, + sourceDate: item.sourceDate, + })), + gaps: gaps + .filter((gap) => defenseIds.has(gap.id)) + .map((gap) => ({ + id: gap.id, + label: gap.label ?? gap.id, + reason: gap.reason, + era: /^era_([abc])_/i.exec(gap.id ?? '')?.[1]?.toUpperCase() ?? null, + })), +}) + +/** The "O essencial" key facts — a short reading of the sourced set. */ +const buildEssentials = ({ items, gaps, synthesis, unit, eras }) => { + const facts = [] + if (items.length === 0) { + facts.push({ + label: 'Nenhum ponto com fonte localizado:', + text: 'a leitura é a das lacunas explícitas — não completar por inferência.', + }) + return { facts } + } + const omitted = eras.filter((era) => era.omitted).map((era) => era.id) + facts.push({ + label: `${items.length} ${items.length === 1 ? 'ponto com fonte' : 'pontos com fonte'} no recorte:`, + text: `${synthesis.byEra.map((row) => `${row.count} na ${row.label}`).join(' · ')}.${ + omitted.length + ? ` A Era ${omitted.join('/')} não tem evidência nominal suficiente e é omitida como seção.` + : '' + }`, + }) + facts.push({ + label: 'Abrangência sem soma:', + text: `${synthesis.bySphere + .map((row) => `${row.count} ${row.label}`) + .join(' · ')}. ${unit.synthesisSumGuard ?? 'recortes não são somados.'}`, + }) + if (synthesis.moneyByPhase.length) { + facts.push({ + label: 'Valores localizados, sem consolidar fases:', + text: `${synthesis.moneyByPhase + .map((row) => `${formatMoneyCompact(row.amount)} ${row.label}`) + .join(' · ')}. Empenho não é pagamento.`, + }) + } + facts.push({ + label: `${gaps.length} ${gaps.length === 1 ? 'lacuna declarada' : 'lacunas declaradas'}:`, + text: gaps.length + ? `prioridade: ${gaps[0].label ?? gaps[0].id}.` + : 'nenhuma lacuna declarada — confira a cobertura das eras.', + }) + return { facts } +} + +/** + * "Leitura entre eras": concentration, instruments, continuity, reach and the + * gaps that weigh. The narrative file may carry its own `betweenEras` bullets + * (array of strings); without them the reading is derived from the ledger. + */ +const buildBetweenEras = ({ items, gaps, synthesis, unit, narrative }) => { + const authored = Array.isArray(narrative?.betweenEras) + ? narrative.betweenEras + .filter((text) => typeof text === 'string' && text.trim()) + .map((text) => text.trim()) + : [] + const bullets = [] + if (authored.length) { + bullets.push(...authored.map((text) => ({ label: null, text }))) + } else if (items.length === 0) { + bullets.push({ + label: 'Lacunas que pesam.', + text: 'Nenhum ponto com fonte — a leitura é a das lacunas explícitas.', + }) + } else { + const top = [...synthesis.byEra].sort((left, right) => right.count - left.count)[0] + if (top) { + bullets.push({ + label: 'Concentração.', + text: `A ${top.label} reúne ${top.count} de ${items.length} pontos com fonte (${Math.round( + (top.count / items.length) * 100, + )}%).`, + }) + } + const areasByEra = synthesis.byEra + .map((row) => ({ + era: row.key, + areas: toRows( + countBy( + items.filter((item) => item.era === row.key), + (item) => item.area, + ), + ), + })) + .filter((row) => row.areas.length) + if (areasByEra.length) { + bullets.push({ + label: 'Instrumentos.', + text: `${areasByEra + .map( + (row) => + `Na Era ${row.era}, ${row.areas + .slice(0, 3) + .map((area) => `${area.key} (${area.count})`) + .join(', ')}`, + ) + .join('; ')}. São registros de natureza distinta; a leitura preserva essa diferença.`, + }) + } + const areaEras = new Map() + for (const item of items) { + const set = areaEras.get(item.area) ?? new Set() + set.add(item.era) + areaEras.set(item.area, set) + } + const continuous = [...areaEras].filter(([, eras]) => eras.size > 1) + bullets.push({ + label: 'Continuidade documentada.', + text: continuous.length + ? `${continuous + .map(([area, eras]) => `${area} (${[...eras].sort().join(', ')})`) + .join(' · ')}.` + : 'Nenhuma área aparece em mais de uma era.', + }) + bullets.push({ + label: 'Alcance por era.', + text: `${synthesis.byEraSphere + .map( + (row) => + `Era ${row.key}: ${ + row.segments + .filter((segment) => segment.count > 0) + .map((segment) => `${segment.count} ${segment.label}`) + .join(', ') || 'sem registro' + }`, + ) + .join(' · ')}.`, + }) + if (gaps.length) { + bullets.push({ + label: 'Lacunas que pesam.', + text: `${gaps.length} declaradas — prioridade: ${gaps[0].label ?? gaps[0].id}.`, + }) + } + } + + const table = synthesis.byEraSphere.map((row) => { + const present = row.segments.filter((segment) => segment.count > 0) + const direct = row.segments.find((segment) => segment.key === unit.defaultSphere)?.count ?? 0 + const gapCount = synthesis.gapsByEra.find((gap) => gap.key === row.key)?.count ?? 0 + return { + era: `${row.key} · ${DOSSIER_ERAS.find((era) => era.id === row.key)?.period ?? ''}`.trim(), + where: present.length + ? present.map((segment) => `${segment.count} ${segment.label}`).join(' · ') + : 'Sem registro com fonte localizado', + howToCite: present.length + ? 'Nomear objeto, fase e fonte' + : `Não atribuir ação ao ${unit.sphereLabels?.[unit.defaultSphere] ?? 'recorte'}`, + limit: + present.length === 0 + ? 'Lacuna explícita' + : direct === 0 + ? `Sem ${unit.sphereLabels?.[unit.defaultSphere] ?? 'recorte'} exclusivo localizado` + : `${gapCount} ${gapCount === 1 ? 'lacuna declarada' : 'lacunas declaradas'}`, + } + }) + + return { bullets, table } +} + const buildSubjectReport = (params) => { const { snapshot, research, generatedAt = new Date(), narrative = null } = params const unit = resolveDossierUnit(params.unit ?? INSTITUTION_UNIT) @@ -885,8 +1012,10 @@ const buildSubjectReport = (params) => { const subjectName = identity.name ?? identity.label ?? identity.slug ?? '—' const speechFacts = speechFactsFromRows(snapshot, unit.defaultSphere) - const items = research.items ?? [] - const directItems = items.filter((item) => item.sphere === unit.defaultSphere) + const allItems = research.items ?? [] + /** C209: defense items are the "O que Solla defende" dimension — they never + * enter the evidence lists, the synthesis or the era tables. */ + const items = allItems.filter((item) => item.kind !== 'defense') /** Honors live in their own sheet; they are not repeated as era action cards. */ const researchActionsByEra = (era) => @@ -941,37 +1070,6 @@ const buildSubjectReport = (params) => { } }) - const deliveryList = [...directItems] - .sort((left, right) => { - const leftEra = left.era === 'C' ? 0 : 1 - const rightEra = right.era === 'C' ? 0 : 1 - if (leftEra !== rightEra) return leftEra - rightEra - const leftNumber = left.numbers?.length ? 0 : 1 - const rightNumber = right.numbers?.length ? 0 : 1 - return leftNumber - rightNumber - }) - .map((item) => ({ - sphere: item.sphere, - era: item.era, - year: item.numbers?.[0]?.year ?? null, - title: item.answer, - detail: item.details, - brief: item.brief ?? null, - value: item.numbers?.[0]?.value ?? null, - phase: item.numbers?.[0]?.phase ?? null, - sourceUrl: item.sourceUrl, - sourceDate: item.sourceDate, - })) - const deliveries = capped(deliveryList, MAX_INSTITUTION_DELIVERIES) - - const hookList = items.map((item) => ({ - topic: item.area, - angle: item.answer, - brief: item.brief ?? null, - sourceUrl: item.sourceUrl, - })) - const hooks = capped(hookList, MAX_INSTITUTION_HOOKS) - const acervo = { items: speechFacts.map((fact) => ({ id: fact.id, @@ -987,27 +1085,12 @@ const buildSubjectReport = (params) => { topics: snapshot.speeches?.topics ?? [], } - const timelineList = items - .filter((item) => item.sourceUrl) - .map((item) => ({ - period: item.numbers?.[0]?.year ?? (item.sourceDate ?? '').slice(0, 4) ?? '—', - role: item.answer, - brief: item.brief ?? null, - source: item.label, - url: item.sourceUrl, - })) - .sort((left, right) => String(left.period).localeCompare(String(right.period))) - const timeline = capped(timelineList, MAX_INSTITUTION_TIMELINE) - const gaps = (research.gaps ?? []).map((gap) => ({ label: gap.label ?? gap.id, reason: gap.reason, nextStep: 'Apurar em fonte primária.', era: gapEraLabel(gap.id), })) - const pendingList = (research.gaps ?? []).map((gap) => gap.label ?? gap.id) - const pending = capped(pendingList, MAX_INSTITUTION_PENDING) - const scopeItem = (sphere, key, label) => { const sphereItems = items.filter((item) => item.sphere === sphere) return { @@ -1020,7 +1103,9 @@ const buildSubjectReport = (params) => { era: item.era, sphere: item.sphere, area: item.area, - year: item.numbers?.[0]?.year ?? (item.sourceDate ?? '').slice(0, 4) ?? null, + year: + item.numbers?.[0]?.year ?? + (item.sourceDate ? String(item.sourceDate).slice(0, 4) : null), value: item.numbers?.[0]?.value ?? null, phase: item.numbers?.[0]?.phase ?? null, title: item.answer, @@ -1046,13 +1131,14 @@ const buildSubjectReport = (params) => { unit, }) - const bulletinFacts = items + const bulletinFacts = allItems .filter((item) => Boolean(item.sourceUrl)) .map((item) => ({ id: item.id, era: item.era, sphere: item.sphere, area: item.area, + defense: item.kind === 'defense', headline: stripInlineSources(item.answer), detail: item.details ? stripInlineSources(item.details) : null, brief: item.brief @@ -1121,26 +1207,41 @@ const buildSubjectReport = (params) => { scope: unit.copy.coverScope, version: 'Documento de trabalho · versão 01', }, - page1: { timeline, deliveries, hooks, pending }, trajectory: CAREER_TIMELINE, eras: eraSections, + essentials: buildEssentials({ + items, + gaps: research.gaps ?? [], + synthesis, + unit, + eras: eraSections, + }), + betweenEras: buildBetweenEras({ + items, + gaps: research.gaps ?? [], + synthesis, + unit, + narrative, + }), + defends: buildDefends({ + items: allItems, + gaps: research.gaps ?? [], + defenseIds: defenseChecklistIds(unit), + }), acervo, synthesis, reach: { ruleTitle: unit.copy.reachRuleTitle, ruleBody: unit.copy.reachRuleBody, lists: scopeLists, - hook: hooks.items[0] ?? null, - priorityGap: pending.items[0] ?? null, }, gaps, - acervo, - synthesis, - opening, news: (research.news ?? []).map((row) => ({ + era: row.era ?? null, date: row.publishedAt, outlet: row.outlet ?? '—', title: row.title, + summary: row.summary ?? null, url: row.url, })), limits: { diff --git a/scripts/lib/dossieBulletin.mjs b/scripts/lib/dossieBulletin.mjs index 85470607..79886c37 100644 --- a/scripts/lib/dossieBulletin.mjs +++ b/scripts/lib/dossieBulletin.mjs @@ -2,16 +2,20 @@ * Boletim model (owner: C186; unit seam C187): one A4 page, voter-facing. It * reads ONLY the `bulletinFacts` ledger produced by `buildDossierReport` — every * fact there already carries a source, so the boletim cannot introduce a new - * fact. No sources are printed (they live in the dossiê). + * fact. No sources are printed (they live in the dossiê). C209: the ledger also + * carries the `defense` facts ("O que Solla defende"), printed outside the + * highlight ranking and counted in the "e mais N" line. */ import { formatDateTimeBr } from './cityReportFormat.mjs' -import { dossierPhaseLabel, dossierSphereLabel } from './dossieBlocks.mjs' +import { dossierPhaseLabel } from './dossieBlocks.mjs' import { BULLETIN_TIMELINE_STEPS } from './dossieCareer.mjs' import { resolveDossierUnit } from './dossieUnit.mjs' export const BULLETIN_HIGHLIGHT_LIMIT = 6 const BULLETIN_MORE_LIMIT = 14 +/** "O que Solla defende" budget of the one-page boletim (design cena 12). */ +export const BULLETIN_DEFENSE_LIMIT = 2 const eraRank = { C: 0, B: 1, A: 2 } @@ -30,13 +34,14 @@ const rankFacts = (facts, unit) => { /** * @param {{ - * facts?: Array<{ id: string, era: string, sphere: string, area: string, headline: string, detail: string|null, value: string|null, year: string|null, phase: string|null, sourceUrl: string, sourceDate: string, sourcePanel?: boolean }>, + * facts?: Array<{ id: string, era: string, sphere: string, area: string, defense?: boolean, headline: string, detail: string|null, value: string|null, year: string|null, phase: string|null, sourceUrl: string, sourceDate: string, sourcePanel?: boolean }>, * municipality?: string, * region?: string | null, * identity?: { name: string, badges?: string[] } | null, * unit?: any, * generatedAt?: Date, * printLimit?: number | null, + * defenseLimit?: number, * }} params */ export const buildBulletin = ({ @@ -47,12 +52,15 @@ export const buildBulletin = ({ unit, generatedAt = new Date(), printLimit = null, + defenseLimit = BULLETIN_DEFENSE_LIMIT, }) => { const resolvedUnit = resolveDossierUnit(unit) const ordered = rankFacts(facts, resolvedUnit) // The acervo sample is a source panel, not a finding (C190): it is counted in - // the one-pager but never takes a printed slot from a sourced finding. - const printable = ordered.filter((fact) => !fact.sourcePanel) + // the one-pager but never takes a printed slot from a sourced finding. The + // defenses (C209) are their own block, outside the highlight ranking. + const printable = ordered.filter((fact) => !fact.sourcePanel && !fact.defense) + const defenseFacts = ordered.filter((fact) => fact.defense) const target = printLimit === null ? BULLETIN_HIGHLIGHT_LIMIT + (resolvedUnit.bulletinMoreLimit ?? BULLETIN_MORE_LIMIT) @@ -61,17 +69,14 @@ export const buildBulletin = ({ const subjectName = municipality ?? identity?.name ?? '—' const highlights = printed.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => { // Theme-only fallback: a fact without money value shows its year; without - // either, the card is textual and prints no number slot at all (never zero). + // either, the line prints no number at all (never a fake zero). const number = resolvedUnit.bulletinNumberFallback ? (fact.value ?? fact.year ?? null) : fact.value return { - eyebrow: `${fact.area ?? 'Atuação'} · ${dossierSphereLabel(fact.sphere, resolvedUnit)}`, number, - ...(resolvedUnit.bulletinNumberFallback && !number ? { textual: true } : {}), title: fact.brief?.title ?? fact.headline, note: fact.brief?.note ?? fact.detail ?? null, - phase: fact.value ? (fact.phase ?? null) : null, phaseLabel: fact.value ? dossierPhaseLabel(fact.phase) : null, } }) @@ -79,34 +84,58 @@ export const buildBulletin = ({ label: fact.brief?.title ?? fact.headline, detail: fact.brief?.note ?? fact.detail ?? fact.value ?? fact.area, })) - const shown = highlights.length + moreItems.length + const defenses = defenseFacts.slice(0, Math.max(0, defenseLimit)).map((fact) => ({ + label: fact.brief?.title ?? fact.headline, + text: fact.brief?.note ?? fact.detail ?? fact.headline, + })) + const shown = highlights.length + moreItems.length + defenses.length return { unit: resolvedUnit, sparse: printable.length < 3, meta: { title: resolvedUnit.bulletinTitle, - kicker: resolvedUnit.bulletinKicker ?? 'Boletim informativo · atuação pública', + kicker: + resolvedUnit.bulletinKicker ?? + (municipality + ? `Boletim informativo · ${subjectName}` + : 'Boletim informativo · atuação pública'), subjectName, municipality: subjectName, region, identity, identityBadges: identity?.badges ?? [], - identityPills: - resolvedUnit.bulletinPills && identity ? resolvedUnit.bulletinPills(identity) : null, generatedAt, generatedAtLabel: formatDateTimeBr(generatedAt), modelLabel: 'Modelo — insumo interno', }, highlights, + defenses, moreItems, - // Never a silent drop (C188): the facts beyond the two caps — and the whole + // Never a silent drop (C188): the facts beyond the caps — and the whole // source panel — are counted so the one-page boletim states how much of the // sourced set it left out. factsTotal: ordered.length, - factsPrintable: printable.length, + factsPrintable: printable.length + defenseFacts.length, factsPrinted: shown, factsRemaining: ordered.length - shown, timeline: BULLETIN_TIMELINE_STEPS, } } + +/** + * One shrink step of the one-page fit (C190/C209): reduce the printed highlights + * first (two at a time), then the defenses (2 → 1 → 0). Returns the options for + * the next `buildBulletin`, or null when there is nothing left to cut — the + * builder then fails closed instead of truncating. + * + * @param {any} bulletin + * @returns {{ printLimit?: number, defenseLimit?: number } | null} + */ +export const nextBulletinFit = (bulletin) => { + const printedFacts = bulletin.highlights.length + bulletin.moreItems.length + if (printedFacts > 0) return { printLimit: Math.max(0, printedFacts - 2) } + if (bulletin.defenses.length > 1) return { printLimit: 0, defenseLimit: 1 } + if (bulletin.defenses.length > 0) return { printLimit: 0, defenseLimit: 0 } + return null +} diff --git a/scripts/lib/dossieBulletinRender.mjs b/scripts/lib/dossieBulletinRender.mjs index f3a70cd5..5e9bf07c 100644 --- a/scripts/lib/dossieBulletinRender.mjs +++ b/scripts/lib/dossieBulletinRender.mjs @@ -1,396 +1,209 @@ /** - * Boletim renderer (C186): one A4 `.sheet` in voter-facing language. Ports the - * approved hi-fi classes (`dossie-solla-cidade-boletim-ui-design.html`). No - * source list — the facts come from the dossiê ledger, and the page is labelled - * as an internal model. + * Boletim renderer (C186; sober redesign C209): one A4 `.sheet` in voter-facing + * language. Ports the approved hi-fi classes + * (`docs/plans/dossies-sobrios-analiticos-ui-design.html`, cena 12) — list-based, + * no cards, badges or image placeholders. No source list: the facts come from + * the dossiê ledger, and the page is labelled as an internal model. */ import { isSubjectUnit } from './dossieUnit.mjs' import { htmlEscape, moreItemsLabel } from './reportText.mjs' -const ASSET = (title, lines) => - `

${htmlEscape(title)}

${lines - .map((line) => htmlEscape(line)) - .join('
')}

` - -const phaseModifier = (phase) => - phase === 'pago' || phase === 'liquidado' - ? 'phase-paid' - : phase === 'empenhado' || phase === 'autorizado' - ? 'phase-pending' - : '' - -const renderHighlight = (highlight, { showPhase = false } = {}) => ` -
-

${htmlEscape(highlight.eyebrow)}

- ${!highlight.textual && highlight.number ? `

${htmlEscape(highlight.number)}

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

${htmlEscape(highlight.phaseLabel)}

` - : '' - } -

${htmlEscape(highlight.title)}

- ${highlight.note ? `

${htmlEscape(highlight.note)}

` : ''} -
` - -const renderMoreItem = (item) => - `

${htmlEscape(item.label)}

` - const PRINT_CSS = ` * { box-sizing: border-box; } html, body { margin: 0; padding: 0; } - body { font-family: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; color: #172033; background: #fff; } + body { font-family: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; color: var(--ink); background: #fff; } + :root { + --ink: #18232d; + --ink-soft: #52616d; + --accent: #4d6d7c; + --line: #bdc8ce; + --line-light: #dce3e6; + } @page { size: A4; margin: 0; } .sheet { position: relative; + display: flex; width: 210mm; height: 297mm; min-height: 297mm; - padding: 8mm 10mm 7mm 12mm; - display: flex; - flex-direction: column; + padding: 15mm 17mm 13mm; overflow: hidden; + flex-direction: column; background: #fff; - font-size: 10.25pt; - line-height: 1.26; + font-size: 9.4pt; + line-height: 1.42; } - .sheet::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3.5mm; background: #315c75; } - .sheet p, .sheet h1, .sheet h2, .sheet h3, .sheet ul { margin-top: 0; } - .tabular { font-variant-numeric: tabular-nums; } - .model-label { display: inline-flex; align-items: center; min-height: 6mm; padding: 1mm 2.5mm; border: .35mm solid #8a5a18; border-radius: 99px; color: #68420f; background: #fff7e8; font-size: 7.3pt; line-height: 1; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } - .kicker, .section-label { color: #315c75; font-size: 7.4pt; line-height: 1.1; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } - .section-label { margin-bottom: 1.2mm; } - .asset-box { border: .4mm dashed #748894; color: #40535f; background: #f7f9fa; padding: 3mm; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } - .asset-box p { margin: 0; } - .asset-box .asset-title { font-size: 7.5pt; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; } - .asset-box p + p { margin-top: 1mm; font-size: 7pt; line-height: 1.25; } - .bulletin-head { display: flex; align-items: center; justify-content: space-between; gap: 6mm; border-bottom: .55mm solid #172033; padding-bottom: 2.5mm; } - .bulletin-head .kicker { margin-bottom: 1mm; } - .bulletin-head .place { margin: 0; font-size: 10pt; font-weight: 700; color: #435264; } - .bulletin-head .note { margin: 0; max-width: 44mm; text-align: right; font-size: 6.8pt; font-weight: 600; line-height: 1.2; color: #8a5a18; } - .bulletin-head .right { display: flex; align-items: center; gap: 3mm; } - .opening { margin-top: 3.5mm; display: grid; grid-template-columns: 1fr 46mm; gap: 5mm; } - .opening h1 { margin: 0; font-size: 23pt; font-weight: 600; line-height: 1; letter-spacing: -.038em; } - .opening h1 .accent { color: #315c75; } - .opening .lead { margin: 2.6mm 0 0; max-width: 125mm; font-size: 10.5pt; line-height: 1.33; color: #435264; } - .opening .asset-box { min-height: 36mm; border-radius: .125rem; } - .highlights { margin-top: 3.8mm; } - .highlights-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 4mm; border-bottom: .3mm solid #cbd5dc; padding-bottom: 1.5mm; } - .highlights-head h2 { margin: 0; font-size: 14pt; font-weight: 600; line-height: 1; letter-spacing: -.015em; } - .highlights-head .side { margin: 0; text-align: right; font-size: 7pt; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: #435264; } - .highlight-grid { margin-top: 2.5mm; display: grid; grid-template-columns: repeat(3, 1fr); gap: 2.2mm; } - .highlight-card { min-height: 31mm; padding: 2.8mm 3mm 2.5mm; border: .3mm solid #cbd5dc; border-top: 1.15mm solid #315c75; background: #fff; } - .highlight-eyebrow { margin-bottom: 1.5mm; color: #435264; font-size: 6.7pt; line-height: 1; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } - .highlight-number { margin: 0; color: #315c75; font-size: 18.5pt; line-height: .96; font-weight: 800; letter-spacing: -.035em; } - .highlight-title { margin: 1.5mm 0 0; font-size: 10.8pt; line-height: 1.08; font-weight: 800; letter-spacing: -.01em; } - .highlight-note { margin: 1.3mm 0 0; color: #435264; font-size: 8.15pt; line-height: 1.22; } - .bulletin-timeline { margin-top: 3.5mm; border-radius: .125rem; background: #e8f0f4; padding: 2.8mm 3mm 2.6mm; } - .bulletin-timeline .timeline-head { display: flex; align-items: center; justify-content: space-between; gap: 4mm; } - .bulletin-timeline h2 { margin: 0; font-size: 11pt; font-weight: 600; } - .bulletin-timeline .timeline-side { margin: 0; font-size: 6.7pt; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: #315c75; } - .timeline-grid { margin-top: 3mm; display: grid; grid-template-columns: repeat(4, 1fr); } - .timeline-step { position: relative; padding: 4.5mm 2.5mm 1.5mm; border-top: .4mm solid #97aab6; } - .timeline-step::before { content: ''; position: absolute; top: -1.6mm; left: 2.5mm; width: 3mm; height: 3mm; border: .65mm solid #e8f0f4; border-radius: 99px; background: #315c75; box-shadow: 0 0 0 .3mm #315c75; } - .timeline-period { margin: 0 0 .7mm; font-size: 7.2pt; font-weight: 800; color: #315c75; } - .timeline-text { margin: 0; font-size: 8.4pt; font-weight: 600; line-height: 1.18; } - .more { margin-top: 3.5mm; display: grid; grid-template-columns: 1fr 43mm; gap: 4mm; } - .more-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 4mm; border-bottom: .3mm solid #cbd5dc; padding-bottom: 1.4mm; } - .more-head h2 { margin: 0; font-size: 12.5pt; font-weight: 600; line-height: 1; } - .more-head .side { margin: 0; font-size: 6.7pt; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: #435264; } - .more-grid { margin-top: 2.3mm; display: grid; grid-template-columns: 1fr 1fr; gap: 1.7mm 4mm; } - .more-item { position: relative; margin: 0; min-height: 11mm; padding: 0 0 2mm 5mm; border-bottom: .25mm solid #cbd5dc; font-size: 9pt; line-height: 1.22; } - .more-item::before { content: '✓'; position: absolute; left: 0; top: 0; color: #315c75; font-weight: 900; } - .more-counter { margin-top: 1.8mm; color: #435264; font-size: 7.6pt; font-weight: 600; } - .more-aside { display: grid; grid-template-rows: 1fr 1fr; gap: 2.3mm; } - .more-aside .asset-box { border-radius: .125rem; } - .empty { margin-top: 3mm; color: #435264; font-size: 9.5pt; } - .empty-box { margin-top: 2.3mm; min-height: 40mm; border: .4mm dashed #748894; background: #f7f9fa; color: #40535f; padding: 4mm; display: flex; flex-direction: column; justify-content: center; gap: 1.5mm; } - .empty-box p { margin: 0; font-size: 9.5pt; line-height: 1.3; } - .empty-box .uppercase { text-transform: uppercase; letter-spacing: .06em; } - .bulletin-foot { margin-top: auto; border-top: .35mm solid #cbd5dc; padding-top: 2.2mm; } - .bulletin-foot .row { display: flex; align-items: flex-end; justify-content: space-between; gap: 5mm; } - .bulletin-foot .orig { margin: 0 0 .8mm; font-size: 7.2pt; font-weight: 600; line-height: 1.25; color: #315c75; } - .bulletin-foot .defeso { margin: 0; max-width: 142mm; font-size: 7.4pt; line-height: 1.28; color: #435264; } - .bulletin-foot .defeso strong { color: #8a5a18; } - .bulletin-foot .stamp { margin: 0; text-align: right; font-size: 7.2pt; font-weight: 600; line-height: 1.3; color: #435264; } + .sheet p, .sheet ul, .sheet ol, .sheet h1, .sheet h2 { margin-top: 0; } + .running-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10mm; padding-bottom: 3mm; border-bottom: .35mm solid var(--ink); } + .running-kicker { margin: 0; color: var(--accent); font-size: 7.5pt; font-weight: 700; letter-spacing: .11em; text-transform: uppercase; } + .running-subtitle { margin: 1mm 0 0; color: var(--ink-soft); font-size: 8.7pt; } + .running-meta { min-width: 41mm; margin: 0; color: var(--ink-soft); font-size: 7.3pt; line-height: 1.45; text-align: right; } + .running-meta p { margin: 0; } + .running-footer { display: flex; margin-top: auto; padding-top: 2.6mm; border-top: .25mm solid var(--line); align-items: flex-end; justify-content: space-between; gap: 8mm; color: var(--ink-soft); font-size: 7.2pt; line-height: 1.3; } + .running-footer strong { color: var(--ink); } + .folio { color: var(--ink); font-weight: 700; white-space: nowrap; } + .section { margin-top: 5mm; } + .section + .section { margin-top: 6mm; } + .eyebrow { margin: 0 0 1.3mm; color: var(--accent); font-size: 7.2pt; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } + .bulletin-title { max-width: 162mm; margin: 12mm 0 0; font-size: 28pt; font-weight: 650; line-height: 1.02; letter-spacing: -.035em; } + .bulletin-title span { color: var(--accent); } + .bulletin-lede { max-width: 155mm; margin: 4mm 0 0; color: var(--ink-soft); font-size: 10.2pt; line-height: 1.45; } + .bulletin-list { margin: 3mm 0 0; padding: 0; border-top: .35mm solid var(--ink); list-style: none; } + .bulletin-list li { display: grid; padding: 2.6mm 0; border-bottom: .2mm solid var(--line-light); grid-template-columns: 38mm 1fr; gap: 4mm; } + .bulletin-list strong { font-size: 8.6pt; } + .bulletin-list span { color: var(--ink-soft); font-size: 8.6pt; } + .bulletin-defense-list { margin: 3mm 0 0; padding: 0; border-top: .35mm solid var(--ink); list-style: none; } + .bulletin-defense-list li { display: grid; padding: 2.4mm 0; border-bottom: .2mm solid var(--line-light); grid-template-columns: 52mm 1fr; gap: 4mm; } + .bulletin-defense-list strong { font-size: 8.7pt; } + .bulletin-defense-list span { color: var(--ink-soft); font-size: 8.5pt; line-height: 1.35; } + .period-list { display: grid; margin: 3mm 0 0; padding: 0; border-top: .35mm solid var(--ink); grid-template-columns: 1fr 1fr; list-style: none; } + .period-list li { min-height: 18mm; padding: 2.6mm 4mm 2.6mm 0; border-bottom: .2mm solid var(--line-light); } + .period-list li:nth-child(odd) { border-right: .2mm solid var(--line-light); } + .period-list li:nth-child(even) { padding-left: 4mm; } + .period-list strong { display: block; color: var(--accent); font-size: 7.6pt; } + .period-list span { display: block; margin-top: 1mm; font-size: 8.4pt; line-height: 1.33; } + .plain-list { margin: 3mm 0 0; padding: 0; list-style: none; } + .plain-list li { position: relative; padding: 2.2mm 0 2.2mm 6mm; border-top: .2mm solid var(--line-light); } + .plain-list li:last-child { border-bottom: .2mm solid var(--line-light); } + .plain-list li::before { position: absolute; top: 3.3mm; left: 0; width: 2.2mm; height: .45mm; background: var(--accent); content: ''; } + .table-note { margin: 1.7mm 0 0; color: var(--ink-soft); font-size: 7.6pt; } + .note-line { margin-top: 3mm; padding: 2mm 0; border-top: .25mm solid var(--line); border-bottom: .25mm solid var(--line); color: var(--ink-soft); font-size: 8.2pt; } + .note-line strong { color: var(--ink); } ` -const renderMunicipalityBulletinHtml = (bulletin) => ` - - -${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.municipality)} - -
-
-
-

${htmlEscape(bulletin.meta.kicker)}

-

Município de ${htmlEscape(bulletin.meta.municipality)}${bulletin.meta.region ? ` · ${htmlEscape(bulletin.meta.region)}` : ' · Bahia'}

-
-
-

Síntese do dossiê da cidade — conferir antes de publicar.

- ${htmlEscape(bulletin.meta.modelLabel)} -
-
- -
-
- -

O que Jorge Solla fez por ${htmlEscape(bulletin.meta.municipality)}

-

Da gestão da saúde ao trabalho na Câmara: recursos, serviços, obras e equipamentos que melhoram a vida de quem mora aqui.

-
- ${ASSET('NEEDS ASSET · foto real', ['Solla na cidade ou no serviço citado', 'origem, autoria e licença'])} -
- -
-
-
- -

Entregas que fazem diferença

-
-

cidade ≠ região · cada alcance vem identificado

-
- ${ - bulletin.highlights.length - ? `
${bulletin.highlights.map(renderHighlight).join('')}
` - : '

Sem fato com fonte suficiente para destacar — ver lacunas no dossiê.

' - } -
- -
-
-

Desde 1999, uma trajetória na saúde pública

-

experiência que virou trabalho pela Bahia

-
-
- ${bulletin.timeline - .map( - (step) => `
-

${htmlEscape(step.period)}

-

${htmlEscape(step.text)}

-
`, - ) - .join('')} -
-
- -
-
-
-
- -

Outras ações que entram no boletim

-
-

frases curtas · fatos confirmados

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

Sem itens adicionais com fonte além dos destaques.

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

' - } - ${ - bulletin.factsRemaining > 0 - ? `

${htmlEscape(moreItemsLabel(bulletin.factsRemaining, 'fato com fonte', 'fatos com fonte'))} no dossiê da cidade — o boletim de uma página não os exibe.

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

Fatos selecionados do dossiê da cidade.

-

Defeso eleitoral: material informativo em preparação. Não publicar sem revisão editorial e jurídica.

-
-

MODELO · INSUMO INTERNO
A4 · página 1/1
${htmlEscape(bulletin.meta.generatedAtLabel)}

-
-
-
- -` - -/* Institution boletim (C187) — ports the institutional boletim hi-fi. */ - -const SUBJECT_BULLETIN_CSS = ` - ${PRINT_CSS} - .defeso-band { margin-inline: -10mm; padding: 1.4mm 10mm 1.35mm; display: flex; align-items: center; justify-content: space-between; gap: 5mm; color: #68420f; background: #fff7e8; border-top: .25mm solid #e7cc9d; border-bottom: .25mm solid #e7cc9d; font-size: 7.15pt; line-height: 1.2; font-weight: 700; } - .identity-pill { display: inline-flex; align-items: center; min-height: 5mm; padding: .55mm 1.7mm; border: .25mm solid #cbd5dc; border-radius: 99px; color: #435264; background: #f3f6f8; font-size: 6.9pt; line-height: 1; font-weight: 700; letter-spacing: .045em; text-transform: uppercase; } - .identity-pill--code { font-family: ui-monospace, 'DejaVu Sans Mono', monospace; letter-spacing: .02em; } - .bulletin-head .subject { margin: 0; font-size: 11pt; font-weight: 800; } - .bulletin-head .pills { margin-top: 1.3mm; display: flex; flex-wrap: wrap; gap: 1.2mm; } - .lacuna-panel { border: .4mm dashed #b18549; color: #684819; background: #fff7e8; } - .lacuna-panel .lacuna-rule { margin-top: 2mm; border-top: .25mm solid #d8bb8d; padding-top: 2mm; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; line-height: 1.35; letter-spacing: .055em; } - .production-note { border: .25mm solid #cbd5dc; background: #f3f6f8; border-radius: .125rem; padding: 3mm; } - .opening { margin-top: 2.5mm; } - .highlights { margin-top: 3mm; } - .bulletin-timeline { margin-top: 2.5mm; } - .more { margin-top: 2.5mm; } - .empty-box { min-height: 30mm; } - .more-counter { margin-top: 1.8mm; color: #435264; font-size: 7.6pt; font-weight: 600; } - .lacuna-panel .warn-icon { width: 5mm; height: 5mm; flex-shrink: 0; margin-top: .4mm; color: #8a5a18; } - .highlight-phase { margin: 1.2mm 0 0; } - .highlight-card--textual { border-top-color: #435264; } - .highlight-card--textual .highlight-title { margin-top: 0; font-size: 11.6pt; } - .phase { display: inline-block; padding: .5mm 1.4mm; border-radius: 1mm; background: #e9eef1; color: #334553; font-size: 7.3pt; font-weight: 700; text-transform: uppercase; letter-spacing: .035em; } - .phase-paid { color: #285338; background: #e6f2e9; } - .phase-pending { color: #6b4918; background: #fff2d9; } -` - -const INST_WARNING_ICON = - '' - -const renderSubjectBulletinHtml = (bulletin) => { - const sparse = Boolean(bulletin.sparse) - const copy = bulletin.unit.copy.bulletin - // Theme pills come from the descriptor (`Área` + canonical token + taxonomy); - // institution keeps the identity badges untouched. - const pillList = bulletin.meta.identityPills - ? bulletin.meta.identityPills.filter((pill) => Boolean(pill.label)) - : (bulletin.meta.identityBadges ?? []).map((badge) => ({ label: badge })) - const pills = pillList - .map( - (pill) => - `${htmlEscape(pill.label)}`, +const highlightText = (highlight) => { + const parts = [] + if (highlight.note) parts.push(highlight.note) + if (highlight.number) { + parts.push( + highlight.phaseLabel ? `${highlight.number} · ${highlight.phaseLabel}` : highlight.number, ) - .join('') - const highlights = bulletin.highlights - const more = bulletin.moreItems + } + return parts.join(' — ') +} + +/** One sober composition for both shapes; the copy deltas come from the unit. */ +export const renderBulletinHtml = (bulletin) => { + const sparse = Boolean(bulletin.sparse) + const subject = isSubjectUnit(bulletin.unit) + const copy = bulletin.unit.copy?.bulletin ?? {} + const subjectName = bulletin.meta.subjectName + const title = subject + ? (() => { + const escaped = htmlEscape(copy.openingTitle(subjectName)) + const name = htmlEscape(subjectName) + return escaped.includes(name) ? escaped.replace(name, `${name}`) : escaped + })() + : `O que Jorge Solla fez por ${htmlEscape(subjectName)}` + const lede = subject + ? sparse + ? copy.leadSparse + : copy.lead + : 'Da gestão da saúde ao mandato federal: ações no município e iniciativas para a região, apresentadas com o alcance de cada uma.' + const remaining = subject + ? copy.remaining + : 'no dossiê da cidade — o boletim de uma página não os exibe.' + const sparseRule = subject + ? copy.sparseRule + : 'Não repetir item, não ampliar efeito e não preencher espaço com fato sem lastro.' return ` -${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.subjectName)} +${htmlEscape(bulletin.meta.title)} — ${htmlEscape(subjectName)} -
-
+
+
-

${htmlEscape(bulletin.meta.kicker)}

-

${htmlEscape(bulletin.meta.subjectName)}

- ${pills ? `
${pills}
` : ''} +

${htmlEscape(bulletin.meta.kicker)}

+

${htmlEscape( + subject ? subjectName : `${bulletin.meta.region ?? 'Bahia'} · Bahia`, + )}

+
+
+

${htmlEscape(bulletin.meta.modelLabel)}

+

folha 1/1

- ${htmlEscape(bulletin.meta.modelLabel)}
-
- DEFESO 2026 · material em preparação · não publicar sem revisão editorial e jurídica - sem CTA · sem propaganda -
- -
-
- -

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

-

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

-
- ${ASSET(sparse ? 'NEEDS ASSET · foto ou clipping' : 'NEEDS ASSET · foto real', [ - sparse ? copy.assetSparse : copy.asset, - 'origem, autoria e licença', - ])} +
+

${title}

+

${htmlEscape(lede)}

-
-
-
- -

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

-
-

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

-
+
+

Destaques

${ - highlights.length - ? `
${highlights.map((highlight) => renderHighlight(highlight, { showPhase: true })).join('')}
` - : '

Sem fato com fonte suficiente para destacar — ver lacunas no dossiê.

' - } -
- -
-
-

${htmlEscape(copy.title)}

-

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

-
-
- ${bulletin.timeline - .map( - (step) => `
-

${htmlEscape(step.period)}

-

${htmlEscape(step.text)}

-
`, - ) - .join('')} -
-
- -
-
-
-
- -

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

-
-

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

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

Sem itens adicionais com fonte além dos destaques.

${htmlEscape(copy.moreEmpty)}

` - } - ${ - bulletin.factsRemaining > 0 - ? `

${htmlEscape(moreItemsLabel(bulletin.factsRemaining, 'fato com fonte', 'fatos com fonte'))} ${htmlEscape(copy.remaining)}

` - : '' - } -
- ${ - sparse - ? `` - : `` + bulletin.highlights.length + ? `
    ${bulletin.highlights + .map( + (highlight) => `
  • + ${htmlEscape(highlight.title)} + ${htmlEscape(highlightText(highlight))} +
  • `, + ) + .join('')}
` + : `
Sem fato com fonte suficiente para destacar — ver lacunas no dossiê.
` }
${ - sparse - ? `
-
- -

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

-
- ${ASSET('NEEDS ASSET · selo', copy.assetSelo ?? [])} -
` + bulletin.defenses.length + ? `
+

O que Solla defende

+
    ${bulletin.defenses + .map( + (defense) => `
  • + ${htmlEscape(defense.label)} + ${htmlEscape(defense.text)} +
  • `, + ) + .join('')}
+
` : '' } -
-
-
-

${htmlEscape(copy.orig)}

-

Controle editorial: ${htmlEscape(copy.defeso)}

-
-

MODELO · INSUMO INTERNO
A4 · página 1/1
${htmlEscape(bulletin.meta.generatedAtLabel)}

-
+
+

Trajetória em quatro períodos

+
    ${bulletin.timeline + .map( + (step) => + `
  1. ${htmlEscape(step.period)}${htmlEscape(step.text)}
  2. `, + ) + .join('')}
+
+ +
+

E mais

+ ${ + bulletin.moreItems.length + ? `
    ${bulletin.moreItems + .map((item) => `
  • ${htmlEscape(item.label)}
  • `) + .join('')}
` + : bulletin.factsRemaining === 0 + ? `
Nenhum item adicional além dos destaques nesta página.
` + : '' + } + ${ + bulletin.factsRemaining > 0 + ? `

${htmlEscape(moreItemsLabel(bulletin.factsRemaining, 'fato com fonte', 'fatos com fonte'))} ${htmlEscape(remaining)}

` + : '' + } + ${ + sparse + ? `
A página termina com espaço — de propósito. ${htmlEscape(sparseRule)}
` + : '' + } +
+ +
+ ${ + subject + ? htmlEscape(copy.orig ?? 'Conteúdo selecionado do dossiê.') + : 'Fatos selecionados do dossiê da cidade.' + }${subject && copy.defeso ? ` ${htmlEscape(copy.defeso)}.` : ''} Defeso eleitoral: material informativo em preparação. Não publicar sem revisão editorial e jurídica. + MODELO · INSUMO INTERNO · 1/1
` } - -/** Dispatch: subject-shaped units (institution/theme) render the shared subject composition. */ -export const renderBulletinHtml = (bulletin) => - isSubjectUnit(bulletin?.unit) - ? renderSubjectBulletinHtml(bulletin) - : renderMunicipalityBulletinHtml(bulletin) diff --git a/scripts/lib/dossieCareer.mjs b/scripts/lib/dossieCareer.mjs index 85420f07..46f151dc 100644 --- a/scripts/lib/dossieCareer.mjs +++ b/scripts/lib/dossieCareer.mjs @@ -118,23 +118,6 @@ export const BULLETIN_TIMELINE_STEPS = [ { period: 'DESDE 2015', text: 'Atuação na Câmara Federal' }, ] -/** - * The six cells the hi-fi page-1 "trajetória" grid shows; the complete timeline - * (with every period and recovery trail) lives on the dedicated página de - * trajetória — the page-1 grid must stay a one-glance read. - */ -const CAREER_HIGHLIGHT_PERIODS = [ - '1999–2002', - '2003–2005', - '01/01/2007–18/01/2014', - '2015–2019', - '2019–2023', - '2023–2027', -] - -export const careerTimelineHighlights = () => - CAREER_TIMELINE.filter((row) => CAREER_HIGHLIGHT_PERIODS.includes(row.period)) - /** * Non-negotiable career facts the renderers may print without a per-item source * (they are the identity of the document). Anything beyond this needs a source. diff --git a/scripts/lib/dossiePack.mjs b/scripts/lib/dossiePack.mjs index b03c04c8..66aa847b 100644 --- a/scripts/lib/dossiePack.mjs +++ b/scripts/lib/dossiePack.mjs @@ -15,7 +15,7 @@ /** * Per-sheet reserve for the block margins the tight probe zeroes out - * (`.block` 3.4mm, `.action-grid`/`.document-table` top margins). The A4 fit + * (section/block margins, `.document-table`/list top margins). The A4 fit * guard still catches any residue; the reserve keeps a first pack from * overflowing. */ diff --git a/scripts/lib/dossieRender.mjs b/scripts/lib/dossieRender.mjs index 7336ae84..e6df7e92 100644 --- a/scripts/lib/dossieRender.mjs +++ b/scripts/lib/dossieRender.mjs @@ -1,68 +1,32 @@ /** - * Dossiê renderer (C186): the block model goes to print HTML (Chromium - * `page.pdf`, A4, one `.sheet` per page) and to the companion `.md`. Ports the - * approved hi-fi classes (`dossie-solla-cidade-ui-design.html`) class-by-class; - * shared escaping/source-token contract lives in `reportText.mjs`. + * Dossiê renderer (C186; sober redesign C209): the block model goes to print + * HTML (Chromium `page.pdf`, A4, one `.sheet` per page) and to the companion + * `.md`. Ports the approved hi-fi classes + * (`docs/plans/dossies-sobrios-analiticos-ui-design.html`) class-by-class: + * analysis first (O essencial, Leitura entre eras, O que Solla defende), lists + * and tables as the spine, no cards, colored badges, charts or images. Shared + * escaping/source-token contract lives in `reportText.mjs`. */ -import { - CHART_COLORS, - barChart, - columnChart, - stackedColumnChart, - valueList, -} from './chartPrimitives.mjs' -import { formatDateBr, formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs' -import { dossierPhaseLabel, dossierSphereBadgeClass, dossierSphereLabel } from './dossieBlocks.mjs' +import { formatDateBr, formatDateTimeBr } from './cityReportFormat.mjs' +import { dossierPhaseLabel } from './dossieBlocks.mjs' import { MUNICIPALITY_UNIT, isSubjectUnit, resolveDossierUnit } from './dossieUnit.mjs' -import { htmlEscape, moreItemsLabel, showingLabel, stripInlineSources } from './reportText.mjs' +import { htmlEscape, stripInlineSources } from './reportText.mjs' -/** Markdown column headers capitalize the sphere label (sentence case, as C187 shipped). */ -const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1) +const SOURCE_LABEL = '(fonte)' -const SOURCE_ICON = - '' - -const WARNING_ICON = - '' - -const sourceLink = (url, label = '(fonte)') => +const sourceLink = (url, label = SOURCE_LABEL) => url - ? `${SOURCE_ICON}${htmlEscape(label)}` + ? `${htmlEscape(label)}` : '' -const scopeBadge = (sphere, label = null, unit = MUNICIPALITY_UNIT) => { - const badgeClass = dossierSphereBadgeClass(sphere, unit) - return `${htmlEscape(label ?? dossierSphereLabel(sphere, unit))}` -} - -/** `dd/mm/aa` — the news table column is 9% wide (no room for a 4-digit year). */ -const formatShortDateBr = (value) => { - const full = formatDateBr(value) - const parts = full.split('/') - return parts.length === 3 ? `${parts[0]}/${parts[1]}/${parts[2].slice(-2)}` : full -} - -/** - * Print surfaces prefer the reformulated `brief` (short headline + note that fit - * the fixed A4 with no ellipsis) and fall back to the full `answer`/`details` - * record when an item has none — the builder's A4 fit guard then fails closed - * instead of silently dropping information. - */ +/** Print copy prefers the reformulated `brief`; the era/MD keep the integral record. */ const copyHtml = (value) => htmlEscape(stripInlineSources(value)) const briefOr = (brief, fallback) => brief?.title ?? fallback const noteOr = (brief, fallback) => brief?.note ?? fallback -const PHASE_MODIFIER = { - autorizado: 'phase-pending', - empenhado: 'phase-pending', - liquidado: 'phase-paid', - pago: 'phase-paid', -} -const phaseBadge = (phase) => { - const modifier = PHASE_MODIFIER[phase] - return `${htmlEscape(dossierPhaseLabel(phase))}` -} +const phaseText = (phase) => + `${htmlEscape(dossierPhaseLabel(phase))}` const placeholder = (label) => `` @@ -72,798 +36,404 @@ const valueCell = (value, widthLabel = 'Valor desconhecido') => ? placeholder(widthLabel) : `${htmlEscape(value)}` -const assetBox = (title, lines) => - `

${htmlEscape(title)}

${lines - .map((line) => htmlEscape(line)) - .join('
')}

` - -/** Total of sheets rendered so far — the shell's `pág. N/M` reads it. */ -const pageCount = (report) => report.meta.pageTotal - -const timelineGrid = (rows) => ` -
- ${rows - .map( - (row) => `
-

${htmlEscape(row.period)}

-

${htmlEscape(row.role)}

-

${htmlEscape(row.recovery)}${row.uncertain ? ` · ${htmlEscape(row.uncertain)}` : ''}

-
`, - ) - .join('')} -
` - -const sheetHeader = (report, title, subtitle, pageNo) => ` -
+const runningHead = (report, { kicker, title, subtitle, section, pageNo, continuation = null }) => { + const docLabel = isSubjectUnit(report.unit) + ? report.unit.docLabel + : `Dossiê cidade · ${report.meta.municipalityName}` + return ` +
-

Dossiê de atuação pública · ${htmlEscape(report.meta.municipalityName)}

-

${htmlEscape(title)}

- ${subtitle ? `

${htmlEscape(subtitle)}

` : ''} +

${htmlEscape(kicker ?? docLabel)}

+

${htmlEscape(title)}

+ ${subtitle ? `

${htmlEscape(subtitle)}

` : ''}
-
-

Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}

-

INSUMO INTERNO

-

pág. ${pageNo}/${pageCount(report)}

+
+ ${continuation ? `

${htmlEscape(continuation)}

` : section ? `

${htmlEscape(section)}

` : ''} +

${htmlEscape(report.cover.internalNote)}

+

${htmlEscape(report.meta.generatedAtLabel)} · versão 01

+

folha ${pageNo}/${report.meta.pageTotal}

` +} -const sheetFooter = (report, left, pageNo) => ` -