// Scene visual-hierarchy gate for validate-section.mjs. // // A scene is "risky" when it stacks competing focal claims — a multi-act scene, // or an action/payoff (CTA) co-existing with proof (logos / stats). Risky scenes // must declare a **PrimarySubjectTimeline:** and a **Handoff:** so one subject // owns the frame at a time. This module decides risk; the enforcement (which // anchors a risky scene must carry) stays in validate-section.mjs. // // Two sources, in priority order: // 1. AUTHORITATIVE — a structured `**Hierarchy:**` anchor the planner declares. // This is a pure schema read (no prose scanning), so it never costs review // to reason about regex alternations. Prefer it. // 2. FALLBACK — the prose classifier below, kept for plans that don't declare // the anchor yet. Once an E2E replay confirms the planner reliably emits // `**Hierarchy:**`, this fallback (classifyHierarchy + its regexes) can be // deleted and the gate becomes a pure schema check. // Fixed vocabulary for the **Hierarchy:** anchor. `simple` is the explicit // "no competing focal claims" declaration (risky=false). The others map 1:1 to // the prose classifier's signals so both sources produce the same shape. export const HIERARCHY_TAGS = ["simple", "multi-act", "action", "social-proof", "data-proof"]; // Read the authoritative `**Hierarchy:** ` anchor. Returns null when the // anchor is absent (→ caller falls back to the prose classifier). When present, // returns the risk shape plus any `unknown` tags (caller turns those into a // validation error so a typo can't silently disable the gate). export function declaredHierarchy(body) { const m = body.match(/^\*\*Hierarchy:\*\*\s*(.*)$/m); if (!m) return null; const tags = m[1] .toLowerCase() .split(/[,\s]+/) .filter(Boolean); const allowed = new Set(HIERARCHY_TAGS); const unknown = tags.filter((t) => !allowed.has(t)); const multiAct = tags.includes("multi-act"); const hasAction = tags.includes("action"); const hasSocialProof = tags.includes("social-proof"); const hasDataProof = tags.includes("data-proof"); return { source: "declared", unknown, multiAct, hasAction, hasSocialProof, hasDataProof, risky: multiAct || (hasAction && (hasSocialProof || hasDataProof)), }; } const hasAny = (text, patterns) => patterns.some((pattern) => pattern.test(text)); // Strip negated clauses so a scene that only mentions proof to DENY it (e.g. a // pure CTA: "there is no logo strip / customer logo / stat counter / chart") // doesn't read as a proof scene. Each negation eats to the next sentence stop. // This is what lets a CTA stop writing anti-regex defensive prose. function stripNegations(text) { return text.replace( /\b(no|not|never|without|isn'?t|aren'?t|don'?t|doesn'?t|won'?t|cannot|can'?t)\b[^.;:]*/gi, " ", ); } // FALLBACK prose classifier — the historical hierarchyRisk(). Same return shape // as declaredHierarchy() so callers don't branch on the source. export function classifyHierarchy(body) { const text = body.toLowerCase(); // Proof signals are read from (1) cited component ids — an unambiguous, // structured signal preferred over fuzzy prose scans — and (2) the prose with // negated clauses removed, using PHRASE patterns (not bare "logo"/"customer", // which over-trigger on brand wordmarks and incidental mentions). const proofText = stripNegations(text); const compM = body.match(/^\*\*Components:\*\*\s*(.*)$/m); const compIds = compM ? [...compM[1].matchAll(/`([^`]+)`/g)].map((m) => m[1]) : []; const compProof = compIds.some((id) => /logo|proof|testimonial|customer/i.test(id)); const compData = compIds.some((id) => /stat|chart|metric|kpi|counter/i.test(id)); const multiAct = /\b(multi[- ]?act|three[- ]?act|act\s+[abc]|\bfocal points?)\b/i.test(text); const hasAction = /\b(cta|get started|call[- ]?to[- ]?action|button|sign up|book demo|start trial|download|subscribe|contact sales|action headline|payoff frame|payoff close|closing action)\b/i.test( text, ); const hasSocialProof = compProof || hasAny(proofText, [ /logo[- ]?(strip|grid|wall|cloud|chain|row|rail|lockup)/i, /\btrusted by\b/i, /social[- ]proof/i, /\btestimonials?\b/i, /customer logos?/i, /enterprise logos?/i, /\bbrands? you (know|trust)\b/i, ]); const hasDataProof = compData || hasAny(proofText, [ /\bstats?\b/i, /\bstat[- ]?counter\b/i, /\bmetrics?\b/i, /\bkpis?\b/i, /\bproof cluster\b/i, /\bproof rail\b/i, /\bchart\b/i, /\bcount[- ]?up\b/i, /\bpolicy compliance\b/i, /\bhours saved\b/i, /\byield\b/i, ]); return { source: "prose", unknown: [], multiAct, hasAction, hasSocialProof, hasDataProof, risky: multiAct || (hasAction && (hasSocialProof || hasDataProof)), }; } // Resolve a scene's hierarchy profile: prefer the declared anchor (schema check), // else classify the prose (fallback). export function hierarchyProfile(body) { return declaredHierarchy(body) || classifyHierarchy(body); }