teqo-backup/scripts/build-dossie-solla-briefing.mjs
Francisco Solla c6620c2511 feat(C214): briefing 100% recorte — 4 folhas do recorte + âncora item-level (Closes #1284)
Replaneja o Briefing de capacitação C210: saem capa/objetivo/trajetória e as
recomendações genéricas (roteiro de 5 passos, justificativa científica, evitar,
conferir, limites); as 4 folhas passam a ser todas do recorte — defesas
(princípios + posições com fonte), essencial (fatos-âncora com fase + pedido
literal e uma linha de plano), qa 1/2 e qa 2/2.

- briefingContent: contrato novo (plan no lugar de script.steps; avoid/checklist
  removidos; shed qa → defesas) e âncora item-level por sourceUrl — o id de
  checklist repete e o ledger resolvia pelo primeiro item; par errado falha
  fechado (normalizeQa não repassava o sourceUrl, corrigido).
- briefingRender: 4 folhas novas (defesas/essencial/qa/qa-2) e companion .md;
  CSS portado do design (principles-copy/request-block/request-plan).
- build-dossie-solla-briefing: âncoras e comentários do fluxo novo.
- Design hi-fi refeito (4 cenas do recorte), skill e agente autor atualizados.
- Specs briefingContent/briefingRender reescritos; gate:fast verde (lint,
  typecheck, 389 arquivos / 4310 testes).
2026-09-23 13:17:37 -03:00

182 lines
7.3 KiB
JavaScript

/**
* Briefing de capacitação builder (C210): snapshot + per-era research + the
* authored `<slug>.briefing.json` → one internal A4 PDF (four fixed recorte
* sheets: defesas → essencial + pedido → qa 1/2 → qa 2/2) and the `.md`
* companion.
*
* Runs on the workstation, offline: no database access, no official-source
* fetch — the briefing derives from the dossiê JSONs already researched, and
* every anchor must resolve in the `bulletinFacts` ledger (facts with source).
* The four-sheet cap is a hard guard: on overflow the lowest-priority lists shed
* by priority (qa → defesas; declared on the sheet, complete in the `.md`),
* never silently.
*
* Usage:
* NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-dossie-solla-briefing.mjs \
* --unit=municipality \
* --snapshot=data/dossie-solla-cidade/<slug>.snapshot.json \
* --research-dir=data/dossie-solla-cidade \
* --out-dir=docs/research/dossie-solla-cidade
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
BRIEFING_ANCHORS,
BRIEFING_PAGE_TOTAL,
normalizeBriefingContent,
trimBriefing,
} from './lib/briefingContent.mjs'
import { briefingSubjectName, renderBriefingHtml, renderBriefingMd } from './lib/briefingRender.mjs'
import { emitHtmlSinglePdf, launchPdfBrowser } from './lib/buildPdf.mjs'
import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs'
import { buildDossierReport } from './lib/dossieBlocks.mjs'
import { DOSSIER_ERA_IDS } from './lib/dossieCareer.mjs'
import { mergeDossierResearch, normalizeDossierResearchInput } from './lib/dossieResearch.mjs'
import { resolveDossierUnit } from './lib/dossieUnit.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-dossie-solla-briefing'
const die = dieWithLabel(LABEL)
const { flags } = parseEqualsFlags(process.argv.slice(2))
const unitId = typeof flags.unit === 'string' ? flags.unit : 'municipality'
let unit
try {
unit = resolveDossierUnit(unitId)
} catch (error) {
die(error instanceof Error ? error.message : String(error))
}
const snapshotPath = typeof flags.snapshot === 'string' ? flags.snapshot : null
const researchDir =
typeof flags['research-dir'] === 'string' ? flags['research-dir'] : unit.researchDir
const outDir = typeof flags['out-dir'] === 'string' ? flags['out-dir'] : unit.outDir
const generatedAt =
typeof flags['generated-at'] === 'string' ? new Date(flags['generated-at']) : new Date()
if (!snapshotPath) {
die(
'Uso: --snapshot=<json> [--unit=municipality|institution|theme] [--research-dir=<dir>] [--out-dir=<dir>] [--generated-at=<ISO>]',
)
}
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[unit.snapshotField]?.slug
if (!slug) die(`Snapshot sem "${unit.snapshotField}.slug" — extração inválida.`)
const readEraResearch = async (era) => {
const path = join(researchDir, `${slug}.${era.toLowerCase()}.research.json`)
try {
const raw = JSON.parse(await readFile(resolve(ROOT, path), 'utf8'))
if (raw[unit.slugField] !== slug) {
die(
`Pesquisa da Era ${era} é de "${raw[unit.slugField]}", não de "${slug}" (${path}) — pare e regenere.`,
)
}
if (raw.era !== era) {
die(`Pesquisa em ${path} declara Era ${raw.era}, não Era ${era} — pare e regenere.`)
}
return normalizeDossierResearchInput(raw, { unit })
} catch (error) {
if (error?.code === 'ENOENT') {
console.log(`[${LABEL}] Era ${era}: sem pesquisa (${path}) — vira lacuna explícita.`)
return normalizeDossierResearchInput(
{ [unit.slugField]: slug, era, researchedAt: generatedAt.toISOString(), items: [] },
{ unit },
)
}
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, { unit })
const report = buildDossierReport({ snapshot, research, generatedAt, unit })
const facts = report.bulletinFacts
const sourcedFactIds = new Set(
facts.filter((fact) => Boolean(fact.sourceUrl) && !fact.sourcePanel).map((fact) => fact.id),
)
const readBriefing = async () => {
const path = join(researchDir, `${slug}.briefing.json`)
try {
return JSON.parse(await readFile(resolve(ROOT, path), 'utf8'))
} catch (error) {
if (error?.code === 'ENOENT') {
die(
`Briefing autoral ausente (${path}) — rode a etapa de redação da skill briefing-capacitacao-solla e tente de novo.`,
)
}
die(`Briefing inválido (${path}): ${error instanceof Error ? error.message : error}`)
}
}
const rawBriefing = await readBriefing()
const content = normalizeBriefingContent(rawBriefing, { unit, slug, facts })
for (const warning of content.warnings) console.warn(`[${LABEL}] AVISO: ${warning}`)
const baseName = `${slug}-${generatedAt.toISOString().slice(0, 10)}`
const briefingPdfFile = join(outDir, `${baseName}-briefing.pdf`)
const briefingMdFile = join(outDir, `${baseName}-briefing.md`)
const htmlCacheFile = join(researchDir, `${baseName}.briefing.html`)
const briefingMd = renderBriefingMd(content, { unit, report })
let printContent = content
let briefingHtml = renderBriefingHtml(printContent, { unit, report })
const browser = await launchPdfBrowser()
try {
await emitHtmlSinglePdf(browser, {
html: briefingHtml,
pdf: resolve(ROOT, briefingPdfFile),
maxPages: BRIEFING_PAGE_TOTAL,
requiredAnchors: BRIEFING_ANCHORS,
label: 'O briefing',
// Four fixed recorte sheets: on overflow the lowest-priority list sheds one
// item per pass (qa → defesas) until it fits; the remainder stays counted on
// the sheet and complete in the `.md`.
onOverflow: () => {
const trimmed = trimBriefing(printContent)
if (!trimmed) return null
printContent = trimmed
briefingHtml = renderBriefingHtml(printContent, { unit, report })
return briefingHtml
},
})
} catch (error) {
die(error instanceof Error ? error.message : String(error))
} finally {
await browser.close()
}
await mkdir(resolve(ROOT, researchDir), { recursive: true })
await writeFile(resolve(ROOT, htmlCacheFile), briefingHtml)
await mkdir(resolve(ROOT, outDir), { recursive: true })
await writeFile(resolve(ROOT, briefingMdFile), briefingMd)
console.log(`[${LABEL}] companion → ${briefingMdFile}`)
console.log(`[${LABEL}] PDF → ${briefingPdfFile}`)
const shedTotal = Object.values(printContent.shed).reduce((total, count) => total + count, 0)
console.log(
`[${LABEL}] ${briefingSubjectName(report, content)}: fatos_com_fonte=${sourcedFactIds.size} ` +
`essencial=${printContent.essential.length} qa=${printContent.qa.length} defesas=${printContent.defenses.length} ` +
`folhas=${BRIEFING_PAGE_TOTAL} shed=${shedTotal} html_bytes=${Buffer.byteLength(briefingHtml)}`,
)