fix(C190): boletim de uma página cabe por medição (painel de acervo + fit)

O run real (Educação) entregava o dossiê mas o boletim media 3502px vs 1133px úteis: os 15 fatos do acervo (sphere area) dominavam os destaques com o summary integral e não havia contador nem fit no one-pager.

- dossieBlocks marca as falas do acervo com sourcePanel: true; buildBulletin as mantém contadas no "e mais N" e fora das vagas impressas (destaques/E mais ficam com findings com fonte)
- buildBulletin ganha printLimit; emitHtmlPairPdf ganha onBulletinOverflow (espelho do onDossierOverflow do C188) e o builder do tema re-renderiza com 2 fatos a menos até caber, fail-closed
- boletim subject (tema/instituição) ganha o contador C188 que só o município tinha, com copy.remaining por descritor
- contrato do tema documenta brief/summary (C188) na skill e no subagente researcher
This commit is contained in:
Francisco Solla 2026-09-18 18:46:31 -03:00
parent e5e1ae7ee8
commit 9207553863
10 changed files with 188 additions and 44 deletions

View file

@ -194,7 +194,12 @@ 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)",
"details": "opcional; aceita {{fonte}} / {{fonte:N}}",
"brief": { // obrigatório no item publicado: copy reformulada, curta
"title": "≤80 chars — manchete (o quê + onde)",
"note": "≤120 chars — 1 frase de contexto; opcional"
},
"sphere": "area", // area | segmento | rede (default area)
"numbers": [ // opcional
{ "label": "Emenda", "value": "R$ 5,0 mi", "year": "2024", "phase": "empenhado" }
@ -229,6 +234,17 @@ 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.
`<slug>.narrative.json` (opcional; escrito pelo redator, auditado pelo
orquestrador):
@ -299,7 +315,10 @@ nenhum número, data, nome ou órgão novo.
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).
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.
- **Sem declaração de fontes** (as fontes vivem exclusivamente no dossiê), sem
CTA de campanha; herda **apenas** fatos com fonte do dossiê.

View file

@ -18,7 +18,10 @@ slug da área na taxonomia e coordena as demais eras/áreas em paralelo.
2. Fazer a **pesquisa web datada** da era e escrever
`data/dossie-solla-tema/<slug>.<era>.research.json` no contrato da skill
(ex.: `educacao.c.research.json`) — item sem `sourceUrl`/`sourceDate` não vai
para o arquivo (vira lacuna explícita). Use o checklist da era e marque
para o arquivo (vira lacuna explícita). Todo item publicado leva `brief`
(`title` ≤ 80, `note` ≤ 120 chars) e, quando ajudar o resumo, `summary`
curto e auto-contido (C188): é a copy que o PDF e o boletim imprimem; sem ela
o texto integral pode estourar a página. Use o checklist da era e marque
`sphere` (`area`/`segmento`/`rede`); segmento e rede **nunca** são somados à
área.
3. Devolver **apenas o recibo curto** (seção "Recibo do researcher" da skill):

View file

@ -0,0 +1 @@
**C190-fit — boletim de uma página cabe por medição (2026-09-18):** o primeiro run real do `/dossie-solla-tema` (Educação) entregou o dossiê mas **não** o boletim: com o acervo real (15 falas) o one-pager media 3502px contra 1133px úteis. Três correções no dono, sem geminar pipeline: (1) **acervo é painel de fontes, não achado** — os fatos de fala entram no `bulletinFacts` marcados `sourcePanel: true` (`dossieBlocks`) e o `buildBulletin` os mantém **contados** no "e mais N fatos com fonte" mas **fora das vagas impressas** (destaques e "E mais"), que passam a ser só findings com fonte; (2) **fit por medição** — `emitHtmlPairPdf` ganha `onBulletinOverflow` (espelho do `onDossierOverflow` do C188), e o builder do tema re-renderiza com `printLimit` dois fatos menor até a folha caber, sempre declarando o resto (fail-closed se o callback não encolher); (3) **contador C188 no boletim `subject`** — tema/instituição não exibiam o "e mais N" que o C188 exige (o renderer de município já tinha); o rodapé ganha `copy.remaining` por descritor ("no dossiê da área/da instituição"). O contrato do tema (skill + subagente) passa a documentar `brief` (title ≤80 / note ≤120, obrigatório no item publicado) e `summary` (C188) — a copy impressa que faltava deixava o `answer`/`details` integrais irem para as páginas. Specs novos em `dossieTheme.unit.spec.ts` (painel contado fora dos destaques; `printLimit` encolhe e declara o resto); specs existentes de município/instituição seguem verdes (testes 3772/3772). Sem schema, migration, URL pública, Consent ou LGPD — scripts/skill/testes apenas.

