'use strict'; /** * contentEngine — posts that structurally cannot lie. * * The top-of-funnel is a content flywheel, and the thing that makes it VYNDR's * rather than anyone's is that the numbers in it are real. That is easy to * promise and hard to keep, because the failure mode is not a person choosing to * fabricate — it is a template with a hardcoded adjective, or a field that came * back null and rendered as "0", or a caption drifting from the card beside it. * * So the honesty is enforced by construction rather than by care: * * 1. COPY IS TOKEN-SUBSTITUTED. Every factual claim in a template is a * `{token}` resolved against pulled facts. A token with no backing fact * REFUSES to render — it cannot fall back to a plausible default, because * there is no code path that produces one. * 2. THE FACT CONTRACT IS ASSERTED FIRST. A template declares the fields it * needs; the engine checks them before any string is built. A missing field * means SKIP or an honest-absence variant, never invention. * 3. CARD AND COPY SHARE ONE FACT OBJECT. They cannot diverge, because there * is only one set of numbers and both read it. * * NO LIVE MODEL WRITES FACTUAL CLAIMS. The voice lives in the template; the * facts are pulled. A voice-polish port is reserved for later and is not wired * here — an LLM that can rewrite a sentence can rewrite a number. * * READ-ONLY. This engine touches no serving or forecast table. It has zero * effect on the repaired-champion accrual clock. */ const { knownNumber } = require('../../utils/known'); /** Resolve `a.b.c` against an object; undefined when any hop is missing. */ function pathValue(obj, path) { return String(path).split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj); } /** * A value that may stand as a fact. * * `null` and `undefined` are absent. Empty string is absent — it renders as a * hole in a sentence. Zero and false are PRESENT: "0 props cleared B+" is a real * and important claim, and treating 0 as missing is the `Number(null) === 0` * breach wearing its opposite coat. */ function isPresent(v) { if (v === null || v === undefined) return false; if (typeof v === 'string' && v.trim() === '') return false; if (Array.isArray(v) && v.length === 0) return false; if (typeof v === 'number' && !Number.isFinite(v)) return false; return true; } const TOKEN = /\{([a-zA-Z0-9_.]+)\}/g; /** Every `{token}` in a string. */ function tokensIn(text) { const out = []; let m; const re = new RegExp(TOKEN); while ((m = re.exec(String(text))) !== null) out.push(m[1]); return out; } /** * Substitute tokens, refusing on any that is unbacked. * @throws when a token has no present fact — the whole point. */ function render(text, facts) { const missing = tokensIn(text).filter((t) => !isPresent(pathValue(facts, t))); if (missing.length) { throw new Error(`TRUTH LAW: unbacked token(s) ${missing.join(', ')} — refusing to render`); } return String(text).replace(TOKEN, (_, t) => String(pathValue(facts, t))); } /** Which contract fields are absent from a pulled fact set. */ function contractGaps(template, facts) { return (template.requires || []).filter((f) => !isPresent(pathValue(facts, f))); } const registry = new Map(); /** * Register a template. * * @param {object} t * id stable key * sport which sport it speaks for * requires fact-contract: dotted paths that MUST be present * pull async (deps) => facts — the ONLY place data enters * copy (facts) => string with {tokens} * card (facts) => card model (layout + token strings) * absent optional (gaps) => honest "nothing tonight" post */ function registerTemplate(t) { if (!t || !t.id) throw new Error('a template needs an id'); for (const k of ['requires', 'pull', 'copy', 'card']) { if (!t[k]) throw new Error(`template ${t.id} is missing ${k}`); } registry.set(t.id, t); return t; } const listTemplates = () => [...registry.values()]; const getTemplate = (id) => registry.get(id) || null; /** * Generate one post. * * @returns {object} { ok, id, copy, card, facts, skipped, reason } * Never throws on absent data — absence is an outcome, not an error. */ async function generate(id, deps = {}) { const t = registry.get(id); if (!t) return { ok: false, id, skipped: true, reason: 'no such template' }; let facts; try { facts = await t.pull(deps); } catch (e) { return { ok: false, id, skipped: true, reason: `pull failed: ${e.message}` }; } // ── THE CONTRACT, BEFORE ANY STRING IS BUILT ── const gaps = contractGaps(t, facts || {}); if (gaps.length) { if (typeof t.absent === 'function') { const alt = t.absent(gaps, facts || {}) || {}; // An absence variant may DECLINE to speak. A template that cannot tell // "nothing happened" from "nothing was fetched" must not publish the // first sentence when the second is true -- honest-absence copy is a // perfect hiding place for a broken pull. if (alt.skip) return { ok: false, id, skipped: true, reason: alt.skip, gaps }; return { ok: true, id, honest_absence: true, copy: alt.copy, card: alt.card, facts: facts || {}, gaps }; } return { ok: false, id, skipped: true, reason: `fact-contract gap: ${gaps.join(', ')}`, gaps }; } try { const copyRaw = t.copy(facts); const copy = render(copyRaw, facts); const cardModel = t.card(facts); // The card's own strings pass the same gate -- a caption and a card cannot // disagree if both are rendered from one fact object under one rule. const card = { ...cardModel, lines: (cardModel.lines || []).map((l) => (typeof l === 'string' ? render(l, facts) : { ...l, text: render(l.text, facts) })), title: cardModel.title ? render(cardModel.title, facts) : null, subtitle: cardModel.subtitle ? render(cardModel.subtitle, facts) : null, }; return { ok: true, id, copy, card, facts }; } catch (e) { // A refusal is a skip, never a degraded post. return { ok: false, id, skipped: true, reason: e.message }; } } /** Generate every registered template; skips are reported, never hidden. */ async function generateAll(deps = {}) { const out = []; for (const t of listTemplates()) out.push(await generate(t.id, deps)); return out; } module.exports = { registerTemplate, listTemplates, getTemplate, generate, generateAll, render, tokensIn, contractGaps, isPresent, pathValue, };