/** * 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 `.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 `.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 }