View file

@ -148,25 +148,29 @@ const report = buildDossierReport({
unit: THEME_UNIT,
narrative,
})
const bulletin = buildBulletin({
facts: report.bulletinFacts,
identity: {
name: report.meta.subjectName,
badges: report.meta.identityBadges,
value: report.meta.identity?.value ?? null,
taxonomyNote: report.meta.identity?.taxonomyNote ?? null,
},
unit: THEME_UNIT,
generatedAt,
})
const bulletinIdentity = {
name: report.meta.subjectName,
badges: report.meta.identityBadges,
value: report.meta.identity?.value ?? null,
taxonomyNote: report.meta.identity?.taxonomyNote ?? null,
}
const buildThemeBulletin = (printLimit = null) =>
buildBulletin({
facts: report.bulletinFacts,
identity: bulletinIdentity,
unit: THEME_UNIT,
generatedAt,
printLimit,
})
let bulletin = buildThemeBulletin()
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`)
@ -229,7 +233,18 @@ try {
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.
onBulletinOverflow: async () => {
const next = bulletin.factsPrinted - 2
if (bulletin.factsPrinted === 0 || next < 0) return null
bulletin = buildThemeBulletin(next)
bulletinHtml = renderBulletinHtml(bulletin)
return bulletinHtml
},
})
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml)
} catch (error) {
die(error instanceof Error ? error.message : String(error))
} finally {

View file

@ -19,6 +19,14 @@ export const A4_WIDTH_PX = Math.round(210 * MM_TO_PX)
export const PAGE_FIT_TOLERANCE_PX = 10
export const A4_PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX
/**
* Bounded re-renders of the one-page boletim while an `onBulletinOverflow`
* callback keeps handing back a shorter document (C190). Each pass drops at
* least one printed fact, so the cap only exists to fail closed on a callback
* that stops shrinking.
*/
const MAX_BULLETIN_FIT_ATTEMPTS = 24
export const launchPdfBrowser = () => chromium.launch()
/** Every `[data-page]` taller than the budget, in document order. */
@ -166,13 +174,19 @@ const printA4Pdf = (page, outPath) =>
/**
* One browser run, two HTML documents: sets each document, asserts its anchor
* exists, guards the fit and emits the PDF. Shared by the C186 (city) and C187
* (institution) builders — the pages differ, the emit contract does not.
* exists, guards the fit and emits the PDF. Shared by the C186 (city), C187
* (institution) and C190 (theme) builders — the pages differ, the emit
* contract does not.
*
* C188 fit fallback: when `onDossierOverflow` is given, only the page-1 sheet
* (`resumoOnly`) is measured first; if it overflows, the callback rebuilds the
* document (pointer lines, never "…") and the fit is checked again — still
* fail-closed if even the pointer does not fit.
*
* C190 bulletin fit: the one-pager is measured and, when `onBulletinOverflow`
* is given, the callback rebuilds it with fewer printed facts (the remainder
* is declared in the "e mais N" counter) until the A4 holds — still fail-closed
* when the callback cannot shrink any further.
*/
export const emitHtmlPairPdf = async (
browser,
@ -182,6 +196,7 @@ export const emitHtmlPairPdf = async (
dossierPdf,
bulletinPdf,
onDossierOverflow = null,
onBulletinOverflow = null,
resumoOnly = false,
},
) => {
@ -226,17 +241,35 @@ export const emitHtmlPairPdf = async (
}
await printA4Pdf(page, dossierPdf)
await page.setContent(bulletinHtml, { waitUntil: 'load' })
let currentBulletinHtml = bulletinHtml
await page.setContent(currentBulletinHtml, { waitUntil: 'load' })
await page.emulateMedia({ media: 'print' })
const hasBulletin = await page.evaluate(() =>
Boolean(document.querySelector('[data-page="boletim"]')),
)
if (!hasBulletin) throw new Error('Bloco do boletim ausente no HTML — renderer quebrado.')
assertPageFits(
await measurePageOverflows(page, A4_PAGE_BUDGET_PX),
'O boletim',
'Corte itens/caps — o boletim TEM de caber em uma página.',
)
for (let attempt = 0; ; attempt += 1) {
const hasBulletin = await page.evaluate(() =>
Boolean(document.querySelector('[data-page="boletim"]')),
)
if (!hasBulletin) throw new Error('Bloco do boletim ausente no HTML — renderer quebrado.')
const overflows = await measurePageOverflows(page, A4_PAGE_BUDGET_PX)
if (overflows.length === 0) break
if (!onBulletinOverflow || attempt >= MAX_BULLETIN_FIT_ATTEMPTS) {
assertPageFits(
overflows,
'O boletim',
'Corte itens/caps — o boletim TEM de caber em uma página.',
)
}
const rebuilt = await onBulletinOverflow()
if (!rebuilt) {
assertPageFits(
overflows,
'O boletim',
'Corte itens/caps — o boletim TEM de caber em uma página.',
)
}
currentBulletinHtml = rebuilt
await page.setContent(currentBulletinHtml, { waitUntil: 'load' })
await page.emulateMedia({ media: 'print' })
}
await printA4Pdf(page, bulletinPdf)
await page.close()

View file

@ -419,7 +419,11 @@ const buildMunicipalityReport = ({
sourceUrl: row.sourceUrl,
sourceDate: row.sourceDate,
})),
...speechFacts.map((fact) => ({ ...fact, headline: stripInlineSources(fact.headline) })),
...speechFacts.map((fact) => ({
...fact,
headline: stripInlineSources(fact.headline),
sourcePanel: true,
})),
]
const generatedAtLabel = formatDateTimeBr(generatedAt)
@ -557,7 +561,9 @@ const uncapped = (list) => ({ items: list, total: list.length, omitted: 0 })
/**
* Speeches from the internal acervo (read-only snapshot) that the research did
* not already carry: they are sourced evidence for the recorte, so they feed
* the bulletin ledger too — never a second, unsourced fact.
* the bulletin ledger too — never a second, unsourced fact. They enter as a
* declared source panel (`sourcePanel: true`): counted in the one-pager, never
* displacing a finding from the printed slots.
*/
const speechFactsFromRows = (snapshot, sphere) =>
(snapshot.speeches?.rows ?? [])
@ -1068,7 +1074,13 @@ const buildSubjectReport = (params) => {
sourceUrl: item.sourceUrl,
sourceDate: item.sourceDate,
}))
.concat(speechFacts.map((fact) => ({ ...fact, headline: stripInlineSources(fact.headline) })))
.concat(
speechFacts.map((fact) => ({
...fact,
headline: stripInlineSources(fact.headline),
sourcePanel: true,
})),
)
const generatedAtLabel = formatDateTimeBr(generatedAt)
const readAtLabel = snapshot.meta?.readAt ? formatDateTimeBr(snapshot.meta.readAt) : '—'

View file

@ -30,12 +30,13 @@ 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 }>,
* 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 }>,
* municipality?: string,
* region?: string | null,
* identity?: { name: string, badges?: string[] } | null,
* unit?: any,
* generatedAt?: Date,
* printLimit?: number | null,
* }} params
*/
export const buildBulletin = ({
@ -45,11 +46,20 @@ export const buildBulletin = ({
identity = null,
unit,
generatedAt = new Date(),
printLimit = null,
}) => {
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)
const target =
printLimit === null
? BULLETIN_HIGHLIGHT_LIMIT + (resolvedUnit.bulletinMoreLimit ?? BULLETIN_MORE_LIMIT)
: Math.max(0, printLimit)
const printed = printable.slice(0, target)
const subjectName = municipality ?? identity?.name ?? '—'
const highlights = ordered.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => {
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).
const number = resolvedUnit.bulletinNumberFallback
@ -65,20 +75,15 @@ export const buildBulletin = ({
phaseLabel: fact.value ? dossierPhaseLabel(fact.phase) : null,
}
})
const moreItems = ordered
.slice(
BULLETIN_HIGHLIGHT_LIMIT,
BULLETIN_HIGHLIGHT_LIMIT + (resolvedUnit.bulletinMoreLimit ?? BULLETIN_MORE_LIMIT),
)
.map((fact) => ({
label: fact.brief?.title ?? fact.headline,
detail: fact.brief?.note ?? fact.detail ?? fact.value ?? fact.area,
}))
const moreItems = printed.slice(BULLETIN_HIGHLIGHT_LIMIT).map((fact) => ({
label: fact.brief?.title ?? fact.headline,
detail: fact.brief?.note ?? fact.detail ?? fact.value ?? fact.area,
}))
const shown = highlights.length + moreItems.length
return {
unit: resolvedUnit,
sparse: ordered.length < 3,
sparse: printable.length < 3,
meta: {
title: resolvedUnit.bulletinTitle,
kicker: resolvedUnit.bulletinKicker ?? 'Boletim informativo · atuação pública',
@ -95,9 +100,12 @@ export const buildBulletin = ({
},
highlights,
moreItems,
// Never a silent drop (C188): the facts beyond the two caps are counted so
// the one-page boletim states how much of the sourced set it left out.
// Never a silent drop (C188): the facts beyond the two 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,
factsPrinted: shown,
factsRemaining: ordered.length - shown,
timeline: BULLETIN_TIMELINE_STEPS,
}

View file

@ -229,6 +229,7 @@ const SUBJECT_BULLETIN_CSS = `
.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; }
@ -341,6 +342,11 @@ const renderSubjectBulletinHtml = (bulletin) => {
? `<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

View file

@ -206,6 +206,7 @@ export const INSTITUTION_UNIT = {
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',
@ -411,6 +412,7 @@ export const THEME_UNIT = {
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',

View file

@ -516,3 +516,48 @@ describe('buildBulletin (theme unit)', () => {
expect(sparseHtml).toContain('entrega exclusiva da área')
})
})
describe('buildBulletin (theme unit, one-page fit)', () => {
const identity = {
name: report.meta.subjectName,
badges: report.meta.identityBadges,
value: report.meta.identity?.value ?? null,
taxonomyNote: report.meta.identity?.taxonomyNote ?? null,
}
const fala = report.bulletinFacts.find((fact) => String(fact.id).startsWith('fala-'))
it('marks the acervo as a declared source panel, out of the printed slots', () => {
expect(fala).toMatchObject({ sourcePanel: true })
const bulletin = buildBulletin({
facts: fala ? [fala] : [],
identity,
unit: THEME_UNIT,
generatedAt,
})
expect(bulletin.highlights).toHaveLength(0)
expect(bulletin.moreItems).toHaveLength(0)
expect(bulletin.factsTotal).toBe(1)
expect(bulletin.factsPrinted).toBe(0)
expect(bulletin.factsRemaining).toBe(1)
expect(bulletin.sparse).toBe(true)
const html = renderBulletinHtml(bulletin)
expect(html).not.toContain(fala?.headline ?? '')
expect(html).toContain('e mais 1 fato com fonte no dossiê da área')
})
it('shrinks the printed set on printLimit and declares every remaining fact', () => {
const facts = report.bulletinFacts.filter((fact) => !fact.sourcePanel)
const full = buildBulletin({ facts, identity, unit: THEME_UNIT, generatedAt })
const fitted = buildBulletin({ facts, identity, unit: THEME_UNIT, generatedAt, printLimit: 2 })
expect(full.highlights).toHaveLength(6)
expect(full.moreItems).toHaveLength(0)
expect(fitted.highlights).toHaveLength(2)
expect(fitted.moreItems).toHaveLength(0)
expect(fitted.factsPrinted).toBe(2)
expect(fitted.factsPrintable).toBe(facts.length)
expect(fitted.factsRemaining).toBeGreaterThan(full.factsRemaining)
expect(renderBulletinHtml(fitted)).toContain(
`e mais ${fitted.factsRemaining} fatos com fonte no dossiê da área`,
)
})
})