mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C209): dossiês e boletins Solla sóbrios e analíticos — O essencial, defesa e consulta rápida (Closes #1251)
This commit is contained in:
parent
f63e5cb9b4
commit
3a1878691b
30 changed files with 2952 additions and 2937 deletions
|
|
@ -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:
|
||||
|
||||
**`<slug>.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 <cidade>"; **≤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 <cidade>"; 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`,
|
||||
|
|
|
|||
|
|
@ -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 <instituição>"; **≤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 <instituição>"; 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`,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
`<slug>.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 = <valor>`) 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 = <valor>`).
|
||||
- **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`,
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ terem sido pesquisadas, pelo agente principal.
|
|||
`gaps[]` são lacunas explícitas).
|
||||
2. Escrever `data/dossie-solla-cidade/<slug>.narrative.json` no contrato da
|
||||
skill: `municipalitySlug`, `generatedAt`, `title`, `opening` (3 parágrafos,
|
||||
110–150 palavras) e `eras` (A/B/C, 80–120 palavras cada) — prosa corrida,
|
||||
contando a contribuição de Solla para a cidade e seu recorte regional.
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ pesquisadas, pelo agente principal.
|
|||
`sourceDate`; `gaps[]` são lacunas explícitas).
|
||||
2. Escrever `data/dossie-solla-instituicao/<slug>.narrative.json` no contrato da
|
||||
skill: `institutionSlug`, `generatedAt`, `title`, `opening` (3 parágrafos,
|
||||
110–150 palavras) e `eras` (A/B/C, 80–120 palavras cada) — prosa corrida,
|
||||
contando a contribuição de Solla para a instituição por era.
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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/<slug>.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).
|
||||
|
|
|
|||
1
docs/changelog/2026-09-22-c209.md
Normal file
1
docs/changelog/2026-09-22-c209.md
Normal file
|
|
@ -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.
|
||||
188
docs/plans/dossies-sobrios-analiticos-impl.md
Normal file
188
docs/plans/dossies-sobrios-analiticos-impl.md
Normal file
|
|
@ -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<br/>dossies-sobrios-analiticos-ui-design.html<br/>(10 cenas dossiê + cena 12 boletim)"] --> U["dossieUnit (3 variantes)"]
|
||||
U --> R["dossieResearch<br/>checklist + kind:'defense'<br/>+ news.summary preservado"]
|
||||
R --> B["dossieBlocks<br/>report: defends / betweenEras / news"]
|
||||
B --> H["dossieRender<br/>CSS sóbrio + seções novas<br/>+ índice âncora→página"]
|
||||
B --> MD["companion .md espelhado"]
|
||||
H --> P["dossiePack + buildPdf<br/>(probe, fit A4, emit)"]
|
||||
B --> BL["dossieBulletin<br/>defenses fora do ranking"]
|
||||
BL --> BR["dossieBulletinRender<br/>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.**
|
||||
64
docs/plans/escala-dry-pos-c209.md
Normal file
64
docs/plans/escala-dry-pos-c209.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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)}`,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
`<div class="asset-box"><p class="asset-title">${htmlEscape(title)}</p><p>${lines
|
||||
.map((line) => htmlEscape(line))
|
||||
.join('<br />')}</p></div>`
|
||||
|
||||
const phaseModifier = (phase) =>
|
||||
phase === 'pago' || phase === 'liquidado'
|
||||
? 'phase-paid'
|
||||
: phase === 'empenhado' || phase === 'autorizado'
|
||||
? 'phase-pending'
|
||||
: ''
|
||||
|
||||
const renderHighlight = (highlight, { showPhase = false } = {}) => `
|
||||
<article class="highlight-card${highlight.textual ? ' highlight-card--textual' : ''}">
|
||||
<p class="highlight-eyebrow">${htmlEscape(highlight.eyebrow)}</p>
|
||||
${!highlight.textual && highlight.number ? `<p class="highlight-number tabular">${htmlEscape(highlight.number)}</p>` : ''}
|
||||
${
|
||||
showPhase && highlight.number && highlight.phaseLabel
|
||||
? `<p class="highlight-phase"><span class="phase ${phaseModifier(highlight.phase)}">${htmlEscape(highlight.phaseLabel)}</span></p>`
|
||||
: ''
|
||||
}
|
||||
<h3 class="highlight-title">${htmlEscape(highlight.title)}</h3>
|
||||
${highlight.note ? `<p class="highlight-note">${htmlEscape(highlight.note)}</p>` : ''}
|
||||
</article>`
|
||||
|
||||
const renderMoreItem = (item) =>
|
||||
`<p class="more-item"><strong>${htmlEscape(item.label)}</strong></p>`
|
||||
|
||||
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) => `
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.municipality)}</title><style>${PRINT_CSS}</style></head>
|
||||
<body>
|
||||
<article class="sheet" data-page="boletim" aria-label="Modelo de boletim informativo de uma página">
|
||||
<header class="bulletin-head">
|
||||
<div>
|
||||
<p class="kicker">${htmlEscape(bulletin.meta.kicker)}</p>
|
||||
<p class="place">Município de ${htmlEscape(bulletin.meta.municipality)}${bulletin.meta.region ? ` · ${htmlEscape(bulletin.meta.region)}` : ' · Bahia'}</p>
|
||||
</div>
|
||||
<div class="right">
|
||||
<p class="note">Síntese do dossiê da cidade — conferir antes de publicar.</p>
|
||||
<span class="model-label">${htmlEscape(bulletin.meta.modelLabel)}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="opening" aria-label="Abertura do boletim">
|
||||
<div>
|
||||
<p class="section-label">Trabalho que chega à cidade e à região</p>
|
||||
<h1>O que Jorge Solla fez por <span class="accent">${htmlEscape(bulletin.meta.municipality)}</span></h1>
|
||||
<p class="lead">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.</p>
|
||||
</div>
|
||||
${ASSET('NEEDS ASSET · foto real', ['Solla na cidade ou no serviço citado', 'origem, autoria e licença'])}
|
||||
</section>
|
||||
|
||||
<section class="highlights" aria-label="Destaques">
|
||||
<div class="highlights-head">
|
||||
<div>
|
||||
<p class="section-label">Destaques</p>
|
||||
<h2>Entregas que fazem diferença</h2>
|
||||
</div>
|
||||
<p class="side">cidade ≠ região · cada alcance vem identificado</p>
|
||||
</div>
|
||||
${
|
||||
bulletin.highlights.length
|
||||
? `<div class="highlight-grid">${bulletin.highlights.map(renderHighlight).join('')}</div>`
|
||||
: '<p class="empty">Sem fato com fonte suficiente para destacar — ver lacunas no dossiê.</p>'
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="bulletin-timeline" aria-label="Trajetória">
|
||||
<div class="timeline-head">
|
||||
<h2>Desde 1999, uma trajetória na saúde pública</h2>
|
||||
<p class="timeline-side">experiência que virou trabalho pela Bahia</p>
|
||||
</div>
|
||||
<div class="timeline-grid">
|
||||
${bulletin.timeline
|
||||
.map(
|
||||
(step) => `<div class="timeline-step">
|
||||
<p class="timeline-period">${htmlEscape(step.period)}</p>
|
||||
<p class="timeline-text">${htmlEscape(step.text)}</p>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="more" aria-label="Outras ações">
|
||||
<div>
|
||||
<div class="more-head">
|
||||
<div>
|
||||
<p class="section-label">E mais</p>
|
||||
<h2>Outras ações que entram no boletim</h2>
|
||||
</div>
|
||||
<p class="side">frases curtas · fatos confirmados</p>
|
||||
</div>
|
||||
${
|
||||
bulletin.moreItems.length
|
||||
? `<div class="more-grid">${bulletin.moreItems.map(renderMoreItem).join('')}</div>`
|
||||
: '<div class="empty-box"><p><strong class="uppercase">Sem itens adicionais com fonte além dos destaques.</strong></p><p>Conferir as lacunas explícitas no dossiê da cidade antes de ampliar o boletim.</p></div>'
|
||||
}
|
||||
${
|
||||
bulletin.factsRemaining > 0
|
||||
? `<p class="more-counter">${htmlEscape(moreItemsLabel(bulletin.factsRemaining, 'fato com fonte', 'fatos com fonte'))} no dossiê da cidade — o boletim de uma página não os exibe.</p>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
<aside class="more-aside" aria-label="Ativos visuais pendentes">
|
||||
${ASSET('NEEDS ASSET · selo', ['marca institucional aprovada', 'arquivo vetorial'])}
|
||||
${ASSET('NEEDS ASSET · recorte', ['imagem real de cobertura', 'origem e licença'])}
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<footer class="bulletin-foot">
|
||||
<div class="row">
|
||||
<div>
|
||||
<p class="orig">Fatos selecionados do dossiê da cidade.</p>
|
||||
<p class="defeso"><strong>Defeso eleitoral:</strong> material informativo em preparação. Não publicar sem revisão editorial e jurídica.</p>
|
||||
</div>
|
||||
<p class="stamp">MODELO · INSUMO INTERNO<br />A4 · página 1/1<br />${htmlEscape(bulletin.meta.generatedAtLabel)}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
/* 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 =
|
||||
'<svg class="warn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10.3 2.9 1.8 17a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 2.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/></svg>'
|
||||
|
||||
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) =>
|
||||
`<span class="identity-pill${pill.code ? ' identity-pill--code' : ''}">${htmlEscape(pill.label)}</span>`,
|
||||
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, `<span>${name}</span>`) : escaped
|
||||
})()
|
||||
: `O que Jorge Solla fez por <span>${htmlEscape(subjectName)}</span>`
|
||||
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 `<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(bulletin.meta.subjectName)}</title><style>${SUBJECT_BULLETIN_CSS}</style></head>
|
||||
<head><meta charset="utf-8"><title>${htmlEscape(bulletin.meta.title)} — ${htmlEscape(subjectName)}</title><style>${PRINT_CSS}</style></head>
|
||||
<body>
|
||||
<article class="sheet" data-page="boletim" aria-label="${htmlEscape(copy.aria ?? 'Modelo de boletim informativo institucional de uma página')}">
|
||||
<header class="bulletin-head">
|
||||
<article class="sheet" data-page="boletim" aria-label="${htmlEscape(copy.aria ?? 'Modelo de boletim informativo de uma página')}">
|
||||
<header class="running-head">
|
||||
<div>
|
||||
<p class="kicker">${htmlEscape(bulletin.meta.kicker)}</p>
|
||||
<p class="subject">${htmlEscape(bulletin.meta.subjectName)}</p>
|
||||
${pills ? `<div class="pills">${pills}</div>` : ''}
|
||||
<p class="running-kicker">${htmlEscape(bulletin.meta.kicker)}</p>
|
||||
<p class="running-subtitle">${htmlEscape(
|
||||
subject ? subjectName : `${bulletin.meta.region ?? 'Bahia'} · Bahia`,
|
||||
)}</p>
|
||||
</div>
|
||||
<div class="running-meta">
|
||||
<p><strong class="ink-strong">${htmlEscape(bulletin.meta.modelLabel)}</strong></p>
|
||||
<p>folha 1/1</p>
|
||||
</div>
|
||||
<span class="model-label">${htmlEscape(bulletin.meta.modelLabel)}</span>
|
||||
</header>
|
||||
|
||||
<div class="defeso-band" role="note" aria-label="Nota de defeso eleitoral">
|
||||
<span>DEFESO 2026 · material em preparação · não publicar sem revisão editorial e jurídica</span>
|
||||
<span style="text-transform: uppercase; letter-spacing: .07em">sem CTA · sem propaganda</span>
|
||||
</div>
|
||||
|
||||
<section class="opening" aria-label="Abertura do boletim">
|
||||
<div>
|
||||
<p class="section-label">${sparse ? 'Somente o que está documentado' : htmlEscape(copy.openingLabel)}</p>
|
||||
<h1>${(() => {
|
||||
const title = copy.openingTitle(bulletin.meta.subjectName)
|
||||
const name = htmlEscape(bulletin.meta.subjectName)
|
||||
const escaped = htmlEscape(title)
|
||||
return escaped.includes(name)
|
||||
? escaped.replace(name, `<span class="accent">${name}</span>`)
|
||||
: escaped
|
||||
})()}</h1>
|
||||
<p class="lead">${sparse ? htmlEscape(copy.leadSparse) : htmlEscape(copy.lead)}</p>
|
||||
</div>
|
||||
${ASSET(sparse ? 'NEEDS ASSET · foto ou clipping' : 'NEEDS ASSET · foto real', [
|
||||
sparse ? copy.assetSparse : copy.asset,
|
||||
'origem, autoria e licença',
|
||||
])}
|
||||
<section>
|
||||
<h2 class="bulletin-title">${title}</h2>
|
||||
<p class="bulletin-lede">${htmlEscape(lede)}</p>
|
||||
</section>
|
||||
|
||||
<section class="highlights" aria-label="Destaques">
|
||||
<div class="highlights-head">
|
||||
<div>
|
||||
<p class="section-label">Destaques</p>
|
||||
<h2>${sparse ? htmlEscape(copy.highlightsTitleSparse) : htmlEscape(copy.highlightsTitle)}</h2>
|
||||
</div>
|
||||
<p class="side">${sparse ? 'não criar cards vazios para chegar a seis' : 'cada número mantém sua fase e seu alcance'}</p>
|
||||
</div>
|
||||
<section class="section" aria-label="Destaques">
|
||||
<p class="eyebrow">Destaques</p>
|
||||
${
|
||||
highlights.length
|
||||
? `<div class="highlight-grid" style="${sparse ? 'grid-template-columns: repeat(2, 1fr)' : ''}">${highlights.map((highlight) => renderHighlight(highlight, { showPhase: true })).join('')}</div>`
|
||||
: '<p class="empty">Sem fato com fonte suficiente para destacar — ver lacunas no dossiê.</p>'
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="bulletin-timeline" aria-label="Trajetória">
|
||||
<div class="timeline-head">
|
||||
<h2>${htmlEscape(copy.title)}</h2>
|
||||
<p class="timeline-side">${sparse ? htmlEscape(copy.titleSparse) : 'quatro momentos · linguagem direta'}</p>
|
||||
</div>
|
||||
<div class="timeline-grid">
|
||||
${bulletin.timeline
|
||||
.map(
|
||||
(step) => `<div class="timeline-step">
|
||||
<p class="timeline-period">${htmlEscape(step.period)}</p>
|
||||
<p class="timeline-text">${htmlEscape(step.text)}</p>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="more" aria-label="Outras ações">
|
||||
<div>
|
||||
<div class="more-head">
|
||||
<div>
|
||||
<p class="section-label">E mais</p>
|
||||
<h2>${sparse ? htmlEscape(copy.moreTitleSparse) : htmlEscape(copy.moreTitle)}</h2>
|
||||
</div>
|
||||
<p class="side">${sparse ? 'sem completar por inferência' : 'até 14 · sem completar por inferência'}</p>
|
||||
</div>
|
||||
${
|
||||
more.length
|
||||
? `<div class="more-grid">${more.map(renderMoreItem).join('')}</div>`
|
||||
: `<div class="empty-box"><p><strong class="uppercase">Sem itens adicionais com fonte além dos destaques.</strong></p><p>${htmlEscape(copy.moreEmpty)}</p></div>`
|
||||
}
|
||||
${
|
||||
bulletin.factsRemaining > 0
|
||||
? `<p class="more-counter">${htmlEscape(moreItemsLabel(bulletin.factsRemaining, 'fato com fonte', 'fatos com fonte'))} ${htmlEscape(copy.remaining)}</p>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
${
|
||||
sparse
|
||||
? `<aside class="lacuna-panel" style="border-radius: .125rem; padding: 2.5mm" aria-label="Regra editorial para poucos fatos">
|
||||
${INST_WARNING_ICON}
|
||||
<p style="margin: 0; font-size: 10.5pt; font-weight: 800">A página termina com espaço — de propósito.</p>
|
||||
<p style="font-size: 8.7pt; line-height: 1.35">${htmlEscape(copy.sparseRule)}</p>
|
||||
<p class="lacuna-rule">Poucos fatos com lastro > página cheia sem lastro</p>
|
||||
</aside>`
|
||||
: `<aside class="more-aside" aria-label="Ativos visuais pendentes">
|
||||
${ASSET('NEEDS ASSET · selo', copy.assetSelo ?? [])}
|
||||
${ASSET('NEEDS ASSET · clipping', copy.assetClipping ?? [])}
|
||||
</aside>`
|
||||
bulletin.highlights.length
|
||||
? `<ul class="bulletin-list">${bulletin.highlights
|
||||
.map(
|
||||
(highlight) => `<li>
|
||||
<strong>${htmlEscape(highlight.title)}</strong>
|
||||
<span>${htmlEscape(highlightText(highlight))}</span>
|
||||
</li>`,
|
||||
)
|
||||
.join('')}</ul>`
|
||||
: `<div class="note-line">Sem fato com fonte suficiente para destacar — ver lacunas no dossiê.</div>`
|
||||
}
|
||||
</section>
|
||||
|
||||
${
|
||||
sparse
|
||||
? `<section style="margin-top: 3mm; display: grid; grid-template-columns: 1fr 48mm; gap: 4mm">
|
||||
<div class="production-note">
|
||||
<p class="section-label">Nota de produção</p>
|
||||
<p style="margin: 0; font-size: 9.2pt; line-height: 1.38; color: #435264">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.</p>
|
||||
</div>
|
||||
${ASSET('NEEDS ASSET · selo', copy.assetSelo ?? [])}
|
||||
</section>`
|
||||
bulletin.defenses.length
|
||||
? `<section class="section" aria-label="O que Solla defende">
|
||||
<p class="eyebrow">O que Solla defende</p>
|
||||
<ul class="bulletin-defense-list">${bulletin.defenses
|
||||
.map(
|
||||
(defense) => `<li>
|
||||
<strong>${htmlEscape(defense.label)}</strong>
|
||||
<span>${htmlEscape(defense.text)}</span>
|
||||
</li>`,
|
||||
)
|
||||
.join('')}</ul>
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
<footer class="bulletin-foot">
|
||||
<div class="row">
|
||||
<div>
|
||||
<p class="orig">${htmlEscape(copy.orig)}</p>
|
||||
<p class="defeso"><strong>Controle editorial:</strong> ${htmlEscape(copy.defeso)}</p>
|
||||
</div>
|
||||
<p class="stamp">MODELO · INSUMO INTERNO<br />A4 · página 1/1<br />${htmlEscape(bulletin.meta.generatedAtLabel)}</p>
|
||||
</div>
|
||||
<section class="section" aria-label="Trajetória">
|
||||
<p class="eyebrow">Trajetória em quatro períodos</p>
|
||||
<ol class="period-list">${bulletin.timeline
|
||||
.map(
|
||||
(step) =>
|
||||
`<li><strong>${htmlEscape(step.period)}</strong><span>${htmlEscape(step.text)}</span></li>`,
|
||||
)
|
||||
.join('')}</ol>
|
||||
</section>
|
||||
|
||||
<section class="section" aria-label="Outras ações">
|
||||
<p class="eyebrow">E mais</p>
|
||||
${
|
||||
bulletin.moreItems.length
|
||||
? `<ul class="plain-list">${bulletin.moreItems
|
||||
.map((item) => `<li>${htmlEscape(item.label)}</li>`)
|
||||
.join('')}</ul>`
|
||||
: bulletin.factsRemaining === 0
|
||||
? `<div class="note-line">Nenhum item adicional além dos destaques nesta página.</div>`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
bulletin.factsRemaining > 0
|
||||
? `<p class="table-note">${htmlEscape(moreItemsLabel(bulletin.factsRemaining, 'fato com fonte', 'fatos com fonte'))} ${htmlEscape(remaining)}</p>`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
sparse
|
||||
? `<div class="note-line"><strong>A página termina com espaço — de propósito.</strong> ${htmlEscape(sparseRule)}</div>`
|
||||
: ''
|
||||
}
|
||||
</section>
|
||||
|
||||
<footer class="running-footer">
|
||||
<span>${
|
||||
subject
|
||||
? htmlEscape(copy.orig ?? 'Conteúdo selecionado do dossiê.')
|
||||
: 'Fatos selecionados do dossiê da cidade.'
|
||||
}${subject && copy.defeso ? ` ${htmlEscape(copy.defeso)}.` : ''} <strong>Defeso eleitoral:</strong> material informativo em preparação. Não publicar sem revisão editorial e jurídica.</span>
|
||||
<span class="folio">MODELO · INSUMO INTERNO · 1/1</span>
|
||||
</footer>
|
||||
</article>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
/** Dispatch: subject-shaped units (institution/theme) render the shared subject composition. */
|
||||
export const renderBulletinHtml = (bulletin) =>
|
||||
isSubjectUnit(bulletin?.unit)
|
||||
? renderSubjectBulletinHtml(bulletin)
|
||||
: renderMunicipalityBulletinHtml(bulletin)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -26,6 +26,22 @@ const DOSSIER_PHASES = ['autorizado', 'empenhado', 'liquidado', 'pago', 'restos'
|
|||
export const DOSSIER_BRIEF_TITLE_MAX = 80
|
||||
export const DOSSIER_BRIEF_NOTE_MAX = 120
|
||||
|
||||
/**
|
||||
* C209: a checklist item of `kind: 'defense'` is a position/priority Solla
|
||||
* stands for in the recorte. It enters through the same item→research→report
|
||||
* contract (answer + sourceUrl + sourceDate) and feeds the "O que Solla
|
||||
* defende" section; without a dated source it degrades to an explicit gap.
|
||||
*/
|
||||
const DEFENSE_AREA = 'Posições e defesas'
|
||||
|
||||
const defenseItem = (era, label) => ({
|
||||
id: `era_${era.toLowerCase()}_defesas`,
|
||||
era,
|
||||
area: DEFENSE_AREA,
|
||||
label,
|
||||
kind: 'defense',
|
||||
})
|
||||
|
||||
/** Municipality checklist (C186). `area` labels the boletim card. */
|
||||
const MUNICIPALITY_RESEARCH_CHECKLIST = [
|
||||
{
|
||||
|
|
@ -58,6 +74,7 @@ const MUNICIPALITY_RESEARCH_CHECKLIST = [
|
|||
area: 'Ministério da Saúde',
|
||||
label: 'Secretaria de Atenção à Saúde do Ministério da Saúde (2003–2005)',
|
||||
},
|
||||
defenseItem('A', 'Posições e defesas sobre o município e a região (até 2006)'),
|
||||
{ id: 'era_b_sesab', era: 'B', area: 'Saúde estadual', label: 'Gestão da SESAB (2007–2014)' },
|
||||
{
|
||||
id: 'era_b_equipamentos',
|
||||
|
|
@ -77,6 +94,7 @@ const MUNICIPALITY_RESEARCH_CHECKLIST = [
|
|||
area: 'Obras',
|
||||
label: 'Obras e investimentos em saúde no município/região',
|
||||
},
|
||||
defenseItem('B', 'Posições e defesas sobre o município e a região (2007–2014)'),
|
||||
{
|
||||
id: 'era_c_discursos',
|
||||
era: 'C',
|
||||
|
|
@ -107,6 +125,7 @@ const MUNICIPALITY_RESEARCH_CHECKLIST = [
|
|||
area: 'Atuação regional',
|
||||
label: 'Atuação e articulação regional',
|
||||
},
|
||||
defenseItem('C', 'Posições e defesas sobre o município e a região (2015–2027)'),
|
||||
]
|
||||
|
||||
/** Institution checklist (C187) — same eras, institution-oriented. */
|
||||
|
|
@ -141,6 +160,7 @@ const INSTITUTION_RESEARCH_CHECKLIST = [
|
|||
area: 'Ministério da Saúde',
|
||||
label: 'Secretaria de Atenção à Saúde do MS com interface institucional',
|
||||
},
|
||||
defenseItem('A', 'Posições e defesas sobre a instituição (até 2006)'),
|
||||
{
|
||||
id: 'era_a_vinculo',
|
||||
era: 'A',
|
||||
|
|
@ -177,6 +197,7 @@ const INSTITUTION_RESEARCH_CHECKLIST = [
|
|||
area: 'Convênios',
|
||||
label: 'Convênios, termos e parcerias formais com a instituição',
|
||||
},
|
||||
defenseItem('B', 'Posições e defesas sobre a instituição (2007–2014)'),
|
||||
{
|
||||
id: 'era_c_discursos',
|
||||
era: 'C',
|
||||
|
|
@ -213,6 +234,7 @@ const INSTITUTION_RESEARCH_CHECKLIST = [
|
|||
area: 'Parcerias',
|
||||
label: 'Parcerias, audiências e articulação formal com a instituição',
|
||||
},
|
||||
defenseItem('C', 'Posições e defesas sobre a instituição (2015–2027)'),
|
||||
]
|
||||
|
||||
/** Theme/area checklist (C190) — same eras, area-oriented. */
|
||||
|
|
@ -253,6 +275,7 @@ const THEME_RESEARCH_CHECKLIST = [
|
|||
area: 'Vínculo',
|
||||
label: 'Atuação documentada na área (pesquisa, conselho, docência)',
|
||||
},
|
||||
defenseItem('A', 'Posições e defesas sobre a área (até 2006)'),
|
||||
{
|
||||
id: 'era_b_sesab',
|
||||
era: 'B',
|
||||
|
|
@ -283,6 +306,7 @@ const THEME_RESEARCH_CHECKLIST = [
|
|||
area: 'Articulação',
|
||||
label: 'Articulação regional e setorial da área',
|
||||
},
|
||||
defenseItem('B', 'Posições e defesas sobre a área (2007–2014)'),
|
||||
{
|
||||
id: 'era_c_discursos',
|
||||
era: 'C',
|
||||
|
|
@ -325,9 +349,10 @@ const THEME_RESEARCH_CHECKLIST = [
|
|||
area: 'Reconhecimento',
|
||||
label: 'Títulos, honrarias e homenagens ligadas à área',
|
||||
},
|
||||
defenseItem('C', 'Posições e defesas sobre a área (2015–2027)'),
|
||||
]
|
||||
|
||||
/** @type {Record<string, Array<{ id: string, era: string, area: string, label: string }>>} */
|
||||
/** @type {Record<string, Array<{ id: string, era: string, area: string, label: string, kind?: string }>>} */
|
||||
const CHECKLIST_BY_UNIT = {
|
||||
municipality: MUNICIPALITY_RESEARCH_CHECKLIST,
|
||||
institution: INSTITUTION_RESEARCH_CHECKLIST,
|
||||
|
|
@ -342,7 +367,7 @@ export const DOSSIER_RESEARCH_CHECKLIST_IDS = MUNICIPALITY_RESEARCH_CHECKLIST.ma
|
|||
/**
|
||||
* @param {string} era
|
||||
* @param {any} [unit]
|
||||
* @returns {Array<{ id: string, era: string, area: string, label: string }>}
|
||||
* @returns {Array<{ id: string, era: string, area: string, label: string, kind?: string }>}
|
||||
*/
|
||||
export const dossierChecklistForEra = (era, unit = MUNICIPALITY_UNIT) =>
|
||||
checklistFor(resolveDossierUnit(unit)).filter((item) => item.era === era)
|
||||
|
|
@ -499,13 +524,17 @@ export const normalizeDossierResearchInput = (raw, { unit } = {}) => {
|
|||
era,
|
||||
label: checklistItem.label,
|
||||
area: checklistItem.area,
|
||||
// C209: `defense` items feed the "O que Solla defende" section; every other
|
||||
// item is evidence for the era/recorte sections.
|
||||
kind: checklistItem.kind ?? 'evidence',
|
||||
answer,
|
||||
// Optional short, complete, self-contained rewrite of `answer` (C188): the
|
||||
// resumo/boletim print it as-is; the era pages keep `answer`/`details`
|
||||
// integral. Inherits the item's source — not a new fact.
|
||||
summary: isNonEmptyString(entry.summary) ? entry.summary.trim() : null,
|
||||
details: isNonEmptyString(entry.details) ? entry.details.trim() : null,
|
||||
// C209: `brief` is the reformulated, shorter copy the fixed A4 surfaces
|
||||
// print; the integral `answer`/`details` stay in the record and the .md.
|
||||
brief,
|
||||
// Optional short label of the position/priority (defense items); the
|
||||
// checklist `area` is the fallback when the researcher does not name it.
|
||||
position: isNonEmptyString(entry.position) ? entry.position.trim() : null,
|
||||
details: isNonEmptyString(entry.details) ? entry.details.trim() : null,
|
||||
sphere,
|
||||
numbers: normalizeNumbers(entry),
|
||||
sourceUrl,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ export const MUNICIPALITY_UNIT = {
|
|||
spheres: ['municipio', 'regiao', 'polo'],
|
||||
defaultSphere: 'municipio',
|
||||
sphereLabels: { municipio: 'município', regiao: 'região', polo: 'polo' },
|
||||
sphereBadgeClass: { municipio: '', regiao: 'scope-region', polo: 'scope-region' },
|
||||
/** Bulletin ranking: a direct item outranks a shared-scope one. */
|
||||
breadthRank: { municipio: 0, regiao: 1, polo: 2 },
|
||||
researchDir: 'data/dossie-solla-cidade',
|
||||
|
|
@ -74,9 +73,8 @@ export const MUNICIPALITY_UNIT = {
|
|||
acervoNote: 'recorte municipal (menção confirmada); região e polo não entram nesta conta.',
|
||||
eraNumbersTitle: 'Recursos e entregas quantificáveis',
|
||||
eraActionsTitle: 'O que fez — item, alcance e lastro',
|
||||
honorsTitle: 'Títulos, honrarias e vínculos locais',
|
||||
/** C186 literal kept verbatim (byte-parity); subject units override it. */
|
||||
synthesisSumGuard: 'Setor e rede não são somados à instituição.',
|
||||
/** Guard printed in "O essencial"/synthesis for the city recorte (C209). */
|
||||
synthesisSumGuard: 'Região e polo não são somados ao município.',
|
||||
}
|
||||
|
||||
export const INSTITUTION_UNIT = {
|
||||
|
|
@ -88,7 +86,6 @@ export const INSTITUTION_UNIT = {
|
|||
spheres: ['instituicao', 'setor', 'rede'],
|
||||
defaultSphere: 'instituicao',
|
||||
sphereLabels: { instituicao: 'instituição', setor: 'setor', rede: 'rede' },
|
||||
sphereBadgeClass: { instituicao: '', setor: 'scope-sector', rede: 'scope-network' },
|
||||
breadthRank: { instituicao: 0, setor: 1, rede: 2 },
|
||||
researchDir: 'data/dossie-solla-instituicao',
|
||||
outDir: 'docs/research/dossie-solla-instituicao',
|
||||
|
|
@ -103,21 +100,15 @@ export const INSTITUTION_UNIT = {
|
|||
acervoNote: 'recorte por tema, não nominal; não somar à instituição.',
|
||||
eraNumbersTitle: 'Objeto, valor, fase e alcance',
|
||||
eraActionsTitle: 'Papéis com evidência visível',
|
||||
honorsTitle: 'Títulos, honrarias e vínculos institucionais',
|
||||
synthesisSumGuard: 'Setor e rede não são somados à instituição.',
|
||||
aria: {
|
||||
synthesis: 'Síntese da atuação institucional',
|
||||
charts: 'Gráficos consolidados da atuação institucional',
|
||||
sources: 'Fontes e limites do dossiê institucional',
|
||||
scope: 'Painel de abrangência institucional',
|
||||
},
|
||||
/** Subject-shape fields (shared with THEME_UNIT). */
|
||||
nounPlural: 'instituições',
|
||||
subjectNoun: 'instituição',
|
||||
docLabel: 'Dossiê institucional',
|
||||
contributionLabel: 'instituição',
|
||||
scopeLabel: 'no recorte institucional',
|
||||
summaryLinkEyebrow: '01 · vínculo com a instituição',
|
||||
honorsSheet: true,
|
||||
notSummedTo: 'à instituição',
|
||||
scopeLists: [
|
||||
|
|
@ -126,7 +117,6 @@ export const INSTITUTION_UNIT = {
|
|||
{ key: 'network', sphere: 'rede' },
|
||||
],
|
||||
scopeListWords: { institution: 'instituicao', sector: 'setor', network: 'rede' },
|
||||
scopeCardClass: { instituicao: '', setor: 'card-sector', rede: 'card-network' },
|
||||
eraMethod: {
|
||||
A: 'Registros anteriores a 2007 que citem nominalmente a instituição ou comprovem vínculo. Cargo geral não prova ação institucional.',
|
||||
B: 'A gestão estadual é lida por equipamento, programa, convênio e obra; cada linha informa abrangência e fonte. Setor e rede não somam à instituição.',
|
||||
|
|
@ -149,22 +139,16 @@ export const INSTITUTION_UNIT = {
|
|||
['Alcance', identity.scopeLabel],
|
||||
['Alias', (identity.aliases ?? []).join(' · ') || '—'],
|
||||
].filter(([, value]) => Boolean(value)),
|
||||
coverRows: (report) => [{ label: 'Identificação', badges: report.meta.identityBadges }],
|
||||
copy: {
|
||||
coverSubtitle: () => 'O que Jorge Solla fez por, na e com a instituição ao longo da carreira',
|
||||
coverHowToUse:
|
||||
'Localize o vínculo ou a entrega, confira fonte, data, fase e abrangência. Transforme toda lacuna em tarefa de apuração. Este arquivo não é fala pronta nem peça pública.',
|
||||
coverScope:
|
||||
'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara dos Deputados 2015–2027. Instituição, setor e rede separados; sem fonte, não entra como entrega.',
|
||||
coverScopeWords: 'instituição, setor e rede separados',
|
||||
coverAsset: ['selo da instituição', 'uso autorizado e origem'],
|
||||
openingTitle: (subjectName) => `O que Jorge Solla fez pela e na ${subjectName}`,
|
||||
reachRuleTitle: 'Setor/rede não é a instituição. Não some os recortes.',
|
||||
reachRuleBody:
|
||||
'Uma política para uma categoria ou uma articulação com entidades correlatas pode alcançar a instituição sem constituir entrega exclusiva para ela.',
|
||||
synthesisGuard: 'setor e rede nunca somados à instituição.',
|
||||
coverageNote: 'Instituição, setor e rede separados; sem fonte, não entra como entrega.',
|
||||
timelineEmpty: 'Nenhum vínculo datado com fonte — ver lacunas explícitas.',
|
||||
eraEmpty: {
|
||||
body: 'A pesquisa não encontrou documento que sustenta uma afirmação institucional. Isso não prova que não houve vínculo ou atuação; prova apenas que o dossiê ainda não pode afirmá-los.',
|
||||
work: [
|
||||
|
|
@ -196,32 +180,18 @@ export const INSTITUTION_UNIT = {
|
|||
'Valor sempre informa a fase de execução.',
|
||||
],
|
||||
},
|
||||
mdAcervoNote: 'Recorte por tema, não nominal.',
|
||||
bulletin: {
|
||||
aria: 'Modelo de boletim informativo institucional de uma página',
|
||||
openingLabel: 'Uma trajetória de trabalho junto à instituição',
|
||||
openingTitle: (subjectName) => `O que Jorge Solla fez pela e na ${subjectName}`,
|
||||
lead: 'Ações, recursos e articulações explicados de forma direta — somente o que já foi documentado no dossiê institucional.',
|
||||
leadSparse:
|
||||
'Esta versão tem poucos registros confirmados. Em vez de preencher espaço, mostra só os fatos que o dossiê sustenta.',
|
||||
asset: 'Solla na instituição ou na ação citada',
|
||||
assetSparse: 'somente registro relacionado ao fato',
|
||||
assetSelo: ['marca autorizada da instituição', 'arquivo vetorial'],
|
||||
assetClipping: ['registro real da ação', 'origem e licença'],
|
||||
moreEmpty:
|
||||
'Conferir as lacunas explícitas no dossiê institucional antes de ampliar o boletim.',
|
||||
defeso:
|
||||
'fase acompanha cada valor · instituição, setor e rede não são somados · sem percentual estadual absoluto.',
|
||||
orig: 'Conteúdo selecionado do dossiê institucional.',
|
||||
remaining: 'no dossiê da instituição — o boletim de uma página não os exibe.',
|
||||
sparseRule:
|
||||
'Não repetir item, não ampliar efeito e não preencher com ação de setor ou rede como se fosse entrega exclusiva da instituição.',
|
||||
title: 'Uma trajetória de compromisso com o serviço público',
|
||||
titleSparse: 'contexto de carreira · não substitui fato institucional',
|
||||
highlightsTitle: 'Resultados em leitura rápida',
|
||||
highlightsTitleSparse: 'Poucos fatos, sem enchimento',
|
||||
moreTitle: 'Outras ações confirmadas',
|
||||
moreTitleSparse: 'Outros registros confirmados',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -234,7 +204,6 @@ export const THEME_UNIT = {
|
|||
spheres: ['area', 'segmento', 'rede'],
|
||||
defaultSphere: 'area',
|
||||
sphereLabels: { area: 'área', segmento: 'segmento', rede: 'rede' },
|
||||
sphereBadgeClass: { area: '', segmento: 'scope-sector', rede: 'scope-network' },
|
||||
breadthRank: { area: 0, segmento: 1, rede: 2 },
|
||||
researchDir: 'data/dossie-solla-tema',
|
||||
outDir: 'docs/research/dossie-solla-tema',
|
||||
|
|
@ -250,20 +219,14 @@ export const THEME_UNIT = {
|
|||
acervoNote: 'recorte pela tag do acervo (Speech.topics); segmento e rede não entram nesta conta.',
|
||||
eraNumbersTitle: 'Objeto, valor, ano, fase e abrangência',
|
||||
eraActionsTitle: 'Papéis e iniciativas com evidência visível',
|
||||
honorsTitle: 'Títulos, honrarias e vínculos',
|
||||
synthesisSumGuard: 'Segmento e rede não são somados à área.',
|
||||
aria: {
|
||||
synthesis: 'Síntese da atuação por área',
|
||||
charts: 'Gráficos consolidados da atuação por área',
|
||||
sources: 'Fontes e limites do dossiê temático',
|
||||
scope: 'Painel de abrangência por área',
|
||||
},
|
||||
nounPlural: 'temas',
|
||||
subjectNoun: 'área',
|
||||
docLabel: 'Dossiê temático',
|
||||
contributionLabel: 'área',
|
||||
scopeLabel: 'no recorte da área',
|
||||
summaryLinkEyebrow: '01 · vínculo com a área',
|
||||
honorsSheet: false,
|
||||
notSummedTo: 'à área',
|
||||
scopeLists: [
|
||||
|
|
@ -272,20 +235,16 @@ export const THEME_UNIT = {
|
|||
{ key: 'network', sphere: 'rede' },
|
||||
],
|
||||
scopeListWords: { area: 'area', segment: 'segmento', network: 'rede' },
|
||||
scopeCardClass: { area: '', segmento: 'card-sector', rede: 'card-network' },
|
||||
/** Theme-only scene overrides (the institution keeps the shared defaults). */
|
||||
acervoScene: {
|
||||
stats: [
|
||||
{ key: 'universe', label: 'Universo recuperado' },
|
||||
{ key: 'sample', label: 'Amostra exibida' },
|
||||
],
|
||||
columns: ['Data', 'Tema canônico', 'Trecho/contexto', 'Abrangência', 'Link'],
|
||||
alert:
|
||||
'O tema prova pertinência ao recorte do acervo; não prova por si só entrega, autoria material ou execução financeira.',
|
||||
},
|
||||
/** Theme money lines keep every phase on its own line (never fused). */
|
||||
moneyPhaseStyle: 'separate',
|
||||
reachScene: {
|
||||
columns: ['Item', 'Era / ano', 'Valor / fase', 'Evidência temática', 'Fonte'],
|
||||
tableClass: 'document-table--reach',
|
||||
},
|
||||
gapsScene: {
|
||||
columns: ['Era', 'Lacuna', 'Já consultado', 'Próxima busca', 'Status'],
|
||||
|
|
@ -314,12 +273,6 @@ export const THEME_UNIT = {
|
|||
limit: 'Resultado não prova ausência',
|
||||
},
|
||||
],
|
||||
/** Síntese/rótulos temáticos (institution/município keep the shared literals). */
|
||||
byAreaTitle: 'Tipos de atuação com mais registros',
|
||||
byYearTitle: 'Pontos com fonte por ano',
|
||||
fullYearAxis: true,
|
||||
eraCoverageFromLedger: true,
|
||||
moneyPhaseStyle: 'separate',
|
||||
/**
|
||||
* Theme bulletins read the year when a fact has no money value, and mark the
|
||||
* card textual when neither exists (never a fake zero).
|
||||
|
|
@ -336,12 +289,6 @@ export const THEME_UNIT = {
|
|||
C: 'API Câmara · Portal da Transparência · acervo interno read-only · busca web datada',
|
||||
},
|
||||
identityBadges: (identity) => [identity.label].filter(Boolean),
|
||||
/** Theme bulletin pills: recorte label, canonical token (mono) and taxonomy origin. */
|
||||
bulletinPills: (identity) => [
|
||||
{ label: 'Área' },
|
||||
{ label: identity.value, code: true },
|
||||
{ label: identity.taxonomyNote ?? 'Taxonomia do acervo' },
|
||||
],
|
||||
identityRows: (identity) =>
|
||||
[
|
||||
['Área', identity.label],
|
||||
|
|
@ -349,14 +296,6 @@ export const THEME_UNIT = {
|
|||
['Origem', identity.taxonomyNote ?? 'Taxonomia do acervo'],
|
||||
['Filtro', 'Speech.topics'],
|
||||
].filter(([, value]) => Boolean(value)),
|
||||
coverRows: (report) => [
|
||||
{ label: 'Área', badges: report.meta.identityBadges },
|
||||
{
|
||||
label: 'Valor canônico',
|
||||
code: report.meta.identity?.value,
|
||||
note: 'taxonomia do acervo / Speech.topics',
|
||||
},
|
||||
],
|
||||
copy: {
|
||||
coverSubtitle: (subjectName) =>
|
||||
`O que Jorge Solla fez ${themeSubjectPhrase(subjectName)} ao longo da carreira`,
|
||||
|
|
@ -364,15 +303,10 @@ export const THEME_UNIT = {
|
|||
'Localize a entrega, confira fonte, data, fase e abrangência. A área vem da taxonomia canônica; segmento e rede não são somados a ela.',
|
||||
coverScope:
|
||||
'Escopo: carreira técnica e gestão pública até 2006 · SESAB 2007–2014 · Câmara 2015–2027. Área, segmento e rede separados; sem fonte, não entra como entrega.',
|
||||
coverScopeWords: 'área, segmento e rede separados',
|
||||
coverAsset: ['selo autorizado', 'e origem'],
|
||||
openingTitle: (subjectName) => `O que Jorge Solla fez ${themeSubjectPhrase(subjectName)}`,
|
||||
reachRuleTitle: 'Segmento/rede não é a área. Não some os recortes.',
|
||||
reachRuleBody:
|
||||
'Uma ação dirigida a um segmento ou a uma rede pode alcançar a política de área sem constituir entrega exclusiva dela.',
|
||||
synthesisGuard: 'segmento e rede nunca somados à área.',
|
||||
coverageNote: 'Área, segmento e rede separados; sem fonte, não entra como entrega.',
|
||||
timelineEmpty: 'Nenhum vínculo datado com fonte — ver lacunas explícitas.',
|
||||
eraEmpty: {
|
||||
body: 'A pesquisa não encontrou documento que sustente uma afirmação sobre a área. Isso não prova ausência de atuação; prova apenas que o dossiê ainda não pode afirmá-la.',
|
||||
work: [
|
||||
|
|
@ -405,31 +339,18 @@ export const THEME_UNIT = {
|
|||
'Atribuição temática a emenda ou proposição só entra com fonte; sem fonte, vira lacuna.',
|
||||
],
|
||||
},
|
||||
mdAcervoNote: 'Recorte pela tag do acervo, não nominal.',
|
||||
bulletin: {
|
||||
aria: 'Modelo de boletim informativo temático de uma página',
|
||||
openingLabel: 'Uma trajetória de trabalho pela área',
|
||||
openingTitle: (subjectName) => `O que Jorge Solla fez ${themeSubjectPhrase(subjectName)}`,
|
||||
lead: 'Ações, recursos e articulações explicados de forma direta — somente o que já foi documentado no dossiê da área.',
|
||||
leadSparse:
|
||||
'Esta versão tem poucos registros confirmados. Em vez de preencher espaço, mostra só os fatos que o dossiê sustenta.',
|
||||
asset: 'Solla na ação citada',
|
||||
assetSparse: 'somente registro relacionado ao fato',
|
||||
assetSelo: ['selo autorizado', 'arquivo vetorial'],
|
||||
assetClipping: ['registro real da ação', 'origem e licença'],
|
||||
moreEmpty: 'Conferir as lacunas explícitas no dossiê da área antes de ampliar o boletim.',
|
||||
defeso:
|
||||
'fase acompanha cada valor · área, segmento e rede não são somados · sem percentual estadual absoluto.',
|
||||
orig: 'Conteúdo selecionado do dossiê da área.',
|
||||
remaining: 'no dossiê da área — o boletim de uma página não os exibe.',
|
||||
sparseRule:
|
||||
'Não repetir item, não ampliar efeito e não preencher com ação de segmento ou rede como se fosse entrega exclusiva da área.',
|
||||
title: 'Uma trajetória de compromisso com o serviço público',
|
||||
titleSparse: 'contexto de carreira · não substitui fato da área',
|
||||
highlightsTitle: 'Resultados em leitura rápida',
|
||||
highlightsTitleSparse: 'Poucos fatos, sem enchimento',
|
||||
moreTitle: 'Outras ações confirmadas',
|
||||
moreTitleSparse: 'Outros registros confirmados',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,19 +106,20 @@ describe('dossiê sem caps + redação (revisão 2026-09-18)', () => {
|
|||
expect(skill, 'continuation sheets carry the header').toContain('continuação N')
|
||||
})
|
||||
|
||||
it('documents the opening letter, the synthesis and the charts', () => {
|
||||
expect(skill).toContain('A contribuição (carta)')
|
||||
expect(skill).toContain('**Síntese**')
|
||||
expect(skill).toContain('Gráficos consolidados')
|
||||
expect(skill, 'the era opens with a consolidation paragraph').toMatch(
|
||||
/O que esta era\s+entrega/,
|
||||
)
|
||||
it('documents the analysis-first sections and the sober design', () => {
|
||||
expect(skill).toContain('O essencial')
|
||||
expect(skill).toContain('Leitura entre eras')
|
||||
expect(skill).toContain('O que Solla defende')
|
||||
expect(skill, 'the era opens with its reading').toContain('Leitura da era')
|
||||
expect(skill, 'the charts were retired').not.toContain('Gráficos consolidados')
|
||||
})
|
||||
|
||||
it('pins the narrative contract and the writer receipt', () => {
|
||||
expect(skill).toContain('## Recibo do redator')
|
||||
expect(skill).toContain('"municipalitySlug": "ilheus"')
|
||||
expect(skill, 'the narrative file carries the era paragraphs').toContain('"eras": {')
|
||||
expect(skill, 'the narrative may carry the between-eras bullets').toContain('"betweenEras"')
|
||||
expect(skill, 'the defense checklist item is documented').toContain('era_c_defesas')
|
||||
expect(skill, 'the orchestrator audits the citations').toMatch(/audita/i)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
mergeDossierResearch,
|
||||
normalizeDossierResearchInput,
|
||||
} from '../../scripts/lib/dossieResearch.mjs'
|
||||
import { SUMMARY_POINTER_COPY } from '../../scripts/lib/reportText.mjs'
|
||||
|
||||
const generatedAt = new Date('2026-09-17T12:00:00.000Z')
|
||||
|
||||
|
|
@ -48,6 +47,11 @@ const research = mergeDossierResearch([
|
|||
numbers: [{ label: 'Recurso', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
|
||||
}),
|
||||
item('era_c_titulos', { answer: 'Cidadão Ilheense' }),
|
||||
item('era_c_defesas', {
|
||||
position: 'Ensino superior',
|
||||
answer: 'Defende a implantação de campus da UFBA na região',
|
||||
details: 'INC 1497/2023 · Câmara dos Deputados',
|
||||
}),
|
||||
]),
|
||||
),
|
||||
])
|
||||
|
|
@ -138,7 +142,14 @@ describe('buildDossierReport', () => {
|
|||
expect(report.bulletinFacts.some((fact) => fact.area === 'Emendas')).toBe(true)
|
||||
})
|
||||
|
||||
it('omits an era that has no number and no action', () => {
|
||||
it('marks the defense items on the bulletin ledger', () => {
|
||||
const report = build()
|
||||
const defense = report.bulletinFacts.find((fact) => fact.id === 'era_c_defesas')
|
||||
expect(defense).toMatchObject({ defense: true })
|
||||
expect(report.bulletinFacts.find((fact) => fact.id === 'era_c_emendas')?.defense).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps an era without evidence in the report, omitted as a section (C209)', () => {
|
||||
const empty = mergeDossierResearch([
|
||||
normalizeDossierResearchInput(eraFile('A', [])),
|
||||
normalizeDossierResearchInput(eraFile('B', [])),
|
||||
|
|
@ -152,114 +163,114 @@ describe('buildDossierReport', () => {
|
|||
health: { status: 'gap', items: [] },
|
||||
generatedAt,
|
||||
})
|
||||
expect(report.eras).toHaveLength(0)
|
||||
expect(report.eras).toHaveLength(3)
|
||||
expect(report.eras.every((era) => era.omitted)).toBe(true)
|
||||
})
|
||||
|
||||
it('caps the page-1 deliveries and keeps the full timeline on its own page', () => {
|
||||
const report = build()
|
||||
expect(report.page1.deliveries.items.length).toBeLessThanOrEqual(3)
|
||||
expect(report.page1.deliveries.total).toBeGreaterThanOrEqual(
|
||||
report.page1.deliveries.items.length,
|
||||
)
|
||||
expect(report.page1.timeline.length).toBe(6)
|
||||
expect(report.trajectory.length).toBeGreaterThan(report.page1.timeline.length)
|
||||
})
|
||||
|
||||
it('prefers the researcher summary on the resumo and keeps the integral in the era (C188)', () => {
|
||||
const summarized = mergeDossierResearch([
|
||||
normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista')])),
|
||||
normalizeDossierResearchInput(eraFile('B', [item('era_b_sesab')])),
|
||||
normalizeDossierResearchInput(
|
||||
eraFile('C', [
|
||||
item('era_c_emendas', {
|
||||
summary: 'Emenda de R$ 1,2 mi empenhada em 2024.',
|
||||
numbers: [{ label: 'Recurso', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
|
||||
}),
|
||||
]),
|
||||
),
|
||||
])
|
||||
const report = build({ research: summarized })
|
||||
const delivery = report.page1.deliveries.items.find((row: { era: string }) => row.era === 'C')!
|
||||
expect(delivery.title).toBe('Emenda de R$ 1,2 mi empenhada em 2024.')
|
||||
const eraC = report.eras.find((era) => era?.id === 'C')!
|
||||
expect(
|
||||
eraC.actions.some(
|
||||
(action: { title: string }) => action.title === 'Resposta de era_c_emendas',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to a pointer line (never "…") without losing the era integral (C188)', () => {
|
||||
const report = build({ textFallback: 'pointer' })
|
||||
const delivery = report.page1.deliveries.items[0]
|
||||
expect(delivery.title).toBe(SUMMARY_POINTER_COPY)
|
||||
expect(delivery.detail).toBeNull()
|
||||
expect(delivery.title.includes('…')).toBe(false)
|
||||
const eraC = report.eras.find((era) => era?.id === 'C')!
|
||||
expect(
|
||||
eraC.actions.some(
|
||||
(action: { title: string }) => action.title === 'Resposta de era_c_emendas',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the researcher summary even on the pointer pass (C188)', () => {
|
||||
const summarized = mergeDossierResearch([
|
||||
normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista')])),
|
||||
normalizeDossierResearchInput(eraFile('B', [item('era_b_sesab')])),
|
||||
normalizeDossierResearchInput(
|
||||
eraFile('C', [
|
||||
item('era_c_emendas', {
|
||||
summary: 'Emenda de R$ 1,2 mi empenhada em 2024.',
|
||||
numbers: [{ label: 'Recurso', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
|
||||
}),
|
||||
]),
|
||||
),
|
||||
])
|
||||
const report = build({ research: summarized, textFallback: 'pointer' })
|
||||
const summaryDelivery = report.page1.deliveries.items.find(
|
||||
(row: { era: string }) => row.era === 'C',
|
||||
)!
|
||||
expect(summaryDelivery.title).toBe('Emenda de R$ 1,2 mi empenhada em 2024.')
|
||||
const hook = report.page1.hooks.items.find(
|
||||
(row: { angle: string }) => row.angle === SUMMARY_POINTER_COPY,
|
||||
)
|
||||
expect(Boolean(hook)).toBe(true)
|
||||
})
|
||||
|
||||
it('counts every capped list so nothing disappears in silence (C188)', () => {
|
||||
const manyRegional = mergeDossierResearch([
|
||||
normalizeDossierResearchInput(
|
||||
eraFile(
|
||||
'A',
|
||||
[
|
||||
'era_a_formacao',
|
||||
'era_a_sesab',
|
||||
'era_a_consultor_ms',
|
||||
'era_a_conquista',
|
||||
'era_a_sas_ms',
|
||||
].map((id) => item(id, { sphere: 'regiao' })),
|
||||
),
|
||||
),
|
||||
normalizeDossierResearchInput(eraFile('B', [])),
|
||||
normalizeDossierResearchInput(eraFile('C', [])),
|
||||
])
|
||||
const report = build({ research: manyRegional })
|
||||
const eraA = report.eras.find((era) => era?.id === 'A')!
|
||||
expect(eraA.actions).toHaveLength(5)
|
||||
// Uncapped (2026-09-18): the full lists flow across sheets, so there is no
|
||||
// remaining/omitted count on the era or region lists.
|
||||
expect(eraA).not.toHaveProperty('camaraActionsRemaining')
|
||||
expect(report.region.regional.items).toHaveLength(5)
|
||||
expect(report.region.regional.total).toBe(5)
|
||||
expect(report.region.regional).not.toHaveProperty('remaining')
|
||||
})
|
||||
|
||||
it('carries the defeso limits and the editorial rules', () => {
|
||||
it('keeps the trajectory and the defeso limits', () => {
|
||||
const report = build()
|
||||
expect(report.trajectory.length).toBeGreaterThan(6)
|
||||
expect(report.limits.editorial).toContain('Sem fonte, não publica.')
|
||||
expect(report.limits.coverage.join(' ')).toContain('2011')
|
||||
})
|
||||
|
||||
it('reads the sourced set into O essencial facts (never a new fact)', () => {
|
||||
const report = build()
|
||||
expect(report.essentials.facts.length).toBeGreaterThanOrEqual(3)
|
||||
const labels = report.essentials.facts.map((fact: { label: string }) => fact.label).join(' ')
|
||||
expect(labels).toContain('pontos com fonte')
|
||||
expect(labels).toContain('Abrangência sem soma')
|
||||
expect(labels).toContain('lacunas declaradas')
|
||||
expect(report.essentials.facts.map((fact: { text: string }) => fact.text).join(' ')).toContain(
|
||||
'Empenho não é pagamento.',
|
||||
)
|
||||
})
|
||||
|
||||
it('derives the between-eras reading from the ledger', () => {
|
||||
const report = build()
|
||||
expect(
|
||||
report.betweenEras.bullets.map((bullet: { label: string | null }) => bullet.label),
|
||||
).toContain('Concentração.')
|
||||
expect(report.betweenEras.table).toHaveLength(3)
|
||||
expect(report.betweenEras.table[0]).toMatchObject({
|
||||
where: expect.any(String),
|
||||
howToCite: expect.any(String),
|
||||
limit: expect.any(String),
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the narrative betweenEras bullets when the writer provides them', () => {
|
||||
const report = build({
|
||||
narrative: { betweenEras: ['Leitura autoral do conjunto.', 'Segunda linha.'] },
|
||||
})
|
||||
expect(report.betweenEras.bullets).toEqual([
|
||||
{ label: null, text: 'Leitura autoral do conjunto.' },
|
||||
{ label: null, text: 'Segunda linha.' },
|
||||
])
|
||||
})
|
||||
|
||||
it('separates the positions with lastro from the defense gaps (fail-closed)', () => {
|
||||
const report = build()
|
||||
expect(report.defends.positions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'era_c_defesas',
|
||||
label: 'Ensino superior',
|
||||
reading: 'Defende a implantação de campus da UFBA na região',
|
||||
sourceUrl: 'https://exemplo.test/era_c_defesas',
|
||||
}),
|
||||
)
|
||||
// Era A/B defenses were not researched: explicit gaps, never an inference.
|
||||
expect(report.defends.gaps.map((gap: { id: string }) => gap.id)).toEqual([
|
||||
'era_a_defesas',
|
||||
'era_b_defesas',
|
||||
])
|
||||
})
|
||||
|
||||
it('degrades a defense without a source to an explicit gap', () => {
|
||||
const unsourced = mergeDossierResearch([
|
||||
normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista')])),
|
||||
normalizeDossierResearchInput(eraFile('B', [item('era_b_sesab')])),
|
||||
normalizeDossierResearchInput(
|
||||
eraFile('C', [
|
||||
item('era_c_defesas', {
|
||||
position: 'Saúde regional',
|
||||
answer: 'Defende o SAMU regional',
|
||||
sourceUrl: null,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
])
|
||||
const report = build({ research: unsourced })
|
||||
expect(report.defends.positions).toHaveLength(0)
|
||||
expect(report.defends.gaps).toContainEqual(
|
||||
expect.objectContaining({ id: 'era_c_defesas', reason: expect.stringMatching(/Sem fonte/) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the news summary and the era for the sources table', () => {
|
||||
const withNews = mergeDossierResearch([
|
||||
normalizeDossierResearchInput(eraFile('A', [item('era_a_conquista')])),
|
||||
normalizeDossierResearchInput(eraFile('B', [item('era_b_sesab')])),
|
||||
normalizeDossierResearchInput({
|
||||
...eraFile('C', [item('era_c_emendas')]),
|
||||
news: [
|
||||
{
|
||||
title: 'Matéria local',
|
||||
url: 'https://jornal.test/materia',
|
||||
publishedAt: '2024-06-01',
|
||||
outlet: 'Jornal Local',
|
||||
summary: 'Resumo datado da matéria.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
const report = build({ research: withNews })
|
||||
expect(report.news[0]).toMatchObject({
|
||||
era: 'C',
|
||||
summary: 'Resumo datado da matéria.',
|
||||
outlet: 'Jornal Local',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('dossier checklist coverage', () => {
|
||||
|
|
@ -268,4 +279,12 @@ describe('dossier checklist coverage', () => {
|
|||
expect(dossierChecklistForEra(era).every((row) => row.era === era)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries one defense item per era (kind: defense)', () => {
|
||||
for (const era of ['A', 'B', 'C'] as const) {
|
||||
const defenses = dossierChecklistForEra(era).filter((row) => row.kind === 'defense')
|
||||
expect(defenses).toHaveLength(1)
|
||||
expect(defenses[0].id).toBe(`era_${era.toLowerCase()}_defesas`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { BULLETIN_HIGHLIGHT_LIMIT, buildBulletin } from '../../scripts/lib/dossieBulletin.mjs'
|
||||
import {
|
||||
BULLETIN_DEFENSE_LIMIT,
|
||||
BULLETIN_HIGHLIGHT_LIMIT,
|
||||
buildBulletin,
|
||||
nextBulletinFit,
|
||||
} from '../../scripts/lib/dossieBulletin.mjs'
|
||||
import { MUNICIPALITY_UNIT } from '../../scripts/lib/dossieUnit.mjs'
|
||||
|
||||
const fact = (index: number, overrides: Record<string, unknown> = {}) => ({
|
||||
|
|
@ -18,6 +23,9 @@ const fact = (index: number, overrides: Record<string, unknown> = {}) => ({
|
|||
...overrides,
|
||||
})
|
||||
|
||||
const defense = (index: number, overrides: Record<string, unknown> = {}) =>
|
||||
fact(index, { defense: true, headline: `Defesa ${index}`, ...overrides })
|
||||
|
||||
const generatedAt = new Date('2026-09-17T12:00:00.000Z')
|
||||
|
||||
describe('buildBulletin', () => {
|
||||
|
|
@ -33,9 +41,12 @@ describe('buildBulletin', () => {
|
|||
const facts = Array.from({ length: 25 }, (_value, index) => fact(index))
|
||||
const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt })
|
||||
expect(bulletin.factsTotal).toBe(25)
|
||||
expect(bulletin.highlights.length + bulletin.moreItems.length + bulletin.factsRemaining).toBe(
|
||||
bulletin.factsTotal,
|
||||
)
|
||||
expect(
|
||||
bulletin.highlights.length +
|
||||
bulletin.moreItems.length +
|
||||
bulletin.defenses.length +
|
||||
bulletin.factsRemaining,
|
||||
).toBe(bulletin.factsTotal)
|
||||
})
|
||||
|
||||
it('has no remainder when everything fits', () => {
|
||||
|
|
@ -53,30 +64,81 @@ describe('buildBulletin', () => {
|
|||
expect(bulletin.highlights[0].title).toBe('Entrega 3')
|
||||
})
|
||||
|
||||
it('composes the card eyebrow with area and sphere', () => {
|
||||
const bulletin = buildBulletin({
|
||||
facts: [fact(1, { sphere: 'regiao' })],
|
||||
municipality: 'Ilhéus',
|
||||
generatedAt,
|
||||
})
|
||||
expect(bulletin.highlights[0].eyebrow).toBe('Saúde · região')
|
||||
})
|
||||
|
||||
it('renders an empty state without inventing facts', () => {
|
||||
const bulletin = buildBulletin({ facts: [], municipality: 'Ilhéus', generatedAt })
|
||||
expect(bulletin.highlights).toEqual([])
|
||||
expect(bulletin.moreItems).toEqual([])
|
||||
expect(bulletin.defenses).toEqual([])
|
||||
expect(bulletin.timeline.length).toBe(4)
|
||||
})
|
||||
|
||||
it('maps every highlight and "e mais" item to a ledger fact (never invents)', () => {
|
||||
const facts = Array.from({ length: 25 }, (_value, index) => fact(index))
|
||||
it('prints the defenses outside the highlight ranking and counts them', () => {
|
||||
const facts = [
|
||||
defense(1, { headline: 'Defende o SAMU regional' }),
|
||||
defense(2, { headline: 'Defende campus da UFBA' }),
|
||||
defense(3, { headline: 'Defende a Policlínica' }),
|
||||
...Array.from({ length: 25 }, (_value, index) => fact(index)),
|
||||
]
|
||||
const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt })
|
||||
expect(bulletin.defenses).toHaveLength(BULLETIN_DEFENSE_LIMIT)
|
||||
expect(bulletin.defenses[0]).toMatchObject({
|
||||
label: 'Defende o SAMU regional',
|
||||
text: 'Detalhe 1',
|
||||
})
|
||||
// The defenses never displace a sourced finding from the printed slots.
|
||||
expect(bulletin.highlights[0].title).toBe('Entrega 0')
|
||||
expect(
|
||||
bulletin.highlights.length +
|
||||
bulletin.moreItems.length +
|
||||
bulletin.defenses.length +
|
||||
bulletin.factsRemaining,
|
||||
).toBe(bulletin.factsTotal)
|
||||
})
|
||||
|
||||
it('maps every highlight, "e mais" item and defense to a ledger fact (never invents)', () => {
|
||||
const facts = [defense(99), ...Array.from({ length: 25 }, (_value, index) => fact(index))]
|
||||
const bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt })
|
||||
const headlines = new Set(facts.map((row) => row.headline))
|
||||
expect(bulletin.highlights.length + bulletin.moreItems.length).toBe(
|
||||
BULLETIN_HIGHLIGHT_LIMIT + MUNICIPALITY_UNIT.bulletinMoreLimit,
|
||||
)
|
||||
expect(bulletin.highlights.every((row) => headlines.has(row.title))).toBe(true)
|
||||
expect(bulletin.moreItems.every((row) => headlines.has(row.label))).toBe(true)
|
||||
expect(bulletin.defenses.every((row) => headlines.has(row.label))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextBulletinFit', () => {
|
||||
it('reduces the printed facts first and then the defenses', () => {
|
||||
const facts = [
|
||||
defense(1),
|
||||
defense(2),
|
||||
...Array.from({ length: 10 }, (_value, index) => fact(index)),
|
||||
]
|
||||
let bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt })
|
||||
let next = nextBulletinFit(bulletin)
|
||||
expect(next).toEqual({ printLimit: 8 })
|
||||
bulletin = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt, ...next })
|
||||
next = nextBulletinFit(bulletin)
|
||||
expect(next).toEqual({ printLimit: 6 })
|
||||
// Down to zero printed facts, the defenses are the next budget.
|
||||
const zeroed = buildBulletin({ facts, municipality: 'Ilhéus', generatedAt, printLimit: 0 })
|
||||
expect(nextBulletinFit(zeroed)).toEqual({ printLimit: 0, defenseLimit: 1 })
|
||||
const oneDefense = buildBulletin({
|
||||
facts,
|
||||
municipality: 'Ilhéus',
|
||||
generatedAt,
|
||||
printLimit: 0,
|
||||
defenseLimit: 1,
|
||||
})
|
||||
expect(nextBulletinFit(oneDefense)).toEqual({ printLimit: 0, defenseLimit: 0 })
|
||||
})
|
||||
|
||||
it('returns null when there is nothing left to cut (fail-closed)', () => {
|
||||
const bulletin = buildBulletin({
|
||||
facts: [fact(1)],
|
||||
municipality: 'Ilhéus',
|
||||
generatedAt,
|
||||
printLimit: 0,
|
||||
defenseLimit: 0,
|
||||
})
|
||||
expect(nextBulletinFit(bulletin)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import {
|
|||
import { INSTITUTION_UNIT } from '../../scripts/lib/dossieUnit.mjs'
|
||||
|
||||
// C187: institution content model + render. Grounds the institutional recorte:
|
||||
// identity badges, `instituicao|setor|rede` never summed, per-row phase/source,
|
||||
// identity rows, `instituicao|setor|rede` never summed, per-row phase/source,
|
||||
// empty-era state instead of silent omission, and a one-page boletim that
|
||||
// inherits only sourced facts.
|
||||
// inherits only sourced facts. C209: sober redesign (analysis first, lists and
|
||||
// tables, "O que Solla defende").
|
||||
|
||||
const generatedAt = new Date('2026-09-17T12:00:00.000Z')
|
||||
|
||||
|
|
@ -94,6 +95,10 @@ const research = mergeDossierResearch(
|
|||
numbers: [{ label: 'Emenda', value: 'R$ 5,0 mi', year: '2024', phase: 'empenhado' }],
|
||||
}),
|
||||
item('era_c_titulos', 'C', { answer: 'Título de cidadão honorário' }),
|
||||
item('era_c_defesas', 'C', {
|
||||
position: 'Financiamento',
|
||||
answer: 'Defende o financiamento das universidades federais',
|
||||
}),
|
||||
]),
|
||||
{ unit: INSTITUTION_UNIT },
|
||||
),
|
||||
|
|
@ -150,13 +155,6 @@ describe('buildDossierReport (institution unit)', () => {
|
|||
expect(listFor('network')?.total).toBe(1)
|
||||
})
|
||||
|
||||
it('declares overflow instead of truncating silently (C188 rule)', () => {
|
||||
for (const list of report.reach.lists) {
|
||||
expect(list).toMatchObject({ total: expect.any(Number), omitted: expect.any(Number) })
|
||||
}
|
||||
expect(report.page1.deliveries.omitted).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('shows every sourced item — institution lists are never capped', () => {
|
||||
const manyItems = [
|
||||
item('era_c_emendas', 'C', { sphere: 'setor' }),
|
||||
|
|
@ -212,7 +210,7 @@ describe('buildDossierReport (institution unit)', () => {
|
|||
expect(paid).toMatchObject({ count: 1, amount: 1_200_000 })
|
||||
const html = renderDossierHtml(moneyReport)
|
||||
expect(html).toContain('R$ 1,2 mi')
|
||||
expect(html).toContain('Recursos com execução por ano')
|
||||
expect(html).toContain('Valores localizados, sem consolidar fases')
|
||||
})
|
||||
|
||||
it('derives bulletinFacts only from sourced items', () => {
|
||||
|
|
@ -226,6 +224,16 @@ describe('buildDossierReport (institution unit)', () => {
|
|||
expect(report.bulletinFacts.some((fact) => fact.id === 'fala-11')).toBe(true)
|
||||
expect(renderDossierMd(report)).toContain('## Acervo interno (read-only)')
|
||||
})
|
||||
|
||||
it('reads the positions with lastro and the missing defenses', () => {
|
||||
expect(report.defends.positions).toContainEqual(
|
||||
expect.objectContaining({ label: 'Financiamento', era: 'C' }),
|
||||
)
|
||||
expect(report.defends.gaps.map((gap: { id: string }) => gap.id)).toEqual([
|
||||
'era_a_defesas',
|
||||
'era_b_defesas',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderDossierHtml/Md (institution unit)', () => {
|
||||
|
|
@ -237,23 +245,23 @@ describe('renderDossierHtml/Md (institution unit)', () => {
|
|||
'capa',
|
||||
'resumo',
|
||||
'sintese',
|
||||
'graficos',
|
||||
'trajetoria',
|
||||
'era-a',
|
||||
'era-b',
|
||||
'era-c',
|
||||
'abrangencia',
|
||||
'abrangencia-instituicao',
|
||||
'abrangencia-setor',
|
||||
'abrangencia-rede',
|
||||
'titulos',
|
||||
'defende',
|
||||
'lacunas',
|
||||
'noticias',
|
||||
'acervo',
|
||||
'fontes',
|
||||
]) {
|
||||
expect(html).toContain(`data-page="${anchor}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it('prints the synthesis lines and the consolidated charts', () => {
|
||||
it('prints the synthesis lines and the between-eras reading, never the old charts', () => {
|
||||
const synthesis = report.synthesis
|
||||
expect(synthesis).toBeTruthy()
|
||||
if (!synthesis) return
|
||||
|
|
@ -269,11 +277,11 @@ describe('renderDossierHtml/Md (institution unit)', () => {
|
|||
)
|
||||
expect(sphereTotal).toBe(synthesis.totals.items)
|
||||
expect(html).toContain('data-page="sintese"')
|
||||
expect(html).toContain('data-page="graficos"')
|
||||
expect(html).toContain('Síntese do que foi localizado')
|
||||
expect(html).toContain('Gráficos consolidados')
|
||||
expect(html).toContain('chart-grid')
|
||||
expect(md).toContain('## Síntese do que foi localizado')
|
||||
expect(html).toContain('Leitura entre eras')
|
||||
expect(html).not.toContain('data-page="graficos"')
|
||||
expect(html).not.toContain('Gráficos consolidados')
|
||||
expect(html).not.toContain('chart-grid')
|
||||
expect(md).toContain('## Leitura entre eras')
|
||||
})
|
||||
|
||||
it('flows across continuation sheets when the pack plan splits a section', () => {
|
||||
|
|
@ -310,12 +318,12 @@ describe('renderDossierHtml/Md (institution unit)', () => {
|
|||
unit: INSTITUTION_UNIT,
|
||||
})
|
||||
const bigHtml = renderDossierHtml(bigReport, {
|
||||
pack: { 'era:C': [1], 'scope:setor': [1], news: [1] },
|
||||
pack: { 'era:C': [1], scope: [1], news: [1] },
|
||||
})
|
||||
expect(bigHtml).toContain('data-page="era-c"')
|
||||
expect(bigHtml).toContain('data-page="era-c-2"')
|
||||
expect(bigHtml).toContain('data-page="abrangencia-setor"')
|
||||
expect(bigHtml).toContain('data-page="abrangencia-setor-2"')
|
||||
expect(bigHtml).toContain('data-page="abrangencia"')
|
||||
expect(bigHtml).toContain('data-page="abrangencia-2"')
|
||||
expect(bigHtml).toContain('data-page="noticias"')
|
||||
expect(bigHtml).toContain('data-page="noticias-2"')
|
||||
expect(bigHtml).toContain('continuação')
|
||||
|
|
@ -324,24 +332,27 @@ describe('renderDossierHtml/Md (institution unit)', () => {
|
|||
expect(anchors.length).toBe(bigReport.meta.pageTotal)
|
||||
})
|
||||
|
||||
it('ports the institutional classes and identity badges', () => {
|
||||
expect(html).toContain('identity-badge')
|
||||
expect(html).toContain('scope-sector')
|
||||
expect(html).toContain('scope-network')
|
||||
expect(html).toContain('phase-paid')
|
||||
expect(html).toContain('phase-pending')
|
||||
it('ports the sober classes: index, position list, tables and phase as text', () => {
|
||||
expect(html).toContain('index-list')
|
||||
expect(html).toContain('position-list')
|
||||
expect(html).toContain('plain-list')
|
||||
expect(html).toContain('document-table')
|
||||
expect(html).toContain('phase-text')
|
||||
expect(html).not.toContain('identity-badge')
|
||||
expect(html).not.toContain('scope-cards')
|
||||
expect(html).not.toContain('asset-box')
|
||||
})
|
||||
|
||||
it('prints the non-summable rule and the defeso note', () => {
|
||||
expect(html).toContain('Setor/rede não é a instituição')
|
||||
expect(html).toContain('Não somar.')
|
||||
expect(html).toContain('Nota de defeso eleitoral 2026')
|
||||
})
|
||||
|
||||
it('renders the honors scene with date, nature and the reading panels', () => {
|
||||
expect(html).toContain('Data, natureza e fonte primária')
|
||||
expect(html).toContain('Como ler')
|
||||
expect(html).toContain('Vínculo documentado')
|
||||
expect(html).toContain('Honraria documentada')
|
||||
it('renders the honors scene with date, nature and the reading rule', () => {
|
||||
expect(html).toContain('Títulos, honrarias e vínculos')
|
||||
expect(html).toContain('Data')
|
||||
expect(html).toContain('Natureza')
|
||||
expect(html).toContain('Como ler:')
|
||||
})
|
||||
|
||||
it('escapes untrusted research text', () => {
|
||||
|
|
@ -367,9 +378,16 @@ describe('renderDossierHtml/Md (institution unit)', () => {
|
|||
it('mirrors the institution sections in markdown', () => {
|
||||
expect(md).toContain('# Dossiê Solla por instituição — UFBA')
|
||||
expect(md).toContain('## Abrangência: instituição × setor × rede')
|
||||
expect(md).toContain('## Títulos, honrarias e vínculos')
|
||||
expect(md).toContain('## Lacunas explícitas')
|
||||
expect(md).toContain('[fonte](https://ufba.test/era_a_formacao)')
|
||||
})
|
||||
|
||||
it('links every index entry to an anchor that exists in the companion', () => {
|
||||
const links = [...md.matchAll(/\]\(#([^)]+)\)/g)].map((match) => match[1])
|
||||
expect(links.length).toBeGreaterThan(0)
|
||||
for (const anchor of links) expect(md).toContain(`<a id="${anchor}"></a>`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildBulletin (institution unit)', () => {
|
||||
|
|
@ -384,30 +402,27 @@ describe('buildBulletin (institution unit)', () => {
|
|||
})
|
||||
const html = renderBulletinHtml(bulletin)
|
||||
|
||||
it('is a single A4 page with the model label and identity pills', () => {
|
||||
it('is a single A4 page with the model label and the institution name', () => {
|
||||
expect(html).toContain('data-page="boletim"')
|
||||
expect((html.match(/data-page="boletim"/g) ?? []).length).toBe(1)
|
||||
expect(html).toContain('Modelo — insumo interno')
|
||||
expect(html).toContain('identity-pill')
|
||||
expect(html).toContain('UFBA')
|
||||
})
|
||||
|
||||
it('declares no sources and carries the defeso band', () => {
|
||||
it('declares no sources and carries the defeso footer', () => {
|
||||
expect(html).not.toContain('source-link')
|
||||
expect(html).not.toContain('Fonte:')
|
||||
expect(html).toContain('defeso-band')
|
||||
expect(html).toContain('sem CTA')
|
||||
expect(html).toContain('Defeso eleitoral')
|
||||
expect(html).toContain('Não publicar sem revisão')
|
||||
})
|
||||
|
||||
it('keeps the phase next to a valued number (empenho ≠ pagamento)', () => {
|
||||
expect(html).toContain('highlight-phase')
|
||||
expect(html).toContain('phase-pending')
|
||||
expect(html).toContain('empenhado')
|
||||
expect(html).toContain('phase-paid')
|
||||
expect(html).toContain('pago')
|
||||
expect(html).toContain('bulletin-list')
|
||||
})
|
||||
|
||||
it('renders the sparse variant when there are fewer than three facts', () => {
|
||||
it('renders the sparse variant without filling the page', () => {
|
||||
const sparse = buildBulletin({
|
||||
facts: [report.bulletinFacts[0]],
|
||||
identity: { name: 'UFBA', badges: ['Universidade'] },
|
||||
|
|
@ -416,7 +431,7 @@ describe('buildBulletin (institution unit)', () => {
|
|||
})
|
||||
const sparseHtml = renderBulletinHtml(sparse)
|
||||
expect(sparse.sparse).toBe(true)
|
||||
expect(sparseHtml).toContain('lacuna-panel')
|
||||
expect(sparseHtml).toContain('A página termina com espaço')
|
||||
expect(sparseHtml).toContain('note-line')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -110,19 +110,20 @@ describe('dossiê institucional sem caps + redação (revisão 2026-09-18)', ()
|
|||
expect(skill, 'continuation sheets carry the header').toContain('continuação N')
|
||||
})
|
||||
|
||||
it('documents the opening letter, the synthesis and the charts', () => {
|
||||
expect(skill).toContain('A contribuição (carta)')
|
||||
expect(skill).toContain('**Síntese**')
|
||||
expect(skill).toContain('Gráficos consolidados')
|
||||
expect(skill, 'the era opens with a consolidation paragraph').toMatch(
|
||||
/O que esta era\s+entrega/,
|
||||
)
|
||||
it('documents the analysis-first sections and the sober design', () => {
|
||||
expect(skill).toContain('O essencial')
|
||||
expect(skill).toContain('Leitura entre eras')
|
||||
expect(skill).toContain('O que Solla defende')
|
||||
expect(skill, 'the era opens with its reading').toContain('Leitura da era')
|
||||
expect(skill, 'the charts were retired').not.toContain('Gráficos consolidados')
|
||||
})
|
||||
|
||||
it('pins the narrative contract and the writer receipt', () => {
|
||||
expect(skill).toContain('## Recibo do redator')
|
||||
expect(skill).toContain('"institutionSlug": "ufba"')
|
||||
expect(skill, 'the narrative file carries the era paragraphs').toContain('"eras": {')
|
||||
expect(skill, 'the narrative may carry the between-eras bullets').toContain('"betweenEras"')
|
||||
expect(skill, 'the defense checklist item is documented').toContain('era_c_defesas')
|
||||
expect(skill, 'the orchestrator audits the citations').toMatch(/audita/i)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,19 @@ const generatedAt = new Date('2026-09-17T12:00:00.000Z')
|
|||
const snapshot = {
|
||||
meta: { readAt: '2026-09-16T10:00:00.000Z', codeSha: 'abc1234', database: 'teqo_test' },
|
||||
municipality: { slug: 'ilheus', name: 'Ilhéus', region: 'Litoral Sul', ibgeCode: '2913606' },
|
||||
speeches: {
|
||||
topics: { themes: ['saude'] },
|
||||
totalCount: 1,
|
||||
rows: [
|
||||
{
|
||||
id: 31,
|
||||
speechAt: '2024-05-10T00:00:00.000Z',
|
||||
summary: 'Pronunciamento sobre saúde regional.',
|
||||
officialTextUrl: 'https://camara.test/discurso-ilheus',
|
||||
mentionExcerpt: 'a saúde da região',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const research = mergeDossierResearch([
|
||||
|
|
@ -66,6 +79,14 @@ const research = mergeDossierResearch([
|
|||
sourceUrl: 'https://camara.test/titulo',
|
||||
sourceDate: '2026-09-10',
|
||||
},
|
||||
{
|
||||
id: 'era_c_defesas',
|
||||
position: 'Ensino superior',
|
||||
answer: 'Defende campus da UFBA na região',
|
||||
details: 'INC 1497/2023',
|
||||
sourceUrl: 'https://camara.test/inc-1497',
|
||||
sourceDate: '2023-09-22',
|
||||
},
|
||||
],
|
||||
news: [
|
||||
{
|
||||
|
|
@ -73,6 +94,7 @@ const research = mergeDossierResearch([
|
|||
url: 'https://jornal.test/materia',
|
||||
publishedAt: '2024-06-01',
|
||||
outlet: 'Jornal Local',
|
||||
summary: 'Resumo datado da matéria.',
|
||||
},
|
||||
],
|
||||
gaps: [],
|
||||
|
|
@ -91,22 +113,56 @@ const report = buildDossierReport({
|
|||
describe('renderDossierHtml', () => {
|
||||
const html = renderDossierHtml(report)
|
||||
|
||||
it('anchors every page for the overflow guard and navigability', () => {
|
||||
it('anchors every sheet for the overflow guard and navigability', () => {
|
||||
for (const anchor of [
|
||||
'capa',
|
||||
'trajetoria',
|
||||
'resumo',
|
||||
'sintese',
|
||||
'trajetoria',
|
||||
'era-a',
|
||||
'era-b',
|
||||
'era-c',
|
||||
'regiao',
|
||||
'defende',
|
||||
'lacunas',
|
||||
'noticias',
|
||||
'acervo',
|
||||
'fontes',
|
||||
]) {
|
||||
expect(html).toContain(`data-page="${anchor}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it('opens the document with O essencial, the facts and the index with page numbers', () => {
|
||||
expect(html).toContain('O essencial')
|
||||
expect(html).toContain('Fatos-chave')
|
||||
expect(html).toContain('index-list')
|
||||
expect(html).toContain('index-page')
|
||||
expect(html).toContain('<span class="index-page tabular">2</span>')
|
||||
})
|
||||
|
||||
it('reads the eras between themselves and prints the operational synthesis', () => {
|
||||
expect(html).toContain('Leitura entre eras')
|
||||
expect(html).toContain('Síntese operacional')
|
||||
expect(html).toContain('Concentração.')
|
||||
})
|
||||
|
||||
it('prints the position list of O que Solla defende and the missing-register line', () => {
|
||||
expect(html).toContain('O que Solla defende')
|
||||
expect(html).toContain('position-list')
|
||||
expect(html).toContain('Ensino superior')
|
||||
expect(html).toContain('Sem registro localizado.')
|
||||
})
|
||||
|
||||
it('is a sober document: no charts, cards, colored badges or image placeholders', () => {
|
||||
expect(html).not.toContain('chart-grid')
|
||||
expect(html).not.toContain('action-grid')
|
||||
expect(html).not.toContain('scope-cards')
|
||||
expect(html).not.toContain('asset-box')
|
||||
expect(html).not.toContain('NEEDS ASSET')
|
||||
expect(html).not.toContain('data-page="graficos"')
|
||||
})
|
||||
|
||||
it('escapes untrusted research text', () => {
|
||||
expect(html).not.toContain('<script>alert(1)</script>')
|
||||
expect(html).toContain('<script>')
|
||||
|
|
@ -118,17 +174,20 @@ describe('renderDossierHtml', () => {
|
|||
expect(html).toContain('https://exemplo.test/conquista')
|
||||
})
|
||||
|
||||
it('keeps the region rule and the region/polo badge', () => {
|
||||
expect(html).toContain('Região não é cidade')
|
||||
expect(html).toContain('scope-region')
|
||||
})
|
||||
|
||||
it('prints the defeso note', () => {
|
||||
it('keeps the non-summable rule and the defeso note', () => {
|
||||
expect(html).toContain('Não somar.')
|
||||
expect(html).toContain('Nota de defeso eleitoral 2026')
|
||||
})
|
||||
|
||||
it('renders the local honors only on their own era sheet (never on another era)', () => {
|
||||
expect((html.match(/Títulos, honrarias e vínculos locais/g) ?? []).length).toBe(1)
|
||||
it('renders the local honors once on the era-C sheet', () => {
|
||||
const eraC = html.slice(html.indexOf('data-page="era-c"'), html.indexOf('data-page="regiao"'))
|
||||
expect((eraC.match(/Título de cidadão honorário/g) ?? []).length).toBe(1)
|
||||
})
|
||||
|
||||
it('prints the news with the summary and the era usage column', () => {
|
||||
expect(html).toContain('Resumo datado da matéria.')
|
||||
expect(html).toContain('Uso no dossiê')
|
||||
expect(html).toContain('Era C')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -203,7 +262,7 @@ describe('renderDossierHtml — era continuation sheets', () => {
|
|||
const report = buildDossierReport({ snapshot, research: manyActions, generatedAt })
|
||||
const html = renderDossierHtml(report, { pack: { 'era:C': [1] } })
|
||||
|
||||
it('paginates era action cards into continuation sheets instead of dropping them', () => {
|
||||
it('paginates era list items into continuation sheets instead of dropping them', () => {
|
||||
expect(html).toContain('data-page="era-c"')
|
||||
expect(html).toContain('data-page="era-c-2"')
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
|
|
@ -212,12 +271,59 @@ describe('renderDossierHtml — era continuation sheets', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('renderDossierHtml — index fallback', () => {
|
||||
it('drops the page numbers when the resumo sheet overflows (indexMode labels)', () => {
|
||||
const html = renderDossierHtml(report, { indexMode: 'labels' })
|
||||
expect(html).toContain('index-list')
|
||||
expect(html).not.toContain('<span class="index-page tabular">2</span>')
|
||||
expect(html).toContain('<span class="index-page tabular">—</span>')
|
||||
})
|
||||
|
||||
it('keeps an omitted era in the index without a number or a page', () => {
|
||||
const withoutEraA = mergeDossierResearch([
|
||||
normalizeDossierResearchInput({
|
||||
municipalitySlug: 'ilheus',
|
||||
era: 'A',
|
||||
researchedAt: '2026-09-16T10:00:00.000Z',
|
||||
items: [],
|
||||
news: [],
|
||||
gaps: [],
|
||||
}),
|
||||
normalizeDossierResearchInput({
|
||||
municipalitySlug: 'ilheus',
|
||||
era: 'B',
|
||||
researchedAt: '2026-09-16T10:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
id: 'era_b_equipamentos',
|
||||
answer: '1 hospital regional',
|
||||
sourceUrl: 'https://saude.test/hospital',
|
||||
sourceDate: '2026-09-10',
|
||||
},
|
||||
],
|
||||
news: [],
|
||||
gaps: [],
|
||||
}),
|
||||
])
|
||||
const omittedReport = buildDossierReport({ snapshot, research: withoutEraA, generatedAt })
|
||||
const omittedHtml = renderDossierHtml(omittedReport)
|
||||
expect(omittedHtml).toContain('sem evidência nominal suficiente; consulte as lacunas')
|
||||
expect(omittedHtml).not.toContain('data-page="era-a"')
|
||||
const index = omittedHtml.slice(omittedHtml.indexOf('index-list'), omittedHtml.indexOf('</ol>'))
|
||||
expect(index).toContain('<span class="index-number"></span>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderDossierMd', () => {
|
||||
const md = renderDossierMd(report)
|
||||
|
||||
it('mirrors the sections and keeps the sources clickable', () => {
|
||||
expect(md).toContain('# Dossiê Solla por cidade — Ilhéus')
|
||||
expect(md).toContain('## Linha do tempo da carreira')
|
||||
expect(md).toContain('## Índice')
|
||||
expect(md).toContain('## O essencial')
|
||||
expect(md).toContain('## Leitura entre eras')
|
||||
expect(md).toContain('## Trajetória completa')
|
||||
expect(md).toContain('## O que Solla defende')
|
||||
expect(md).toContain('## Região / polo')
|
||||
expect(md).toContain('## Lacunas explícitas')
|
||||
expect(md).toContain('[fonte](https://exemplo.test/conquista)')
|
||||
|
|
@ -226,6 +332,12 @@ describe('renderDossierMd', () => {
|
|||
it('carries the defeso note', () => {
|
||||
expect(md).toContain('defeso eleitoral')
|
||||
})
|
||||
|
||||
it('links every index entry to an anchor that exists in the companion', () => {
|
||||
const links = [...md.matchAll(/\]\(#([^)]+)\)/g)].map((match) => match[1])
|
||||
expect(links.length).toBeGreaterThan(0)
|
||||
for (const anchor of links) expect(md).toContain(`<a id="${anchor}"></a>`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderBulletinHtml', () => {
|
||||
|
|
@ -249,8 +361,14 @@ describe('renderBulletinHtml', () => {
|
|||
expect(html).not.toContain('portaldatransparencia.test')
|
||||
})
|
||||
|
||||
it('prints the trajectory and the defeso note', () => {
|
||||
expect(html).toContain('Desde 1999')
|
||||
it('prints the highlights as a list and the four-period trajectory', () => {
|
||||
expect(html).toContain('bulletin-list')
|
||||
expect(html).toContain('period-list')
|
||||
expect(html).toContain('Defeso eleitoral')
|
||||
})
|
||||
|
||||
it('prints the defense block from the ledger', () => {
|
||||
expect(html).toContain('bulletin-defense-list')
|
||||
expect(html).toContain('Defende campus da UFBA na região')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,15 +43,23 @@ describe('normalizeDossierResearchInput', () => {
|
|||
expect(research.items.every((item) => item.sphere === 'municipio')).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes the optional per-item summary without changing the source contract (C188)', () => {
|
||||
const items = dossierChecklistForEra('C').map((item) => validItem(item.id))
|
||||
items[0] = validItem(items[0].id, { summary: ' Recurso de R$ 1 mi empenhado em 2024. ' })
|
||||
items[1] = validItem(items[1].id, { summary: ' ' })
|
||||
const research = normalizeDossierResearchInput(eraResearch('C', { items }))
|
||||
expect(research.items[0].summary).toBe('Recurso de R$ 1 mi empenhado em 2024.')
|
||||
expect(research.items[0].answer).toBe(`Resposta de ${items[0].id}`)
|
||||
expect(research.items[1].summary).toBeNull()
|
||||
expect(research.gaps).toEqual([])
|
||||
it('marks the defense checklist items with kind and normalizes the position label (C209)', () => {
|
||||
const research = normalizeDossierResearchInput(
|
||||
eraResearch('C', {
|
||||
items: [
|
||||
validItem('era_c_emendas'),
|
||||
validItem('era_c_defesas', {
|
||||
position: ' Ensino superior ',
|
||||
answer: 'Defende campus da UFBA na região',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
const defense = research.items.find((item) => item.id === 'era_c_defesas')
|
||||
const evidence = research.items.find((item) => item.id === 'era_c_emendas')
|
||||
expect(defense).toMatchObject({ kind: 'defense', position: 'Ensino superior' })
|
||||
expect(evidence?.kind).toBe('evidence')
|
||||
expect(evidence?.position).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes the reformulated brief and drops it when it has no title', () => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { THEME_UNIT, themeSubjectPhrase } from '../../scripts/lib/dossieUnit.mjs
|
|||
// C190: theme/area content model + render. Grounds the third recorte: area
|
||||
// identity (label + canonical value), `area|segmento|rede` never summed, per-row
|
||||
// phase/source, empty-era state instead of silent omission, and a one-page
|
||||
// boletim that inherits only sourced facts.
|
||||
// boletim that inherits only sourced facts. C209: sober redesign.
|
||||
|
||||
const generatedAt = new Date('2026-09-18T12:00:00.000Z')
|
||||
|
||||
|
|
@ -91,6 +91,10 @@ const research = mergeDossierResearch(
|
|||
numbers: [{ label: 'Emenda', value: 'R$ 5,0 mi', year: '2024', phase: 'empenhado' }],
|
||||
}),
|
||||
item('era_c_titulos', 'C', { answer: 'Homenagem da área' }),
|
||||
item('era_c_defesas', 'C', {
|
||||
position: 'Educação básica',
|
||||
answer: 'Defende o financiamento da educação básica',
|
||||
}),
|
||||
]),
|
||||
{ unit: THEME_UNIT },
|
||||
),
|
||||
|
|
@ -165,13 +169,6 @@ describe('buildDossierReport (theme unit)', () => {
|
|||
expect(listFor('network')?.total).toBe(1)
|
||||
})
|
||||
|
||||
it('declares overflow instead of truncating silently (C188 rule)', () => {
|
||||
for (const list of report.reach.lists) {
|
||||
expect(list).toMatchObject({ total: expect.any(Number), omitted: expect.any(Number) })
|
||||
}
|
||||
expect(report.page1.deliveries.omitted).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('shows every sourced item — theme lists are never capped', () => {
|
||||
const manyItems = [
|
||||
item('era_c_emendas', 'C', { sphere: 'segmento' }),
|
||||
|
|
@ -224,7 +221,7 @@ describe('buildDossierReport (theme unit)', () => {
|
|||
expect(moneySynthesis.lines.join(' ')).toContain('R$ 1,2 mi pago')
|
||||
const html = renderDossierHtml(moneyReport)
|
||||
expect(html).toContain('R$ 1,2 mi')
|
||||
expect(html).toContain('Recursos com execução por ano')
|
||||
expect(html).toContain('Valores localizados, sem consolidar fases')
|
||||
})
|
||||
|
||||
it('derives bulletinFacts only from sourced items', () => {
|
||||
|
|
@ -249,15 +246,15 @@ describe('renderDossierHtml/Md (theme unit)', () => {
|
|||
'capa',
|
||||
'resumo',
|
||||
'sintese',
|
||||
'graficos',
|
||||
'trajetoria',
|
||||
'era-a',
|
||||
'era-b',
|
||||
'era-c',
|
||||
'abrangencia',
|
||||
'abrangencia-area',
|
||||
'abrangencia-segmento',
|
||||
'abrangencia-rede',
|
||||
'defende',
|
||||
'lacunas',
|
||||
'noticias',
|
||||
'acervo',
|
||||
'fontes',
|
||||
]) {
|
||||
expect(html).toContain(`data-page="${anchor}"`)
|
||||
|
|
@ -267,20 +264,21 @@ describe('renderDossierHtml/Md (theme unit)', () => {
|
|||
expect(html).toContain('Homenagem da área')
|
||||
})
|
||||
|
||||
it('prints the theme acervo scene with stats, canonical columns and the scope alert', () => {
|
||||
expect(html).toContain('acervo-stat-grid')
|
||||
expect(html).toContain('Universo recuperado')
|
||||
expect(html).toContain('Amostra exibida')
|
||||
it('prints the theme acervo scene with the canonical columns and the scope alert', () => {
|
||||
expect(html).toContain('Tema canônico')
|
||||
expect(html).toContain('Trecho/contexto')
|
||||
expect(html).toContain('<code>educacao</code>')
|
||||
// The editorial label carries the column; the canonical token lives in the
|
||||
// identification table, never concatenated to the label.
|
||||
expect(html).toContain('>Educação<')
|
||||
expect(html).toContain('<dt>Token</dt><dd>educacao</dd>')
|
||||
expect(html).not.toContain('Educação educacao')
|
||||
expect(html).toContain(
|
||||
'O tema prova pertinência ao recorte do acervo; não prova por si só entrega',
|
||||
)
|
||||
expect(html).not.toContain('acervo-stat-grid')
|
||||
})
|
||||
|
||||
it('prints the theme reach scene with the five columns and the ledger year', () => {
|
||||
expect(html).toContain('document-table--reach')
|
||||
expect(html).toContain('Era / ano')
|
||||
expect(html).toContain('Valor / fase')
|
||||
expect(html).toContain('Evidência temática')
|
||||
|
|
@ -291,7 +289,7 @@ describe('renderDossierHtml/Md (theme unit)', () => {
|
|||
expect(html).toContain('Já consultado')
|
||||
expect(html).toContain('Próxima busca')
|
||||
expect(html).toContain('prioridade')
|
||||
expect(html).toContain('<span class="phase">aberta</span>')
|
||||
expect(html).toContain('aberta')
|
||||
expect(html).toContain(
|
||||
'Portais que não filtram por área não autorizam atribuição. Sem fonte temática, a linha permanece lacuna — nunca zero.',
|
||||
)
|
||||
|
|
@ -305,14 +303,12 @@ describe('renderDossierHtml/Md (theme unit)', () => {
|
|||
expect(html).toContain('Não filtram por área')
|
||||
})
|
||||
|
||||
it('uses the theme synthesis labels and the pela/pelo subject phrase', () => {
|
||||
expect(html).toContain('Tipos de atuação com mais registros')
|
||||
expect(html).toContain('Pontos com fonte por ano')
|
||||
it('uses the theme subject phrase on the cover and the bulletin', () => {
|
||||
expect(html).toContain('O que Jorge Solla fez pela Educação')
|
||||
expect(html).toContain('pela Educação ao longo da carreira')
|
||||
})
|
||||
|
||||
it('prints the synthesis lines and the consolidated charts', () => {
|
||||
it('prints the between-eras reading, never the old charts', () => {
|
||||
const synthesis = report.synthesis
|
||||
expect(synthesis).toBeTruthy()
|
||||
if (!synthesis) return
|
||||
|
|
@ -328,11 +324,11 @@ describe('renderDossierHtml/Md (theme unit)', () => {
|
|||
)
|
||||
expect(sphereTotal).toBe(synthesis.totals.items)
|
||||
expect(html).toContain('data-page="sintese"')
|
||||
expect(html).toContain('data-page="graficos"')
|
||||
expect(html).toContain('Síntese do que foi localizado')
|
||||
expect(html).toContain('Gráficos consolidados')
|
||||
expect(html).toContain('chart-grid')
|
||||
expect(md).toContain('## Síntese do que foi localizado')
|
||||
expect(html).toContain('Leitura entre eras')
|
||||
expect(html).not.toContain('data-page="graficos"')
|
||||
expect(html).not.toContain('Gráficos consolidados')
|
||||
expect(html).not.toContain('chart-grid')
|
||||
expect(md).toContain('## Leitura entre eras')
|
||||
})
|
||||
|
||||
it('flows across continuation sheets when the pack plan splits a section', () => {
|
||||
|
|
@ -369,12 +365,12 @@ describe('renderDossierHtml/Md (theme unit)', () => {
|
|||
unit: THEME_UNIT,
|
||||
})
|
||||
const bigHtml = renderDossierHtml(bigReport, {
|
||||
pack: { 'era:C': [1], 'scope:segmento': [1], news: [1] },
|
||||
pack: { 'era:C': [1], scope: [1], news: [1] },
|
||||
})
|
||||
expect(bigHtml).toContain('data-page="era-c"')
|
||||
expect(bigHtml).toContain('data-page="era-c-2"')
|
||||
expect(bigHtml).toContain('data-page="abrangencia-segmento"')
|
||||
expect(bigHtml).toContain('data-page="abrangencia-segmento-2"')
|
||||
expect(bigHtml).toContain('data-page="abrangencia"')
|
||||
expect(bigHtml).toContain('data-page="abrangencia-2"')
|
||||
expect(bigHtml).toContain('data-page="noticias"')
|
||||
expect(bigHtml).toContain('data-page="noticias-2"')
|
||||
expect(bigHtml).toContain('continuação')
|
||||
|
|
@ -383,15 +379,15 @@ describe('renderDossierHtml/Md (theme unit)', () => {
|
|||
expect(anchors.length).toBe(bigReport.meta.pageTotal)
|
||||
})
|
||||
|
||||
it('ports the theme vocabulary and identity badges', () => {
|
||||
it('ports the theme vocabulary and the sober classes', () => {
|
||||
expect(html).toContain('Dossiê temático')
|
||||
expect(html).toContain('identity-badge')
|
||||
expect(html).toContain('scope-sector')
|
||||
expect(html).toContain('scope-network')
|
||||
expect(html).toContain('phase-paid')
|
||||
expect(html).toContain('phase-pending')
|
||||
expect(html).toContain('Segmento/rede não é a área')
|
||||
expect(html).toContain('index-list')
|
||||
expect(html).toContain('position-list')
|
||||
expect(html).toContain('phase-text')
|
||||
expect(html).toContain('Uma ação dirigida a um segmento ou a uma rede')
|
||||
expect(html).toContain('Segmento e rede não são somados à área')
|
||||
expect(html).not.toContain('identity-badge')
|
||||
expect(html).not.toContain('scope-cards')
|
||||
})
|
||||
|
||||
it('prints the defeso note', () => {
|
||||
|
|
@ -441,24 +437,20 @@ describe('buildBulletin (theme unit)', () => {
|
|||
})
|
||||
const html = renderBulletinHtml(bulletin)
|
||||
|
||||
it('is a single A4 page with the model label and identity pills', () => {
|
||||
it('is a single A4 page with the model label and the subject phrase', () => {
|
||||
expect(html).toContain('data-page="boletim"')
|
||||
expect((html.match(/data-page="boletim"/g) ?? []).length).toBe(1)
|
||||
expect(html).toContain('aria-label="Modelo de boletim informativo temático de uma página"')
|
||||
expect(html).toContain('Modelo — insumo interno')
|
||||
expect(html).toContain('identity-pill')
|
||||
expect(html).toContain('Educação')
|
||||
expect(html).toContain('O que Jorge Solla fez pela <span class="accent">Educação</span>')
|
||||
expect(html).toContain('O que Jorge Solla fez pela <span>Educação</span>')
|
||||
})
|
||||
|
||||
it('renders the theme pills with the canonical token in mono and the taxonomy note', () => {
|
||||
expect(html).toContain('<span class="identity-pill">Área</span>')
|
||||
expect(html).toContain('<span class="identity-pill identity-pill--code">educacao</span>')
|
||||
expect(html).toContain('<span class="identity-pill">Taxonomia do acervo</span>')
|
||||
expect(html).toContain('.identity-pill--code')
|
||||
it('prints the defense block and the period list', () => {
|
||||
expect(html).toContain('bulletin-defense-list')
|
||||
expect(html).toContain('Defende o financiamento da educação básica')
|
||||
expect(html).toContain('period-list')
|
||||
})
|
||||
|
||||
it('falls back to the year and then to a textual card (never a fake zero)', () => {
|
||||
it('falls back to the year and never prints a fake zero', () => {
|
||||
const fact = (id: string, overrides: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
era: 'C',
|
||||
|
|
@ -482,27 +474,24 @@ describe('buildBulletin (theme unit)', () => {
|
|||
})
|
||||
const fallbackHtml = renderBulletinHtml(fallback)
|
||||
expect(fallback.highlights[0]).toMatchObject({ number: '2019' })
|
||||
expect(fallback.highlights[1]).toMatchObject({ number: null, textual: true })
|
||||
expect(fallbackHtml).toContain('highlight-card--textual')
|
||||
expect(fallback.highlights[1]).toMatchObject({ number: null })
|
||||
expect(fallbackHtml).toContain('2019')
|
||||
expect(fallbackHtml).not.toContain('>0<')
|
||||
})
|
||||
|
||||
it('declares no sources and carries the defeso band', () => {
|
||||
it('declares no sources and carries the defeso footer', () => {
|
||||
expect(html).not.toContain('source-link')
|
||||
expect(html).not.toContain('Fonte:')
|
||||
expect(html).toContain('defeso-band')
|
||||
expect(html).toContain('sem CTA')
|
||||
expect(html).toContain('Defeso eleitoral')
|
||||
expect(html).toContain('área, segmento e rede não são somados')
|
||||
})
|
||||
|
||||
it('keeps the phase next to a valued number (empenho ≠ pagamento)', () => {
|
||||
expect(html).toContain('highlight-phase')
|
||||
expect(html).toContain('phase-pending')
|
||||
expect(html).toContain('empenhado')
|
||||
expect(html).toContain('phase-paid')
|
||||
expect(html).toContain('pago')
|
||||
})
|
||||
|
||||
it('renders the sparse variant when there are fewer than three facts', () => {
|
||||
it('renders the sparse variant without filling the page', () => {
|
||||
const sparse = buildBulletin({
|
||||
facts: [report.bulletinFacts[0]],
|
||||
identity,
|
||||
|
|
@ -511,7 +500,6 @@ describe('buildBulletin (theme unit)', () => {
|
|||
})
|
||||
const sparseHtml = renderBulletinHtml(sparse)
|
||||
expect(sparse.sparse).toBe(true)
|
||||
expect(sparseHtml).toContain('lacuna-panel')
|
||||
expect(sparseHtml).toContain('A página termina com espaço')
|
||||
expect(sparseHtml).toContain('entrega exclusiva da área')
|
||||
})
|
||||
|
|
@ -553,7 +541,8 @@ describe('buildBulletin (theme unit, one-page fit)', () => {
|
|||
expect(full.moreItems).toHaveLength(0)
|
||||
expect(fitted.highlights).toHaveLength(2)
|
||||
expect(fitted.moreItems).toHaveLength(0)
|
||||
expect(fitted.factsPrinted).toBe(2)
|
||||
// Two printed highlights plus the one defense (never dropped by the cap).
|
||||
expect(fitted.factsPrinted).toBe(3)
|
||||
expect(fitted.factsPrintable).toBe(facts.length)
|
||||
expect(fitted.factsRemaining).toBeGreaterThan(full.factsRemaining)
|
||||
expect(renderBulletinHtml(fitted)).toContain(
|
||||
|
|
|
|||
|
|
@ -106,19 +106,20 @@ describe('dossiê temático sem caps + redação (revisão 2026-09-18)', () => {
|
|||
expect(skill, 'continuation sheets carry the header').toContain('continuação N')
|
||||
})
|
||||
|
||||
it('documents the opening letter, the synthesis and the charts', () => {
|
||||
expect(skill).toContain('A contribuição (carta)')
|
||||
expect(skill).toContain('**Síntese**')
|
||||
expect(skill).toContain('Gráficos consolidados')
|
||||
expect(skill, 'the era opens with a consolidation paragraph').toMatch(
|
||||
/O que esta era\s+entrega/,
|
||||
)
|
||||
it('documents the analysis-first sections and the sober design', () => {
|
||||
expect(skill).toContain('O essencial')
|
||||
expect(skill).toContain('Leitura entre eras')
|
||||
expect(skill).toContain('O que Solla defende')
|
||||
expect(skill, 'the era opens with its reading').toContain('Leitura da era')
|
||||
expect(skill, 'the charts were retired').not.toContain('Gráficos consolidados')
|
||||
})
|
||||
|
||||
it('pins the narrative contract and the writer receipt', () => {
|
||||
expect(skill).toContain('## Recibo do redator')
|
||||
expect(skill).toContain('"themeSlug": "educacao"')
|
||||
expect(skill, 'the narrative file carries the era paragraphs').toContain('"eras": {')
|
||||
expect(skill, 'the narrative may carry the between-eras bullets').toContain('"betweenEras"')
|
||||
expect(skill, 'the defense checklist item is documented').toContain('era_c_defesas')
|
||||
expect(skill, 'the orchestrator audits the citations').toMatch(/audita/i)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue