teqo-backup/scripts/lib/briefingContent.mjs
Francisco Solla 7992a274f3 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 12:53:52 -03:00

326 lines
11 KiB
JavaScript

/**
* Briefing de capacitação — content contract and print budget (C210, owner: the
* briefing build).
*
* The briefing is an internal training handout derived from the dossiê: the
* authored `<slug>.briefing.json` may only point at facts whose source the
* dossiê ledger already carries (`buildDossierReport().bulletinFacts`), never at
* new research. This module owns the fail-closed validation, the deny-list that
* keeps staff-only/scenario keys out and the deterministic shed order that keeps
* the four-sheet cap honest: the PDF prints four fixed recorte-only sheets
* (defesas → essencial + pedido → qa 1/2 → qa 2/2), so on overflow the
* lowest-priority lists lose items (declared, never silent) and everything stays
* in the companion `.md`.
*/
export const BRIEFING_LABEL = 'Insumo interno de capacitação — não publicar'
export const BRIEFING_PAGE_TOTAL = 4
export const BRIEFING_ANCHORS = ['defesas', 'essencial', 'qa', 'qa-2']
export const BRIEFING_QA_SIDES = ['direita', 'esquerda', 'entrega']
/** Sides the briefing must always print at least once (C210 guardrail). */
const REQUIRED_QA_SIDES = ['direita', 'esquerda']
export const BRIEFING_MINIMUMS = {
essential: 3,
qa: 4,
}
/** Staff-only electoral scenarios never enter the handout (intention guardrail). */
const FORBIDDEN_KEYS = [
'estimatedVotes',
'estimated',
'scenario',
'cenario',
'cenarios',
'projection',
'projecao',
'staffOnly',
'internalVotes',
'polls',
]
/** Author-facing caps: reported as warnings — the page-fit guard is the hard limit. */
const TEXT_CAPS = {
lede: 380,
subtitle: 160,
essentialTitle: 120,
essentialNote: 200,
defenseTitle: 120,
defenseNote: 200,
plan: 220,
qaQuestion: 180,
qaAcknowledge: 200,
qaAnswer: 520,
qaClose: 200,
}
const isNonEmptyString = (value) => typeof value === 'string' && value.trim() !== ''
const fail = (message) => {
throw new Error(message)
}
const assertKey = (raw, key, label) => {
if (!isNonEmptyString(raw?.[key])) fail(`${label}: campo "${key}" obrigatório e não vazio.`)
}
const assertNoForbiddenKeys = (value, label) => {
if (Array.isArray(value)) {
for (const entry of value) assertNoForbiddenKeys(entry, label)
return
}
if (!value || typeof value !== 'object') return
for (const [key, entry] of Object.entries(value)) {
if (FORBIDDEN_KEYS.includes(key)) {
fail(`${label}: chave "${key}" proibida — cenário/estimativa é staff-only, fora do briefing.`)
}
assertNoForbiddenKeys(entry, label)
}
}
const assertList = (value, label) => {
if (!Array.isArray(value)) fail(`${label}: lista obrigatória.`)
}
const assertMinimum = (list, minimum, label) => {
if (list.length < minimum) {
fail(`${label}: mínimo de ${minimum} item(ns) — recebidos ${list.length}.`)
}
}
const warnIfOver = (warnings, text, max, label) => {
if (typeof text === 'string' && text.length > max) {
warnings.push(`${label}: ${text.length} chars (máx. ${max}) — encurte para caber na folha.`)
}
}
/**
* The ledger fact the anchor points at (the `bulletinFacts` contract). The
* acervo sample (`sourcePanel`) is a declared panel, not a finding — the
* bulletin already refuses it a printed slot, so the briefing refuses it an
* anchor: an "essencial" item is never a speech sample.
*
* A checklist id repeats across items (the research file has several items per
* id), so a bare `factId` resolves to the first item — the legacy behavior. To
* anchor a specific item of a repeated id the item carries the research item's
* `sourceUrl` as a disambiguator: the anchor only counts when the pair
* (`factId`, `sourceUrl`) resolves in the same sourced fact. Wrong pair = null
* and the caller fails closed.
*
* @param {any[]} facts
* @param {string} factId
* @param {string|null} [sourceUrl]
* @returns {any|null}
*/
export const briefingAnchorFact = (facts, factId, sourceUrl = null) => {
const candidates = (Array.isArray(facts) ? facts : []).filter(
(fact) => fact?.id === factId && !fact.sourcePanel,
)
if (isNonEmptyString(sourceUrl)) {
return candidates.find((fact) => fact.sourceUrl === sourceUrl) ?? null
}
return candidates[0] ?? null
}
/**
* The anchor rule of every item: exactly one of `factId | gapReason`, and a
* `factId` only counts when it resolves in a sourced, non-panel ledger fact.
* When the id repeats, the item's optional `sourceUrl` (copied from the research
* item) picks the exact fact. Returns the resolved anchor; `normalizeAnchor`
* adds the copy validation.
*/
const resolveAnchor = (item, { facts, label }) => {
const hasFact = isNonEmptyString(item.factId)
const hasGap = isNonEmptyString(item.gapReason)
if (hasFact === hasGap) {
fail(`${label}: exige exatamente um de "factId" | "gapReason".`)
}
if (hasGap) return { factId: null, gapReason: item.gapReason.trim(), fact: null }
const sourceUrl = isNonEmptyString(item.sourceUrl) ? item.sourceUrl.trim() : null
const fact = briefingAnchorFact(facts, item.factId, sourceUrl)
if (!fact?.sourceUrl) {
fail(
`${label}: factId "${item.factId}"${
sourceUrl ? ` + sourceUrl "${sourceUrl}"` : ''
} não resolve em fato com fonte do dossiê — sem fonte, não entra.`,
)
}
return { factId: fact.id, gapReason: null, fact }
}
const normalizeAnchor = (
item,
{ facts, label, warnings, titleKey, noteKey, titleCap, noteCap },
) => {
assertKey(item, titleKey, label)
assertKey(item, noteKey, label)
warnIfOver(warnings, item[titleKey], titleCap, `${label}.${titleKey}`)
warnIfOver(warnings, item[noteKey], noteCap, `${label}.${noteKey}`)
return {
title: item[titleKey].trim(),
note: item[noteKey].trim(),
...resolveAnchor(item, { facts, label }),
}
}
const normalizeEssential = (raw, options) =>
(raw.essential ?? []).map((item, index) =>
normalizeAnchor(item, {
...options,
label: `essential[${index}]`,
titleKey: 'title',
noteKey: 'note',
titleCap: TEXT_CAPS.essentialTitle,
noteCap: TEXT_CAPS.essentialNote,
}),
)
const normalizeDefenses = (raw, options) =>
(raw.defenses ?? []).map((item, index) =>
normalizeAnchor(item, {
...options,
label: `defenses[${index}]`,
titleKey: 'title',
noteKey: 'note',
titleCap: TEXT_CAPS.defenseTitle,
noteCap: TEXT_CAPS.defenseNote,
}),
)
/**
* The vote plan is one line of copy under the literal request — the generic
* five-step roteiro was retired (C210 replan): the sheet prints the literal ask
* and this single line, nothing else.
*/
const normalizePlan = (raw, options) => {
assertKey(raw, 'plan', 'briefing.json')
warnIfOver(options.warnings, raw.plan, TEXT_CAPS.plan, 'plan')
return raw.plan.trim()
}
const normalizeQa = (raw, options) => {
const qa = raw.qa ?? []
assertList(qa, 'qa')
assertMinimum(qa, BRIEFING_MINIMUMS.qa, 'qa')
const items = qa.map((item, index) => {
const label = `qa[${index}]`
if (!BRIEFING_QA_SIDES.includes(item?.side)) {
fail(`${label}: side inválido — use ${BRIEFING_QA_SIDES.join(' | ')}.`)
}
for (const key of ['question', 'acknowledge', 'answer', 'close']) {
assertKey(item, key, label)
}
warnIfOver(options.warnings, item.question, TEXT_CAPS.qaQuestion, `${label}.question`)
warnIfOver(options.warnings, item.acknowledge, TEXT_CAPS.qaAcknowledge, `${label}.acknowledge`)
warnIfOver(options.warnings, item.answer, TEXT_CAPS.qaAnswer, `${label}.answer`)
warnIfOver(options.warnings, item.close, TEXT_CAPS.qaClose, `${label}.close`)
const anchor = resolveAnchor(
{ factId: item.factId, gapReason: item.gapReason, sourceUrl: item.sourceUrl },
{ facts: options.facts, label },
)
return {
side: item.side,
question: item.question.trim(),
acknowledge: item.acknowledge.trim(),
answer: item.answer.trim(),
close: item.close.trim(),
...anchor,
}
})
const sides = new Set(items.map((item) => item.side))
if (!REQUIRED_QA_SIDES.every((side) => sides.has(side))) {
fail('qa: o briefing cobre os dois lados — exige ao menos 1 "direita" e 1 "esquerda".')
}
return items
}
/**
* Fail-closed normalization of the authored briefing. Every anchor must resolve
* in the sourced ledger; a lacuna is declared with `gapReason`, never invented.
*
* @param {any} raw parsed `<slug>.briefing.json`
* @param {{ unit: any, slug: string, facts?: any[] }} options
* @returns {any} normalized content + warnings
*/
export const normalizeBriefingContent = (raw, { unit, slug, facts = [] }) => {
if (!raw || typeof raw !== 'object') fail('briefing.json inválido: objeto esperado.')
if (unit?.id && raw.unitId !== unit.id) {
fail(`briefing.json: unitId "${raw.unitId ?? '—'}" não é "${unit.id}".`)
}
const slugField = unit?.slugField ?? 'slug'
assertKey(raw, slugField, 'briefing.json')
if (raw[slugField].trim() !== slug) {
fail(`briefing.json: ${slugField} "${raw[slugField]}" não é "${slug}" — pare e regenere.`)
}
assertKey(raw, 'generatedAt', 'briefing.json')
if (Number.isNaN(new Date(raw.generatedAt).getTime())) {
fail(`briefing.json: generatedAt inválido ("${raw.generatedAt}").`)
}
assertKey(raw, 'lede', 'briefing.json')
assertNoForbiddenKeys(raw, 'briefing.json')
const warnings = []
warnIfOver(warnings, raw.lede, TEXT_CAPS.lede, 'lede')
warnIfOver(warnings, raw.subtitle, TEXT_CAPS.subtitle, 'subtitle')
const essential = normalizeEssential(raw, { facts, warnings })
assertMinimum(essential, BRIEFING_MINIMUMS.essential, 'essential')
return {
slug: raw[slugField].trim(),
generatedAt: new Date(raw.generatedAt),
subtitle: isNonEmptyString(raw.subtitle) ? raw.subtitle.trim() : null,
lede: raw.lede.trim(),
essential,
defenses: normalizeDefenses(raw, { facts, warnings }),
plan: normalizePlan(raw, { warnings }),
qa: normalizeQa(raw, { facts, warnings }),
shed: { qa: 0, defenses: 0 },
warnings,
}
}
/**
* The last qa item whose removal still leaves one representative of each
* required side — the shed never silences a side (the bulletin/briefing
* contract prints both). `-1` when only the minimums remain.
*/
const qaShedIndex = (qa) => {
for (let index = qa.length - 1; index >= 0; index -= 1) {
const sides = new Set(
qa.filter((_item, position) => position !== index).map((item) => item.side),
)
if (REQUIRED_QA_SIDES.every((side) => sides.has(side))) return index
}
return -1
}
/**
* One shed step: drop a single item from the lowest-priority list that is still
* above its minimum (qa → defesas), never breaking the two-sides coverage.
* Essential, the principles copy and the pedido never shed. Returns a new
* content object, or `null` when nothing else may be dropped — the caller then
* fails closed.
*
* @param {any} content
* @returns {any|null}
*/
export const trimBriefing = (content) => {
const shed = { ...content.shed }
if (content.qa.length > BRIEFING_MINIMUMS.qa) {
const index = qaShedIndex(content.qa)
if (index !== -1) {
shed.qa += 1
return {
...content,
qa: content.qa.filter((_item, position) => position !== index),
shed,
}
}
}
if (content.defenses.length > 0) {
shed.defenses += 1
return { ...content, defenses: content.defenses.slice(0, -1), shed }
}
return null
}