teqo-backup/scripts/build-dossie-solla-cidade.mjs
Francisco Solla 5644a9f947
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
feat(C186/C187): dossiê corrente sem caps + redação, síntese e gráficos (paridade cidade × instituição)
Revisão de produto a partir do uso real (UFBA): as seções deixam de ser
capadas e fluem por quantas folhas precisarem, com packing medido
(probe → grow/shrink por folha, scripts/lib/dossiePack.mjs) e guarda de A4
no emit; o dossiê ganha redação de abertura (narrative.json escrito por
sub-agente e auditado pelo orquestrador), parágrafo de consolidação por
era, páginas de síntese e gráficos (recursos com fase, abrangência,
trajetória) e acervo como amostra declarada. O dono (dossieBlocks/
dossieRender/dossieUnit/buildPdf) foi generalizado e o ramo cidade adotou
as mesmas decisões; skills, comandos, agentes redatores, specs e designs
hi-fi re-sincronizados.
2026-09-17 23:00:08 -03:00

325 lines
12 KiB
JavaScript

/**
* Dossiê + boletim builder (C186): snapshot + per-era research (+ emendas,
* Câmara, IBGE) → dossiê PDF A4 + `.md` and the one-page boletim PDF.
*
* Runs on the workstation: no database access, no persistence. Public sources
* are read at generation time (or replayed via `--emendas=<json>`). One browser
* run produces both PDFs, each with its own page-fit guard.
*
* Usage:
* NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-cidade.mjs \
* --snapshot=data/dossie-solla-cidade/<slug>.snapshot.json \
* --research-dir=data/dossie-solla-cidade \
* --out-dir=docs/research/dossie-solla-cidade
*/
import { execFileSync } from 'node:child_process'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
A4_PAGE_BUDGET_PX,
emitHtmlPairPdf,
launchPdfBrowser,
measureDocumentPackProbe,
measureDocumentSheets,
} from './lib/buildPdf.mjs'
import { dieWithLabel, isTruthyEnv, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
import { buildDossierReport } from './lib/dossieBlocks.mjs'
import { buildBulletin } from './lib/dossieBulletin.mjs'
import { renderBulletinHtml } from './lib/dossieBulletinRender.mjs'
import { CAMARA_DEFAULT_FROM, fetchCamaraActivity } from './lib/dossieCamara.mjs'
import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs'
import { fetchHealthData } from './lib/dossieHealthData.mjs'
import { adjustPackPlan, packProbeSections } from './lib/dossiePack.mjs'
import { dossierPackAnchors, renderDossierHtml, renderDossierMd } from './lib/dossieRender.mjs'
import { mergeDossierResearch, normalizeDossierResearchInput } from './lib/dossieResearch.mjs'
import { DEFAULT_AUTHOR_NAME, fetchAuthorEmendas } from './lib/portalTransparenciaEmendas.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-dossie-solla-cidade'
const die = dieWithLabel(LABEL)
const DEFAULT_OUT_DIR = 'docs/research/dossie-solla-cidade'
const CACHE_DIR = 'data/dossie-solla-cidade'
/** The whole federal mandate, not just the C163 default window. */
const DOSSIER_EMENDAS_YEARS = Array.from({ length: 12 }, (_value, index) => 2015 + index)
loadCliEnv()
const { flags } = parseEqualsFlags(process.argv.slice(2))
const snapshotPath = typeof flags.snapshot === 'string' ? flags.snapshot : null
const researchDir = typeof flags['research-dir'] === 'string' ? flags['research-dir'] : CACHE_DIR
const emendasPath = typeof flags.emendas === 'string' ? flags.emendas : null
const outDir = typeof flags['out-dir'] === 'string' ? flags['out-dir'] : DEFAULT_OUT_DIR
const authorName = typeof flags.author === 'string' ? flags.author : DEFAULT_AUTHOR_NAME
const generatedAt =
typeof flags['generated-at'] === 'string' ? new Date(flags['generated-at']) : new Date()
if (!snapshotPath) {
die(
'Uso: --snapshot=<json> [--research-dir=<dir>] [--emendas=<json>] [--out-dir=<dir>] [--author=<nome>]',
)
}
if (Number.isNaN(generatedAt.getTime())) die(`--generated-at inválida: ${flags['generated-at']}`)
const readJson = async (relativePath, label) => {
try {
return JSON.parse(await readFile(resolve(ROOT, relativePath), 'utf8'))
} catch (error) {
die(
`Falha ao ler ${label} (${relativePath}): ${error instanceof Error ? error.message : error}`,
)
}
}
const snapshot = await readJson(snapshotPath, 'snapshot')
const slug = snapshot.municipality?.slug
if (!slug) die('Snapshot sem "municipality.slug" — extração inválida.')
const readEraResearch = async (era) => {
const path = join(researchDir, `${slug}.${era.toLowerCase()}.research.json`)
const assertMatches = (research) => {
if (research.municipalitySlug !== slug) {
die(
`Pesquisa da Era ${era} é de "${research.municipalitySlug}", não de "${slug}" (${path}) — pare e regenere.`,
)
}
if (research.era !== era) {
die(`Pesquisa em ${path} declara Era ${research.era}, não Era ${era} — pare e regenere.`)
}
return research
}
try {
const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8'))
return assertMatches(normalizeDossierResearchInput(raw))
} catch (error) {
if (error?.code === 'ENOENT') {
console.log(`[${LABEL}] Era ${era}: sem pesquisa (${path}) — vira lacuna explícita.`)
return normalizeDossierResearchInput({
municipalitySlug: slug,
era,
researchedAt: generatedAt.toISOString(),
items: [],
})
}
die(
`Pesquisa da Era ${era} inválida (${path}): ${error instanceof Error ? error.message : error}`,
)
}
}
const researches = []
for (const era of DOSSIER_ERA_IDS) researches.push(await readEraResearch(era))
const research = mergeDossierResearch(researches)
const currentCodeSha = (() => {
try {
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim()
} catch {
return null
}
})()
if (snapshot.meta?.codeSha && currentCodeSha && snapshot.meta.codeSha !== currentCodeSha) {
console.warn(
`[${LABEL}] AVISO: snapshot extraído no SHA ${snapshot.meta.codeSha}, builder no ${currentCodeSha} — regenere se o extrator mudou.`,
)
}
const baseName = `${slug}-${generatedAt.toISOString().slice(0, 10)}`
const dossierPdfFile = join(outDir, `${baseName}-dossie.pdf`)
const bulletinPdfFile = join(outDir, `${baseName}-boletim.pdf`)
const emendasCachePath = join(CACHE_DIR, `${baseName}.emendas.json`)
const resolveEmendas = async () => {
if (emendasPath) return readJson(emendasPath, 'emendas')
try {
const cached = JSON.parse(await readFile(resolve(ROOT, emendasCachePath), 'utf8'))
console.log(`[${LABEL}] emendas: cache ${emendasCachePath}`)
return cached
} catch (error) {
if (error?.code !== 'ENOENT') {
die(
`Cache de emendas ilegível (${emendasCachePath}): ${error instanceof Error ? error.message : error}`,
)
}
}
const result = await fetchAuthorEmendas({
years: DOSSIER_EMENDAS_YEARS,
municipalityCode: snapshot.municipality.ibgeCode,
municipalityName: snapshot.municipality.name,
authorName,
apiKey: process.env.PORTAL_TRANSPARENCIA_API_KEY ?? null,
consultedAt: generatedAt.toISOString(),
})
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
await writeFile(resolve(ROOT, emendasCachePath), `${JSON.stringify(result, null, 2)}\n`)
console.log(
`[${LABEL}] emendas: ${result.status}${result.status === 'gap' ? ` (${result.reason})` : ` (${result.rows.length} registros)`} → ${emendasCachePath}`,
)
return result
}
const [emendas, camara, health] = await Promise.all([
resolveEmendas(),
fetchCamaraActivity({
from: CAMARA_DEFAULT_FROM,
to: generatedAt.toISOString().slice(0, 10),
}),
fetchHealthData({ ibgeCode: snapshot.municipality.ibgeCode ?? null, now: generatedAt }),
])
console.log(
`[${LABEL}] câmara=${camara.status} ibge=${health.status} lacunas_pesquisa=${research.gaps.length}`,
)
const readNarrative = async () => {
const path = join(researchDir, `${slug}.narrative.json`)
try {
const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8'))
if (raw.municipalitySlug !== slug) {
die(`Redação é de "${raw.municipalitySlug}", não de "${slug}" (${path}) — pare e regenere.`)
}
return raw
} catch (error) {
if (error?.code === 'ENOENT') return null
die(`Redação inválida (${path}): ${error instanceof Error ? error.message : error}`)
}
}
const narrative = await readNarrative()
if (!narrative) {
console.log(
`[${LABEL}] sem redação autoral (${slug}.narrative.json) — a abertura repete os números.`,
)
}
/**
* Builds the dossiê + boletim for a given fit fallback and pack plan. The resumo
* page never cuts with "…": pass 1 prints the researcher's `summary` (or the
* full answer); if the resumo sheet overflows the A4 guard, pass 2 re-renders
* with `textFallback: 'pointer'` so the lines without a `summary` point to the
* era pages (which keep the integral). The other sections flow by measurement
* (`pack`), so they multiply sheets instead of being capped.
*/
const buildArtifacts = ({ textFallback = 'full', pack = null, probe = false } = {}) => {
const report = buildDossierReport({
snapshot,
research,
emendas,
camara,
health,
generatedAt,
textFallback,
narrative,
})
const bulletin = buildBulletin({
facts: report.bulletinFacts,
municipality: snapshot.municipality.name,
region: snapshot.municipality.region ?? null,
generatedAt,
})
return {
report,
bulletin,
dossierHtml: renderDossierHtml(report, { pack, probe }),
dossierMd: renderDossierMd(report),
bulletinHtml: renderBulletinHtml(bulletin),
}
}
/**
* Flowing sheets: probe the real height of every unit, pack the sections, then
* re-render and measure — an overflowing sheet gives a row to the next chunk, a
* sheet with room takes the next row, until the plan stops changing. Nothing is
* capped and no page is left half empty; the final emit still guards the A4.
*/
const browser = await launchPdfBrowser()
let artifacts = buildArtifacts()
let pack = null
try {
const probeArtifacts = buildArtifacts({ probe: true })
const probe = await measureDocumentPackProbe(browser, probeArtifacts.dossierHtml)
const anchors = Object.fromEntries(
Object.entries(dossierPackAnchors(probeArtifacts.report.unit)).map(([anchor, key]) => [
key,
anchor,
]),
)
pack = packProbeSections(probe, A4_PAGE_BUDGET_PX)
console.log(
`[${LABEL}] probe: ${probe.map((section) => `${section.key}=${section.rows.length}`).join(' ')}`,
)
let settled = false
const lockedAnchors = new Set()
for (let attempt = 0; attempt < 16 && !settled; attempt += 1) {
artifacts = buildArtifacts({ pack })
const sheets = await measureDocumentSheets(browser, artifacts.dossierHtml)
for (const sheet of sheets) {
if (sheet.height > A4_PAGE_BUDGET_PX) lockedAnchors.add(sheet.page)
}
const adjusted = adjustPackPlan({
plan: pack,
probe,
sheets,
anchors,
budgetPx: A4_PAGE_BUDGET_PX,
lockedAnchors,
})
if (!adjusted) {
settled = true
break
}
pack = adjusted
}
artifacts = buildArtifacts({ pack })
console.log(
`[${LABEL}] pack ${settled ? 'estável' : 'no teto de tentativas'}: ${Object.entries(pack)
.map(([key, sizes]) => `${key}=${sizes.join('+')}`)
.join(' ')}`,
)
await emitHtmlPairPdf(browser, {
dossierHtml: artifacts.dossierHtml,
bulletinHtml: artifacts.bulletinHtml,
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.
onDossierOverflow: async () => {
artifacts = buildArtifacts({ textFallback: 'pointer', pack })
return artifacts.dossierHtml
},
resumoOnly: true,
})
} catch (error) {
die(error instanceof Error ? error.message : String(error))
} finally {
await browser.close()
}
const { report, bulletin, dossierHtml, dossierMd, bulletinHtml } = artifacts
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.dossie.html`)), dossierHtml)
await writeFile(resolve(ROOT, join(CACHE_DIR, `${baseName}.boletim.html`)), bulletinHtml)
await mkdir(resolve(ROOT, outDir), { recursive: true })
const dossierMdFile = join(outDir, `${baseName}-dossie.md`)
await writeFile(resolve(ROOT, dossierMdFile), dossierMd)
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} ` +
`highlights=${bulletin.highlights.length} lacunas=${research.gaps.length} páginas=${report.meta.pageTotal} html_bytes=${Buffer.byteLength(dossierHtml)}`,
)
if (isTruthyEnv(process.env.DOSSIER_STRICT) && research.gaps.length > 0) {
die(
`DOSSIER_STRICT=1 e ${research.gaps.length} lacuna(s) de pesquisa — o dossiê saiu, mas o modo estrito falha.`,
)
}
process.exit(0)