mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:35:20 +00:00
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).
326 lines
11 KiB
JavaScript
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
|
|
}
|