From 2cd8081c45b5fb21823aa25432c4c98f9af2814b Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Sun, 30 Aug 2026 13:34:47 -0700 Subject: [PATCH 1/5] feat(fableloom): add editorial autopilot and playthrough QA Add one-pass whole-series remediation, a bounded editor/reviewer loop, and exhaustive branching diagnostics with narrative-quality review. Surface the workflow on the Series Plan with explicit AI routing and actionable episode/scene findings. --- .../fableloom/LoomEditorialAutomation.jsx | 384 ++++++++++++ .../LoomEditorialAutomation.test.jsx | 140 +++++ .../components/fableloom/LoomSeriesPlan.jsx | 9 +- .../fableloom/LoomSeriesPlan.test.jsx | 3 + client/src/services/apiFableLoom.js | 18 + client/src/services/apiFableLoom.test.js | 28 + data.reference/prompts/stage-config.json | 14 + .../stages/fableloom-editorial-remediate.md | 128 ++++ .../stages/fableloom-review-playthroughs.md | 61 ++ ...0-fableloom-editorial-automation-stages.js | 8 + ...leloom-editorial-automation-stages.test.js | 12 + server/lib/README.md | 1 + server/lib/apiRouteCatalog.generated.json | 152 +++-- server/lib/fableLoomLimits.js | 1 + server/lib/fableLoomPlaytest.js | 351 +++++++++++ server/lib/fableLoomPlaytest.test.js | 127 ++++ server/lib/fableLoomValidation.js | 18 + server/lib/index.js | 1 + server/routes/fableLoom.js | 51 ++ server/routes/fableLoom.test.js | 78 +++ server/services/fableLoom/README.md | 2 + server/services/fableLoom/editorial.js | 593 ++++++++++++++++++ server/services/fableLoom/editorial.test.js | 137 ++++ .../services/fableLoom/editorialAutopilot.js | 301 +++++++++ .../fableLoom/editorialAutopilot.test.js | 129 ++++ server/services/fableLoom/index.js | 15 + 26 files changed, 2716 insertions(+), 46 deletions(-) create mode 100644 client/src/components/fableloom/LoomEditorialAutomation.jsx create mode 100644 client/src/components/fableloom/LoomEditorialAutomation.test.jsx create mode 100644 data.reference/prompts/stages/fableloom-editorial-remediate.md create mode 100644 data.reference/prompts/stages/fableloom-review-playthroughs.md create mode 100644 scripts/migrations/320-fableloom-editorial-automation-stages.js create mode 100644 scripts/migrations/320-fableloom-editorial-automation-stages.test.js create mode 100644 server/lib/fableLoomPlaytest.js create mode 100644 server/lib/fableLoomPlaytest.test.js create mode 100644 server/services/fableLoom/editorial.js create mode 100644 server/services/fableLoom/editorial.test.js create mode 100644 server/services/fableLoom/editorialAutopilot.js create mode 100644 server/services/fableLoom/editorialAutopilot.test.js diff --git a/client/src/components/fableloom/LoomEditorialAutomation.jsx b/client/src/components/fableloom/LoomEditorialAutomation.jsx new file mode 100644 index 000000000..8434ef86b --- /dev/null +++ b/client/src/components/fableloom/LoomEditorialAutomation.jsx @@ -0,0 +1,384 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Link } from 'react-router'; +import { + AlertTriangle, BrainCircuit, CheckCircle2, Loader2, Sparkles, Square, Waypoints, +} from 'lucide-react'; +import ProviderModelSelector from '../ProviderModelSelector'; +import toast from '../ui/Toast'; +import { useAsyncAction } from '../../hooks/useAsyncAction'; +import useFableLoomAiRun from '../../hooks/useFableLoomAiRun'; +import useProviderModels from '../../hooks/useProviderModels'; +import { + cancelLoomEditorialAutopilot, + getLoom, + getLoomEditorialAutopilotRun, + getLoomEditorialAutopilotStatus, + remediateLoomEditorial, + reviewLoomPlaythroughs, + startLoomEditorialAutopilot, +} from '../../services/api'; +import { effectiveModelFor, effortAwareModelOptions } from '../../utils/providers'; +import LoomAiRunStatus from './LoomAiRunStatus'; + +const ACTIVE_STATUSES = new Set(['running', 'canceling']); +const TERMINAL_STATUSES = new Set(['completed', 'paused', 'failed', 'canceled']); + +const routePayload = (route) => ({ + ...(route.providerId ? { providerId: route.providerId } : {}), + ...(route.model ? { model: route.model } : {}), + ...(route.effort ? { effort: route.effort } : {}), +}); + +const statusClass = (status) => { + if (status === 'completed') return 'border-port-success/40 bg-port-success/5 text-port-success'; + if (status === 'failed') return 'border-port-error/40 bg-port-error/5 text-port-error'; + if (status === 'paused' || status === 'canceled') return 'border-port-warning/40 bg-port-warning/5 text-port-warning'; + return 'border-port-accent/30 bg-port-accent/5 text-port-accent'; +}; + +function Metric({ label, value, tone = 'default' }) { + const valueClass = tone === 'good' + ? 'text-port-success' + : tone === 'bad' ? 'text-port-error' : 'text-port-text'; + return ( +
+

{label}

+

{value}

+
+ ); +} + +function FindingLink({ loom, finding }) { + const episode = loom.episodes.find((candidate) => candidate.id === finding.episodeId); + const scene = episode?.nodes?.find((candidate) => candidate.id === finding.nodeId); + const target = episode + ? `/fableloom/${encodeURIComponent(loom.id)}/${encodeURIComponent(episode.id)}${scene ? `/${encodeURIComponent(scene.id)}` : ''}` + : null; + const severity = finding.severity === 'error' ? 'high' : finding.severity; + const Icon = severity === 'high' ? AlertTriangle : Waypoints; + return ( +
  • +
    + +
    +

    {finding.problem || finding.message}

    + {finding.suggestion ?

    {finding.suggestion}

    : null} + {target ? ( + + Episode {episode.number || loom.episodes.indexOf(episode) + 1}{scene ? ` · ${scene.title || 'Scene'}` : ''} + + ) : null} +
    +
    +
  • + ); +} + +/** Whole-series one-pass editor, bounded editor/reviewer autopilot, and playthrough QA. */ +export default function LoomEditorialAutomation({ loom, dirty, onLoomUpdate }) { + const [route, setRoute] = useState({ providerId: '', model: '', effort: '' }); + const [maxRounds, setMaxRounds] = useState(3); + const [result, setResult] = useState(null); + const [autopilotRun, setAutopilotRun] = useState(null); + const handledTerminalRunRef = useRef(null); + const remediationAi = useFableLoomAiRun(); + const playtestAi = useFableLoomAiRun(); + const { providers, activeProviderId, loading: providersLoading } = useProviderModels({ + allowDefault: true, + silent: true, + withEffort: true, + }); + const effectiveProviderId = route.providerId || activeProviderId; + const selectedProvider = providers.find((provider) => provider.id === effectiveProviderId); + const selectedModel = effectiveModelFor(selectedProvider, route.model); + const routeBody = useMemo(() => routePayload(route), [route]); + + useEffect(() => { + setResult(null); + setAutopilotRun(null); + handledTerminalRunRef.current = null; + let ignore = false; + getLoomEditorialAutopilotStatus(loom.id, { silent: true }) + .then(({ run }) => { if (!ignore && run) setAutopilotRun(run); }) + .catch(() => {}); + return () => { ignore = true; }; + }, [loom.id]); + + const autopilotActive = ACTIVE_STATUSES.has(autopilotRun?.status); + useEffect(() => { + if (!autopilotActive || !autopilotRun?.id) return undefined; + let ignore = false; + const refresh = () => getLoomEditorialAutopilotRun(loom.id, autopilotRun.id, { silent: true }) + .then((run) => { if (!ignore) setAutopilotRun(run); }) + .catch(() => {}); + const interval = setInterval(refresh, 2000); + return () => { + ignore = true; + clearInterval(interval); + }; + }, [autopilotActive, autopilotRun?.id, loom.id]); + + useEffect(() => { + if (!TERMINAL_STATUSES.has(autopilotRun?.status) + || handledTerminalRunRef.current === autopilotRun.id) return; + handledTerminalRunRef.current = autopilotRun.id; + setResult({ type: 'autopilot', run: autopilotRun }); + getLoom(loom.id, { silent: true }).then(onLoomUpdate).catch(() => {}); + }, [autopilotRun, loom.id, onLoomUpdate]); + + const [remediate, remediating] = useAsyncAction(async () => { + const operationId = remediationAi.begin(); + const response = await remediateLoomEditorial(loom.id, { + ...routeBody, + operationId, + }, { silent: true }).catch((error) => { + remediationAi.fail(error.message); + throw error; + }); + setResult({ type: 'remediation', response }); + onLoomUpdate(response.loom); + toast.success(response.changed + ? `Series remediated — ${response.changes.length || 1} safe edit${response.changes.length === 1 ? '' : 's'} applied` + : 'Series evaluated — no safe edit was needed'); + }, { errorMessage: 'Series editorial remediation failed' }); + + const [runPlaytest, playtesting] = useAsyncAction(async () => { + const operationId = playtestAi.begin(); + const response = await reviewLoomPlaythroughs(loom.id, { + ...routeBody, + aiReview: true, + operationId, + }, { silent: true }).catch((error) => { + playtestAi.fail(error.message); + throw error; + }); + setResult({ type: 'playtest', response }); + toast.success(response.passed + ? 'Every enumerated playthrough passed structural and narrative review' + : 'Playthrough review found issues to resolve'); + }, { errorMessage: 'Playthrough test failed' }); + + const [startAutopilot, startingAutopilot] = useAsyncAction(async () => { + const run = await startLoomEditorialAutopilot(loom.id, { + ...routeBody, + maxRounds, + }, { silent: true }); + handledTerminalRunRef.current = null; + setAutopilotRun(run); + setResult(null); + toast.success(run.alreadyRunning ? 'Reattached to the active editorial autopilot' : 'Editorial autopilot started'); + }, { errorMessage: 'Could not start editorial autopilot' }); + + const [cancelAutopilot, cancelingAutopilot] = useAsyncAction(async () => { + const run = await cancelLoomEditorialAutopilot(loom.id, autopilotRun.id, { silent: true }); + setAutopilotRun(run); + }, { errorMessage: 'Could not cancel editorial autopilot' }); + + const busy = remediating || playtesting || startingAutopilot || cancelingAutopilot || autopilotActive; + const blocked = dirty || !loom.episodes.length; + const response = result?.response; + const shownRun = result?.type === 'autopilot' ? result.run : result ? null : autopilotRun; + const diagnostics = result?.type === 'remediation' ? response?.diagnostics : null; + const deterministic = result?.type === 'playtest' + ? response?.deterministic + : diagnostics?.playthrough || shownRun?.lastPlaytest; + const stats = deterministic?.stats; + const remediationStats = result?.type === 'remediation' + ? response?.after + : shownRun?.rounds?.at(-1)?.after; + const review = result?.type === 'playtest' ? response?.review : shownRun?.lastReview; + const evaluation = result?.type === 'remediation' ? response?.evaluation : shownRun?.lastEvaluation; + const findings = result?.type === 'autopilot' || shownRun?.status + ? shownRun?.residualFindings || [] + : review?.findings?.length ? review.findings : evaluation?.findings || []; + const summary = review?.summary || evaluation?.summary || shownRun?.message; + const passed = result?.type === 'playtest' + ? response?.passed + : shownRun?.status === 'completed' || diagnostics?.passed; + + return ( +
    +
    +
    +

    + AI editor, reviewer & playtest +

    +

    + One editor can evaluate and safely repair the complete series. Autopilot alternates that editor with an independent review of every enumerated gameplay path until the story passes or reaches its bounded round limit. +

    +
    + {shownRun ? ( + + {shownRun.status} · round {shownRun.round}/{shownRun.maxRounds} + + ) : null} +
    + +
    + setRoute({ providerId, model: '', effort: '' })} + onModelChange={(model) => setRoute((current) => ({ ...current, model }))} + effort={route.effort} + onEffortChange={(effort) => setRoute((current) => ({ ...current, effort }))} + label="Editorial AI route" + disabled={busy || providersLoading} + modelDisabled={busy || providersLoading} + emptyProviderOption="Default (editorial stage or active provider)" + emptyModelOption="Default model" + alwaysShowModel={!!route.providerId} + /> +
    + + +
    +
    + {selectedProvider ? ( +

    + Runs will use {selectedProvider.name}{selectedModel ? ` (${selectedModel})` : ''} + {route.effort ? ` at ${route.effort} effort` : ' at the provider default effort'}. +

    + ) : null} + +
    + + + {autopilotActive ? ( + + ) : ( + + )} +
    + + {dirty ? ( +

    Save the current series-plan edits before running editorial automation.

    + ) : !loom.episodes.length ? ( +

    Add at least one episode before running editorial automation.

    + ) : null} + {autopilotActive ? ( +
    + + {autopilotRun.message} +
    + ) : null} + + + + {(stats || remediationStats || review || evaluation || shownRun) ? ( +
    +
    + {passed ? ( + + ) : ( + + )} +
    +

    + {passed ? 'Series clears the current editorial gates' : 'Editorial work remains'} +

    + {summary ?

    {summary}

    : null} +
    +
    +
    + {remediationStats ? ( + <> + + + + + ) : null} + {stats ? ( + <> + + + + ) : null} + {review?.qualityScore != null ? ( + = 8 ? 'good' : 'bad'} /> + ) : null} +
    + {evaluation?.strengths?.length || review?.strengths?.length ? ( +

    + Strengths:{' '} + {(review?.strengths?.length ? review.strengths : evaluation.strengths).join(' · ')} +

    + ) : null} + {findings.length ? ( +
      + {findings.slice(0, 12).map((finding, index) => ( + + ))} +
    + ) : null} + {shownRun?.rounds?.length ? ( +

    + {shownRun.rounds.length} bounded editorial round{shownRun.rounds.length === 1 ? '' : 's'} recorded. The run stops on success, plateau, cancellation, provider failure, or the selected round limit. +

    + ) : null} +
    + ) : null} +
    + ); +} diff --git a/client/src/components/fableloom/LoomEditorialAutomation.test.jsx b/client/src/components/fableloom/LoomEditorialAutomation.test.jsx new file mode 100644 index 000000000..6239d3e15 --- /dev/null +++ b/client/src/components/fableloom/LoomEditorialAutomation.test.jsx @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router'; + +vi.mock('../../services/api', () => ({ + cancelLoomEditorialAutopilot: vi.fn(), + getLoom: vi.fn(), + getLoomEditorialAutopilotRun: vi.fn(), + getLoomEditorialAutopilotStatus: vi.fn(), + getProviders: vi.fn(), + remediateLoomEditorial: vi.fn(), + reviewLoomPlaythroughs: vi.fn(), + startLoomEditorialAutopilot: vi.fn(), +})); +vi.mock('../../services/socket', () => ({ + default: { on: vi.fn(), off: vi.fn() }, +})); + +import * as api from '../../services/api'; +import LoomEditorialAutomation from './LoomEditorialAutomation'; + +const loom = { + id: 'loom-1', + name: 'Example Story', + episodes: [{ + id: 'episode-1', number: 1, title: 'Pilot', + nodes: [{ id: 'scene-1', title: 'Opening', transitions: [] }], + }], +}; + +const providers = [{ + id: 'codex', name: 'Codex', type: 'cli', command: 'codex', enabled: true, + defaultModel: 'gpt-5', models: ['gpt-5'], +}]; + +const renderPanel = (props = {}) => render( + + + , +); + +beforeEach(() => { + vi.clearAllMocks(); + api.getProviders.mockResolvedValue({ activeProvider: 'codex', providers }); + api.getLoomEditorialAutopilotStatus.mockResolvedValue({ run: null }); + api.remediateLoomEditorial.mockResolvedValue({ + loom, + changed: true, + changes: ['Added the missing beat outline.'], + evaluation: { summary: 'The outline is now coherent.', strengths: ['Clear central choice'], findings: [] }, + after: { outlineErrors: 0, graphErrors: 0, convergenceIssues: 0 }, + diagnostics: { + passed: true, + playthrough: { + stats: { variationCount: 2, visitedTransitionCount: 4, transitionCount: 4 }, + }, + }, + }); + api.reviewLoomPlaythroughs.mockResolvedValue({ + passed: true, + deterministic: { stats: { variationCount: 2, visitedTransitionCount: 4, transitionCount: 4 } }, + review: { qualityScore: 8.7, summary: 'Every path pays off.', strengths: [], findings: [] }, + }); +}); + +describe('LoomEditorialAutomation', () => { + it('runs one whole-series editor and adopts the remediated loom', async () => { + const user = userEvent.setup(); + const onLoomUpdate = vi.fn(); + renderPanel({ onLoomUpdate }); + + await user.selectOptions(await screen.findByLabelText('Editorial AI route'), 'codex'); + await user.selectOptions(screen.getByLabelText('Model'), 'gpt-5'); + await user.selectOptions(screen.getByLabelText('Thinking effort'), 'high'); + expect(screen.getByText('Runs will use Codex (gpt-5) at high effort.')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Evaluate & remediate series' })); + + await waitFor(() => expect(api.remediateLoomEditorial).toHaveBeenCalledWith( + 'loom-1', + expect.objectContaining({ + providerId: 'codex', model: 'gpt-5', effort: 'high', operationId: expect.any(String), + }), + { silent: true }, + )); + expect(onLoomUpdate).toHaveBeenCalledWith(loom); + expect(await screen.findByText('Series clears the current editorial gates')).toBeInTheDocument(); + expect(screen.getByText('Variations tested').parentElement).toHaveTextContent('2'); + expect(screen.getByText('Path coverage').parentElement).toHaveTextContent('4/4'); + }); + + it('runs the narrative playthrough judge and displays its quality verdict', async () => { + const user = userEvent.setup(); + renderPanel(); + + await user.click(screen.getByRole('button', { name: 'Run playthrough test' })); + + await waitFor(() => expect(api.reviewLoomPlaythroughs).toHaveBeenCalledWith( + 'loom-1', + expect.objectContaining({ aiReview: true, operationId: expect.any(String) }), + { silent: true }, + )); + expect(await screen.findByText('Every path pays off.')).toBeInTheDocument(); + expect(screen.getByText('8.7/10')).toBeInTheDocument(); + }); + + it('starts the bounded editor/reviewer loop and exposes cooperative stop', async () => { + const user = userEvent.setup(); + api.startLoomEditorialAutopilot.mockResolvedValue({ + id: 'editorial-run-1', loomId: 'loom-1', status: 'running', round: 1, maxRounds: 3, + message: 'Round 1: evaluating and remediating the complete series…', rounds: [], + residualFindings: [], + }); + renderPanel(); + + await user.click(screen.getByRole('button', { name: 'Start editor autopilot' })); + + await waitFor(() => expect(api.startLoomEditorialAutopilot).toHaveBeenCalledWith( + 'loom-1', { maxRounds: 3 }, { silent: true }, + )); + expect(screen.getByRole('button', { name: 'Stop editor autopilot' })).toBeInTheDocument(); + expect(screen.getAllByText(/Round 1: evaluating and remediating/).length).toBeGreaterThan(0); + }); + + it('blocks every mutating AI action while the series plan has unsaved edits', async () => { + renderPanel({ dirty: true }); + + await waitFor(() => expect(api.getLoomEditorialAutopilotStatus).toHaveBeenCalled()); + + expect(screen.getByRole('button', { name: 'Evaluate & remediate series' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Run playthrough test' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Start editor autopilot' })).toBeDisabled(); + expect(screen.getByText(/Save the current series-plan edits/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/fableloom/LoomSeriesPlan.jsx b/client/src/components/fableloom/LoomSeriesPlan.jsx index 2da543d11..9258f957a 100644 --- a/client/src/components/fableloom/LoomSeriesPlan.jsx +++ b/client/src/components/fableloom/LoomSeriesPlan.jsx @@ -4,7 +4,7 @@ * and editing its copy cannot race as independent PATCH requests. */ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Link } from 'react-router'; import { BrainCircuit, CheckCircle2, ChevronDown, ChevronUp, Loader2, Plus, Save, Sparkles, Trash2 } from 'lucide-react'; import ConfirmButtonPair from '../ui/ConfirmButtonPair'; @@ -25,6 +25,7 @@ import { uuidv4 } from '../../lib/uuid.js'; import { effectiveModelFor, effortAwareModelOptions } from '../../utils/providers'; import { fieldClass, labelClass } from './fieldStyles'; import LoomAiRunStatus from './LoomAiRunStatus'; +import LoomEditorialAutomation from './LoomEditorialAutomation'; const newItemId = (prefix) => `${prefix}-${uuidv4()}`; @@ -125,11 +126,11 @@ export default function LoomSeriesPlan({ loom, onLoomUpdate }) { // result. Make that response authoritative over any typing that happened // while the provider call was in flight; otherwise the revision guard would // keep the stale local plan on screen and a later Save would undo the AI run. - const adoptServerPlan = (updated) => { + const adoptServerPlan = useCallback((updated) => { revisionRef.current = 0; savedRevisionRef.current = 0; onLoomUpdate(updated); - }; + }, [onLoomUpdate]); const episodeOptions = loom.episodes.map((episode) => ({ id: episode.id, @@ -179,6 +180,8 @@ export default function LoomSeriesPlan({ loom, onLoomUpdate }) { + + ({ default: { on: vi.fn(), off: vi.fn() }, })); vi.mock('../ProviderModelSelector', () => ({ default: () =>
    AI route picker
    })); +vi.mock('./LoomEditorialAutomation', () => ({ + default: () =>
    Editorial automation
    , +})); import * as api from '../../services/api'; import LoomSeriesPlan from './LoomSeriesPlan'; diff --git a/client/src/services/apiFableLoom.js b/client/src/services/apiFableLoom.js index d3a2753f8..9d845cb58 100644 --- a/client/src/services/apiFableLoom.js +++ b/client/src/services/apiFableLoom.js @@ -36,6 +36,24 @@ export const feedbackLoomSeriesPlan = (id, body, options = {}) => request(loomPa export const reviewLoomTeleplay = (id, body = {}, options = {}) => request(loomPath(id, '/review-teleplay'), { method: 'POST', body: JSON.stringify(body), ...options, }); +export const remediateLoomEditorial = (id, body = {}, options = {}) => request(loomPath(id, '/editorial/remediate'), { + method: 'POST', body: JSON.stringify(body), ...options, +}); +export const reviewLoomPlaythroughs = (id, body = {}, options = {}) => request(loomPath(id, '/playtest'), { + method: 'POST', body: JSON.stringify(body), ...options, +}); +export const startLoomEditorialAutopilot = (id, body = {}, options = {}) => + request(loomPath(id, '/editorial/autopilot/start'), { + method: 'POST', body: JSON.stringify(body), ...options, + }); +export const getLoomEditorialAutopilotStatus = (id, options = {}) => + request(loomPath(id, '/editorial/autopilot/status'), options); +export const getLoomEditorialAutopilotRun = (id, runId, options = {}) => + request(loomPath(id, `/editorial/autopilot/${encodeURIComponent(runId)}`), options); +export const cancelLoomEditorialAutopilot = (id, runId, options = {}) => + request(loomPath(id, `/editorial/autopilot/${encodeURIComponent(runId)}/cancel`), { + method: 'POST', body: JSON.stringify({}), ...options, + }); export const addLoomEpisode = (id, body, options = {}) => request(loomPath(id, '/episodes'), { method: 'POST', body: JSON.stringify(body), ...options, diff --git a/client/src/services/apiFableLoom.test.js b/client/src/services/apiFableLoom.test.js index ffdc2f51a..83d156a6f 100644 --- a/client/src/services/apiFableLoom.test.js +++ b/client/src/services/apiFableLoom.test.js @@ -59,6 +59,34 @@ describe('apiFableLoom', () => { }); }); + it('routes editorial remediation, playthrough review, and bounded autopilot operations', async () => { + await api.remediateLoomEditorial('loom/1', { providerId: 'writer' }, { silent: true }); + expect(request).toHaveBeenCalledWith('/fableloom/loom%2F1/editorial/remediate', { + method: 'POST', body: JSON.stringify({ providerId: 'writer' }), silent: true, + }); + + await api.reviewLoomPlaythroughs('loom-1', { aiReview: true }); + expect(request).toHaveBeenCalledWith('/fableloom/loom-1/playtest', { + method: 'POST', body: JSON.stringify({ aiReview: true }), + }); + + await api.startLoomEditorialAutopilot('loom-1', { maxRounds: 3 }); + expect(request).toHaveBeenCalledWith('/fableloom/loom-1/editorial/autopilot/start', { + method: 'POST', body: JSON.stringify({ maxRounds: 3 }), + }); + + await api.getLoomEditorialAutopilotStatus('loom-1', { silent: true }); + expect(request).toHaveBeenCalledWith('/fableloom/loom-1/editorial/autopilot/status', { silent: true }); + + await api.getLoomEditorialAutopilotRun('loom-1', 'run/1', { silent: true }); + expect(request).toHaveBeenCalledWith('/fableloom/loom-1/editorial/autopilot/run%2F1', { silent: true }); + + await api.cancelLoomEditorialAutopilot('loom-1', 'run-1'); + expect(request).toHaveBeenCalledWith('/fableloom/loom-1/editorial/autopilot/run-1/cancel', { + method: 'POST', body: JSON.stringify({}), + }); + }); + it('posts play turns with the transcript', async () => { const body = { nodeId: 'node-1', message: 'open the gate', transcript: [] }; await api.playLoomTurn('loom-1', 'ep-1', body); diff --git a/data.reference/prompts/stage-config.json b/data.reference/prompts/stage-config.json index 85e5f34fc..7ee03049f 100644 --- a/data.reference/prompts/stage-config.json +++ b/data.reference/prompts/stage-config.json @@ -1072,6 +1072,20 @@ "model": "heavy", "returnsJson": true, "variables": [] + }, + "fableloom-editorial-remediate": { + "name": "FableLoom — Evaluate and Remediate Series", + "description": "Evaluates a complete interactive series and returns one safe sparse patch for missing outlines, scene craft, branch labeling, and explicit convergence continuity while preserving all existing graph membership and ids.", + "model": "heavy", + "returnsJson": true, + "variables": [] + }, + "fableloom-review-playthroughs": { + "name": "FableLoom — Review Playthrough Variations", + "description": "Reviews deterministically enumerated playthrough variations for mechanics, causal branch coherence, audience agency, continuity, pacing, ending payoff, canon, and ship-quality storytelling.", + "model": "heavy", + "returnsJson": true, + "variables": [] } } } diff --git a/data.reference/prompts/stages/fableloom-editorial-remediate.md b/data.reference/prompts/stages/fableloom-editorial-remediate.md new file mode 100644 index 000000000..0612e9c98 --- /dev/null +++ b/data.reference/prompts/stages/fableloom-editorial-remediate.md @@ -0,0 +1,128 @@ +# FableLoom — Evaluate and Remediate the Complete Series + +You are the single senior story editor responsible for evaluating and safely remediating an existing interactive FableLoom series. Diagnose the whole experience before editing, then return the smallest coherent patch that resolves the concrete problems you found. + +## Story + +{{storyContext}} + +## World canon + +{{canonDigest}} + +## Series plan and episode outlines + +{{seriesPlanJson}} + +## Expanded teleplay + +{{teleplayDigest}} + +## Enumerated playthroughs + +{{playthroughDigest}} + +## Deterministic findings + +{{deterministicDigest}} + +## Additional editorial guidance + +{{guidance}} + +## Editorial contract + +- Preserve every episode id, scene id, transition id, and all episode/scene/transition membership. Do not add or remove an episode, scene, or transition. +- A missing field preserves its current value. A present empty string or `null` intentionally clears a field where the schema permits it. +- When an episode has no valid beat outline, return its complete `storyOutline`, using only that episode's existing scene ids as scene keys. The outline must begin at `startKey`, reach every beat, give a non-ending `cut` exactly one transition, give a non-ending `decision` two to four transitions, and give every ending beat no outgoing transition. +- Outline transition intents must describe genuine audience decisions on decision beats. Automatic story progression belongs in a single `cut` transition, never as a fake choice. +- A scene with multiple incoming paths may set `visualCanon.continuitySourceNodeId` only to one of that scene's direct incoming predecessor scene ids. Use `null` only when intentionally removing an existing override. +- A non-ending teleplay cut must retain exactly one outgoing transition. A decision scene must retain two or more. An ending must retain none. +- Keep the canonical protagonist, wardrobe, participation mode, and world canon intact. Helper-mode audience conversations keep the protagonist off-screen; visible scenes keep the protagonist present. +- Preserve strong material. Fix only concrete structural, continuity, coherence, agency, pacing, or payoff problems supported by the supplied evidence. +- `findings` describes the evaluated state before this patch. `changes` names edits actually represented in the patch. + +Return ONLY valid JSON matching this shape — no prose, markdown fence, or commentary. Omit unchanged patch fields: + +```json +{ + "summary": "concise whole-series editorial assessment", + "strengths": ["specific strength worth preserving"], + "findings": [ + { + "severity": "high", + "category": "coherence", + "episodeId": "existing episode id or null", + "nodeId": "existing scene id or null", + "problem": "concrete problem", + "suggestion": "smallest useful fix" + } + ], + "changes": ["specific applied change"], + "seriesPlan": { + "storyArc": "complete replacement only when changed", + "plotPoints": [], + "sideQuests": [], + "deliveryOptions": {}, + "interEpisodeVoicemails": [], + "nextSeasonTeaser": {} + }, + "episodes": [ + { + "id": "existing episode id", + "title": "only when changed", + "synopsis": "only when changed", + "startNodeId": "existing scene id, only when changed", + "storyOutline": { + "version": 1, + "startKey": "existing scene id", + "scenes": [ + { + "key": "existing scene id", + "title": "beat title", + "summary": "one to three sentence dramatic beat log-line", + "playbackMode": "cut", + "audienceConnection": "connected", + "protagonistPresence": "onscreen", + "isEnding": false, + "endingLabel": "", + "transitions": [ + { + "targetKey": "existing scene id", + "intent": "continue" + } + ] + } + ] + }, + "scenes": [ + { + "id": "existing scene id", + "title": "only when changed", + "prose": "only when changed", + "imagePrompt": "only when changed", + "videoPrompt": "only when changed", + "cameraMovement": "only when changed", + "playbackMode": "only when changed", + "audienceConnection": "only when changed", + "protagonistPresence": "only when changed", + "isEnding": false, + "endingLabel": "only when changed", + "visualCanon": { + "continuitySourceNodeId": "direct incoming predecessor scene id or null" + }, + "transitions": [ + { + "id": "existing transition id", + "targetNodeId": "existing scene id, only when changed", + "intent": "only when changed", + "triggers": ["only when changed"], + "description": "only when changed" + } + ] + } + ] + } + ] +} +``` diff --git a/data.reference/prompts/stages/fableloom-review-playthroughs.md b/data.reference/prompts/stages/fableloom-review-playthroughs.md new file mode 100644 index 000000000..4aebf017f --- /dev/null +++ b/data.reference/prompts/stages/fableloom-review-playthroughs.md @@ -0,0 +1,61 @@ +# FableLoom — Review Every Playthrough Variation + +You are the final narrative quality reviewer for an interactive FableLoom series. The deterministic harness has enumerated the reachable variations. Judge the experience represented by every supplied path, not merely the nominal route. Do not rewrite the story in this pass. + +## Story + +{{storyContext}} + +## World canon + +{{canonDigest}} + +## Series plan and episode outlines + +{{seriesPlanJson}} + +## Expanded teleplay + +{{teleplayDigest}} + +## Enumerated playthroughs + +{{playthroughDigest}} + +## Deterministic findings + +{{deterministicDigest}} + +Evaluate: + +- whether every choice is understandable before it is made and creates a causally legible consequence; +- whether branch-specific knowledge, relationships, objects, injuries, promises, and emotional state remain coherent after convergence; +- whether the protagonist retains agency and the audience participation contract is honored; +- whether each path escalates, turns, pays off, and reaches a satisfying ending without filler or repeated beats; +- whether endings are meaningfully distinct while remaining true to the same canon and thematic argument; +- whether episode handoffs, side quests, visual continuity, and character voices remain consistent across all variations; +- whether the writing is specific, emotionally credible, paced for play, and strong enough to ship. + +Anchor each finding to the most specific supplied episode, path, and scene ids. Do not invent ids. A score of 8 or higher means polished, coherent, high-quality interactive storytelling. Set `passed` to true only when the score is at least 8, no high-severity finding remains, and the deterministic harness reports no failure. + +Return ONLY valid JSON matching this shape — no prose, markdown fence, or commentary: + +```json +{ + "passed": true, + "qualityScore": 8.5, + "summary": "concise verdict across every variation", + "strengths": ["specific strength found across one or more paths"], + "findings": [ + { + "severity": "high", + "category": "coherence", + "episodeId": "supplied episode id or null", + "pathId": "supplied playthrough path id or null", + "nodeId": "supplied scene id or null", + "problem": "concrete observed issue", + "suggestion": "smallest useful revision" + } + ] +} +``` diff --git a/scripts/migrations/320-fableloom-editorial-automation-stages.js b/scripts/migrations/320-fableloom-editorial-automation-stages.js new file mode 100644 index 000000000..17de6291e --- /dev/null +++ b/scripts/migrations/320-fableloom-editorial-automation-stages.js @@ -0,0 +1,8 @@ +/** Seed the FableLoom editorial remediation and playthrough-review stages. */ + +import { makeSeedMigrations } from './_seedStageHelpers.js'; + +export default makeSeedMigrations([ + 'fableloom-editorial-remediate', + 'fableloom-review-playthroughs', +]); diff --git a/scripts/migrations/320-fableloom-editorial-automation-stages.test.js b/scripts/migrations/320-fableloom-editorial-automation-stages.test.js new file mode 100644 index 000000000..941b64614 --- /dev/null +++ b/scripts/migrations/320-fableloom-editorial-automation-stages.test.js @@ -0,0 +1,12 @@ +import { describe } from 'vitest'; + +import migration from './320-fableloom-editorial-automation-stages.js'; +import { runSeedStageMigrationTests } from './_seedStageTestHelpers.js'; + +describe('migration 320 — seed the FableLoom editorial automation stages', () => { + runSeedStageMigrationTests({ + migration, + stages: ['fableloom-editorial-remediate', 'fableloom-review-playthroughs'], + prefix: 'migration-320-', + }); +}); diff --git a/server/lib/README.md b/server/lib/README.md index 39a2c070d..c2dd8f1ca 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -39,6 +39,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `fableLoomProduction.js` | FableLoom production batch planning and topological orchestration: ordered episode storyboard gates, DAG resolution, asset enumeration, execution stages, and exact-input provenance verification. | | `fableLoomContinuity.js` | FableLoom episodic continuity review: multi-vector deterministic checks for visual entity bindings, convergence clarity, voice profile consistency/drift, pronunciation anchors, and playback safety. | | `fableLoomOutline.js` | FableLoom story-first episode beat outlines: bounded log-lines, deterministic arc validation, and compact prompt rendering before teleplay expansion. | +| `fableLoomPlaytest.js` | FableLoom branching-playthrough harness: bounded exhaustive path enumeration, ending/loop/coverage diagnostics, aggregate loom reports, and compact traces for narrative-quality review. | | `postDrillTypes.js` | MeatSpace POST drill vocabularies (`CACHEABLE_TYPES`, `COGNITIVE_DRILL_TYPES`), kept below validation so a route schema never pulls in the POST services (and, through them, the LLM drill generator). | | `spriteVocabulary.js` | Sprite id pattern, record kinds, the canonical 8-direction order + derived anchor set, `TURNAROUND_ID`, and the animation provider ids — the alphabets the sprite route schemas enumerate, kept below the sprite services. | | `spriteChromaKey.js` | Chroma-key selection color math (`CHROMA_KEYS`, `CHROMA_KEY_HEXES`, `DEFAULT_CHROMA_KEY`, hue-distance picking). Pure color math, no image I/O — that lives in `services/sprites/normalize.js`. | diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json index 15f7d3363..5cceb8cc6 100644 --- a/server/lib/apiRouteCatalog.generated.json +++ b/server/lib/apiRouteCatalog.generated.json @@ -8791,7 +8791,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 97 + "line": 107 } ] }, @@ -8802,7 +8802,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 104 + "line": 114 } ] }, @@ -8813,7 +8813,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 120 + "line": 130 } ] }, @@ -8824,7 +8824,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 109 + "line": 119 } ] }, @@ -8835,7 +8835,62 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 115 + "line": 125 + } + ] + }, + { + "method": "GET", + "path": "/api/fableloom/:id/editorial/autopilot/:runId", + "mountPath": "/api/fableloom", + "sources": [ + { + "source": "server/routes/fableLoom.js", + "line": 182 + } + ] + }, + { + "method": "POST", + "path": "/api/fableloom/:id/editorial/autopilot/:runId/cancel", + "mountPath": "/api/fableloom", + "sources": [ + { + "source": "server/routes/fableLoom.js", + "line": 190 + } + ] + }, + { + "method": "POST", + "path": "/api/fableloom/:id/editorial/autopilot/start", + "mountPath": "/api/fableloom", + "sources": [ + { + "source": "server/routes/fableLoom.js", + "line": 169 + } + ] + }, + { + "method": "GET", + "path": "/api/fableloom/:id/editorial/autopilot/status", + "mountPath": "/api/fableloom", + "sources": [ + { + "source": "server/routes/fableLoom.js", + "line": 175 + } + ] + }, + { + "method": "POST", + "path": "/api/fableloom/:id/editorial/remediate", + "mountPath": "/api/fableloom", + "sources": [ + { + "source": "server/routes/fableLoom.js", + "line": 159 } ] }, @@ -8846,7 +8901,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 149 + "line": 200 } ] }, @@ -8857,7 +8912,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 159 + "line": 210 } ] }, @@ -8868,7 +8923,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 154 + "line": 205 } ] }, @@ -8879,7 +8934,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 359 + "line": 410 } ] }, @@ -8890,7 +8945,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 276 + "line": 327 } ] }, @@ -8901,7 +8956,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 197 + "line": 248 } ] }, @@ -8912,7 +8967,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 207 + "line": 258 } ] }, @@ -8923,7 +8978,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 202 + "line": 253 } ] }, @@ -8934,7 +8989,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 263 + "line": 314 } ] }, @@ -8945,7 +9000,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 186 + "line": 237 } ] }, @@ -8956,7 +9011,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 220 + "line": 271 } ] }, @@ -8967,7 +9022,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 232 + "line": 283 } ] }, @@ -8978,7 +9033,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 225 + "line": 276 } ] }, @@ -8989,7 +9044,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 248 + "line": 299 } ] }, @@ -9000,7 +9055,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 258 + "line": 309 } ] }, @@ -9011,7 +9066,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 253 + "line": 304 } ] }, @@ -9022,7 +9077,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 281 + "line": 332 } ] }, @@ -9033,7 +9088,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 330 + "line": 381 } ] }, @@ -9044,7 +9099,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 335 + "line": 386 } ] }, @@ -9055,7 +9110,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 343 + "line": 394 } ] }, @@ -9066,7 +9121,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 351 + "line": 402 } ] }, @@ -9077,7 +9132,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 325 + "line": 376 } ] }, @@ -9088,7 +9143,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 292 + "line": 343 } ] }, @@ -9099,7 +9154,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 268 + "line": 319 } ] }, @@ -9110,7 +9165,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 303 + "line": 354 } ] }, @@ -9121,7 +9176,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 299 + "line": 350 } ] }, @@ -9132,7 +9187,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 170 + "line": 221 } ] }, @@ -9143,7 +9198,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 240 + "line": 291 } ] }, @@ -9154,7 +9209,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 164 + "line": 215 } ] }, @@ -9165,7 +9220,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 142 + "line": 152 } ] }, @@ -9176,7 +9231,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 127 + "line": 137 } ] }, @@ -9187,7 +9242,18 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 132 + "line": 142 + } + ] + }, + { + "method": "POST", + "path": "/api/fableloom/:id/playtest", + "mountPath": "/api/fableloom", + "sources": [ + { + "source": "server/routes/fableLoom.js", + "line": 164 } ] }, @@ -9198,7 +9264,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 137 + "line": 147 } ] }, @@ -9209,7 +9275,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 319 + "line": 370 } ] }, @@ -9220,7 +9286,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 308 + "line": 359 } ] }, @@ -9231,7 +9297,7 @@ "sources": [ { "source": "server/routes/fableLoom.js", - "line": 314 + "line": 365 } ] }, @@ -23632,8 +23698,8 @@ ], "stats": { "mounts": 146, - "operations": 2132, - "declarations": 2135, + "operations": 2138, + "declarations": 2141, "sourceFiles": 226 } } diff --git a/server/lib/fableLoomLimits.js b/server/lib/fableLoomLimits.js index f47f2ee23..3f545f5d3 100644 --- a/server/lib/fableLoomLimits.js +++ b/server/lib/fableLoomLimits.js @@ -27,6 +27,7 @@ export const LOOM_LIMITS = Object.freeze({ EPISODE_TITLE_MAX: 300, SYNOPSIS_MAX: 4000, FEEDBACK_MAX: 4000, + EDITORIAL_AUTOPILOT_ROUNDS_MAX: 6, NODES_MAX: 200, NODE_TITLE_MAX: 300, PROSE_MAX: 20000, diff --git a/server/lib/fableLoomPlaytest.js b/server/lib/fableLoomPlaytest.js new file mode 100644 index 000000000..2c161d3b5 --- /dev/null +++ b/server/lib/fableLoomPlaytest.js @@ -0,0 +1,351 @@ +/** + * FableLoom branching-playthrough test harness. + * + * This is intentionally deterministic and side-effect free: it walks every + * bounded path through an episode graph, records the exact choices taken, and + * reports coverage/non-termination before an optional AI story editor judges + * the resulting narrative paths. The service layer owns that AI review. + */ + +import { analyzeEpisodeGraph } from './fableLoomGraph.js'; + +const asArray = (value) => (Array.isArray(value) ? value : []); +const hasText = (value) => typeof value === 'string' && value.trim().length > 0; + +export const FABLELOOM_PLAYTEST_LIMITS = Object.freeze({ + DEFAULT_MAX_PATHS: 96, + MAX_PATHS: 256, + DEFAULT_MAX_STEPS: 256, + MAX_STEPS: 1000, + MAX_NODE_VISITS: 2, +}); + +export const PLAYTEST_ISSUE_CODES = Object.freeze({ + NO_START: 'NO_START', + DANGLING_PATH: 'DANGLING_PATH', + DEAD_END: 'DEAD_END', + NON_TERMINATING_CYCLE: 'NON_TERMINATING_CYCLE', + STEP_LIMIT: 'STEP_LIMIT', + VARIATION_LIMIT: 'VARIATION_LIMIT', + UNCOVERED_TRANSITION: 'UNCOVERED_TRANSITION', +}); + +const boundedInteger = (value, fallback, max) => ( + Number.isInteger(value) ? Math.max(1, Math.min(max, value)) : fallback +); + +const transitionKey = (nodeId, transition, index) => ( + hasText(transition?.id) ? transition.id : `${nodeId}:transition-${index + 1}` +); + +const publicPath = (path, index) => ({ + id: `path-${index + 1}`, + nodeIds: path.nodeIds, + transitionIds: path.transitionIds, + choices: path.choices, + termination: path.termination, + ended: path.termination === 'ending', + endingNodeId: path.endingNodeId || null, + endingLabel: path.endingLabel || '', + sceneCount: path.nodeIds.length, + ...(path.problemNodeId ? { problemNodeId: path.problemNodeId } : {}), + ...(path.problemTransitionId ? { problemTransitionId: path.problemTransitionId } : {}), +}); + +/** Enumerate every bounded variation through one episode graph. */ +export function enumerateEpisodePlaythroughs(episode, options = {}) { + const nodes = asArray(episode?.nodes); + const byId = new Map(nodes.map((node) => [node.id, node])); + const maxPaths = boundedInteger( + options.maxPaths, + FABLELOOM_PLAYTEST_LIMITS.DEFAULT_MAX_PATHS, + FABLELOOM_PLAYTEST_LIMITS.MAX_PATHS, + ); + const maxSteps = boundedInteger( + options.maxSteps, + FABLELOOM_PLAYTEST_LIMITS.DEFAULT_MAX_STEPS, + FABLELOOM_PLAYTEST_LIMITS.MAX_STEPS, + ); + const maxNodeVisits = boundedInteger( + options.maxNodeVisits, + FABLELOOM_PLAYTEST_LIMITS.MAX_NODE_VISITS, + 10, + ); + const structural = analyzeEpisodeGraph(episode, options.graphOptions); + const rawPaths = []; + let capped = false; + + const record = (path) => { + if (rawPaths.length >= maxPaths) { + capped = true; + return false; + } + rawPaths.push(path); + return true; + }; + + const walk = ({ nodeId, nodeIds, transitionIds, choices, visits }) => { + if (rawPaths.length >= maxPaths) { + capped = true; + return; + } + const node = byId.get(nodeId); + if (!node) { + record({ + nodeIds, + transitionIds, + choices, + termination: 'dangling-path', + problemNodeId: nodeId, + }); + return; + } + + const nextNodeIds = [...nodeIds, node.id]; + if (node.isEnding === true) { + record({ + nodeIds: nextNodeIds, + transitionIds, + choices, + termination: 'ending', + endingNodeId: node.id, + endingLabel: node.endingLabel || node.title || '', + }); + return; + } + if (nextNodeIds.length >= maxSteps) { + record({ + nodeIds: nextNodeIds, + transitionIds, + choices, + termination: 'step-limit', + problemNodeId: node.id, + }); + return; + } + + const transitions = asArray(node.transitions); + if (!transitions.length) { + record({ + nodeIds: nextNodeIds, + transitionIds, + choices, + termination: 'dead-end', + problemNodeId: node.id, + }); + return; + } + + for (const [index, transition] of transitions.entries()) { + if (rawPaths.length >= maxPaths) { + capped = true; + return; + } + const id = transitionKey(node.id, transition, index); + const targetId = transition?.targetNodeId; + const nextTransitionIds = [...transitionIds, id]; + const nextChoices = [...choices, { + nodeId: node.id, + transitionId: id, + intent: transition?.intent || '', + automatic: node.playbackMode === 'cut', + }]; + if (!byId.has(targetId)) { + record({ + nodeIds: nextNodeIds, + transitionIds: nextTransitionIds, + choices: nextChoices, + termination: 'dangling-path', + problemNodeId: node.id, + problemTransitionId: id, + }); + continue; + } + const targetVisits = visits.get(targetId) || 0; + if (targetVisits >= maxNodeVisits) { + record({ + nodeIds: [...nextNodeIds, targetId], + transitionIds: nextTransitionIds, + choices: nextChoices, + termination: 'cycle', + problemNodeId: targetId, + problemTransitionId: id, + }); + continue; + } + const nextVisits = new Map(visits); + nextVisits.set(targetId, targetVisits + 1); + walk({ + nodeId: targetId, + nodeIds: nextNodeIds, + transitionIds: nextTransitionIds, + choices: nextChoices, + visits: nextVisits, + }); + } + }; + + if (hasText(episode?.startNodeId) && byId.has(episode.startNodeId)) { + walk({ + nodeId: episode.startNodeId, + nodeIds: [], + transitionIds: [], + choices: [], + visits: new Map([[episode.startNodeId, 1]]), + }); + } + + const paths = rawPaths.map(publicPath); + const visitedNodeIds = new Set(paths.flatMap((path) => path.nodeIds)); + const visitedTransitionIds = new Set(paths.flatMap((path) => path.transitionIds)); + const allTransitions = nodes.flatMap((node) => asArray(node.transitions).map((transition, index) => ({ + nodeId: node.id, + transitionId: transitionKey(node.id, transition, index), + targetNodeId: transition?.targetNodeId || null, + }))); + const uncoveredTransitions = allTransitions.filter((transition) => ( + !visitedTransitionIds.has(transition.transitionId) + )); + const issues = []; + const push = (code, severity, message, extra = {}) => issues.push({ code, severity, message, ...extra }); + + if (!hasText(episode?.startNodeId) || !byId.has(episode.startNodeId)) { + push(PLAYTEST_ISSUE_CODES.NO_START, 'error', 'Playthrough testing cannot start because the opening scene is missing.'); + } + for (const path of paths) { + if (path.termination === 'dangling-path') { + push(PLAYTEST_ISSUE_CODES.DANGLING_PATH, 'error', `Variation ${path.id} follows a path to a missing scene.`, { + pathId: path.id, + nodeId: path.problemNodeId, + ...(path.problemTransitionId ? { transitionId: path.problemTransitionId } : {}), + }); + } else if (path.termination === 'dead-end') { + push(PLAYTEST_ISSUE_CODES.DEAD_END, 'error', `Variation ${path.id} stops at a scene that is not an ending.`, { + pathId: path.id, + nodeId: path.problemNodeId, + }); + } else if (path.termination === 'cycle') { + push(PLAYTEST_ISSUE_CODES.NON_TERMINATING_CYCLE, 'error', `Variation ${path.id} can repeat a cycle without reaching an ending.`, { + pathId: path.id, + nodeId: path.problemNodeId, + ...(path.problemTransitionId ? { transitionId: path.problemTransitionId } : {}), + }); + } else if (path.termination === 'step-limit') { + push(PLAYTEST_ISSUE_CODES.STEP_LIMIT, 'error', `Variation ${path.id} exceeded the ${maxSteps}-scene safety limit.`, { + pathId: path.id, + nodeId: path.problemNodeId, + }); + } + } + if (capped) { + push( + PLAYTEST_ISSUE_CODES.VARIATION_LIMIT, + 'warning', + `Playthrough enumeration reached the ${maxPaths}-variation limit; the report is representative rather than exhaustive.`, + ); + } + for (const transition of uncoveredTransitions) { + push( + PLAYTEST_ISSUE_CODES.UNCOVERED_TRANSITION, + capped ? 'warning' : 'error', + 'A story path was not exercised by the generated playthrough variations.', + transition, + ); + } + + const endingCounts = Object.fromEntries(nodes.filter((node) => node.isEnding).map((node) => [ + node.id, + paths.filter((path) => path.endingNodeId === node.id).length, + ])); + const errorCount = issues.filter((issue) => issue.severity === 'error').length; + const warningCount = issues.filter((issue) => issue.severity === 'warning').length; + const enumerationComplete = !capped; + return { + episodeId: episode?.id || null, + structural, + paths, + issues, + stats: { + variationCount: paths.length, + endingVariationCount: paths.filter((path) => path.ended).length, + nonEndingVariationCount: paths.filter((path) => !path.ended).length, + nodeCount: nodes.length, + visitedNodeCount: visitedNodeIds.size, + transitionCount: allTransitions.length, + visitedTransitionCount: visitedTransitionIds.size, + endingCounts, + errorCount, + warningCount, + enumerationComplete, + passed: enumerationComplete + && errorCount === 0 + && structural.stats.errorCount === 0 + && paths.length > 0, + }, + }; +} + +/** Aggregate the deterministic harness across every episode in a loom. */ +export function analyzeLoomPlaythroughs(loom, options = {}) { + const episodes = asArray(loom?.episodes).map((episode, index) => ({ + number: episode.number || index + 1, + title: episode.title || `Episode ${episode.number || index + 1}`, + ...enumerateEpisodePlaythroughs(episode, { + ...options, + graphOptions: { + participationMode: loom?.participationMode, + requireAudienceIntroduction: index === 0, + }, + }), + })); + const errorCount = episodes.reduce((total, episode) => ( + total + episode.stats.errorCount + episode.structural.stats.errorCount + ), 0); + const warningCount = episodes.reduce((total, episode) => ( + total + episode.stats.warningCount + episode.structural.stats.warningCount + ), 0); + return { + passed: episodes.length > 0 && episodes.every((episode) => episode.stats.passed), + complete: episodes.length > 0 && episodes.every((episode) => episode.stats.enumerationComplete), + stats: { + episodeCount: episodes.length, + variationCount: episodes.reduce((total, episode) => total + episode.stats.variationCount, 0), + endingVariationCount: episodes.reduce((total, episode) => total + episode.stats.endingVariationCount, 0), + nonEndingVariationCount: episodes.reduce((total, episode) => total + episode.stats.nonEndingVariationCount, 0), + nodeCount: episodes.reduce((total, episode) => total + episode.stats.nodeCount, 0), + visitedNodeCount: episodes.reduce((total, episode) => total + episode.stats.visitedNodeCount, 0), + transitionCount: episodes.reduce((total, episode) => total + episode.stats.transitionCount, 0), + visitedTransitionCount: episodes.reduce((total, episode) => total + episode.stats.visitedTransitionCount, 0), + errorCount, + warningCount, + }, + episodes, + }; +} + +/** Compact path traces for the AI playthrough-quality stage. */ +export function describeLoomPlaythroughsForPrompt(loom, report) { + const episodesById = new Map(asArray(loom?.episodes).map((episode) => [episode.id, episode])); + return asArray(report?.episodes).map((episodeReport) => { + const episode = episodesById.get(episodeReport.episodeId); + const nodesById = new Map(asArray(episode?.nodes).map((node) => [node.id, node])); + const traces = episodeReport.paths.map((path) => { + const beats = path.nodeIds.map((nodeId, index) => { + const node = nodesById.get(nodeId); + const choice = path.choices[index]; + const label = node?.title || nodeId; + if (!choice) return label; + return `${label} --${choice.automatic ? 'auto' : 'choice'}: ${choice.intent || '(unlabeled)'}-->`; + }); + const end = path.ended + ? `END: ${path.endingLabel || path.endingNodeId}` + : `STOPPED: ${path.termination}`; + return `[${path.id}] ${[...beats, end].join(' ')}`; + }); + return [ + `## Episode ${episodeReport.number}: ${episodeReport.title}`, + `${episodeReport.stats.variationCount} variation(s); ${episodeReport.stats.visitedTransitionCount}/${episodeReport.stats.transitionCount} paths exercised; exhaustive: ${episodeReport.stats.enumerationComplete ? 'yes' : 'no'}`, + ...traces, + ].join('\n'); + }).join('\n\n'); +} diff --git a/server/lib/fableLoomPlaytest.test.js b/server/lib/fableLoomPlaytest.test.js new file mode 100644 index 000000000..b9d7eed5a --- /dev/null +++ b/server/lib/fableLoomPlaytest.test.js @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { + analyzeLoomPlaythroughs, + describeLoomPlaythroughsForPrompt, + enumerateEpisodePlaythroughs, + PLAYTEST_ISSUE_CODES, +} from './fableLoomPlaytest.js'; + +const transition = (id, targetNodeId, intent) => ({ id, targetNodeId, intent, triggers: [] }); + +const branchingEpisode = () => ({ + id: 'episode-1', + number: 1, + title: 'Example Episode', + startNodeId: 'opening', + nodes: [ + { + id: 'opening', title: 'Opening', prose: 'The signal splits in two.', playbackMode: 'decision', + audienceConnection: 'connected', isEnding: false, + transitions: [ + transition('take-left', 'left', 'Take the left route'), + transition('take-right', 'right', 'Take the right route'), + ], + }, + { + id: 'left', title: 'Left Route', prose: 'The traveler crosses the glass bridge.', playbackMode: 'cut', + audienceConnection: 'disconnected', isEnding: false, + transitions: [transition('left-end', 'ending', 'Continue')], + }, + { + id: 'right', title: 'Right Route', prose: 'The traveler follows the buried wire.', playbackMode: 'cut', + audienceConnection: 'disconnected', isEnding: false, + transitions: [transition('right-end', 'ending', 'Continue')], + }, + { + id: 'ending', title: 'Shared Ending', prose: 'Both routes reveal the same beacon.', + playbackMode: 'decision', audienceConnection: 'connected', isEnding: true, + endingLabel: 'Beacon found', transitions: [], + }, + ], +}); + +describe('enumerateEpisodePlaythroughs', () => { + it('exercises every branch through convergence and records ending coverage', () => { + const report = enumerateEpisodePlaythroughs(branchingEpisode()); + + expect(report.stats).toMatchObject({ + variationCount: 2, + endingVariationCount: 2, + nonEndingVariationCount: 0, + visitedNodeCount: 4, + visitedTransitionCount: 4, + transitionCount: 4, + enumerationComplete: true, + passed: true, + }); + expect(report.paths.map((path) => path.transitionIds)).toEqual([ + ['take-left', 'left-end'], + ['take-right', 'right-end'], + ]); + expect(report.stats.endingCounts).toEqual({ ending: 2 }); + }); + + it('reports a repeatable graph cycle while still testing the exit variation', () => { + const episode = branchingEpisode(); + episode.nodes = [ + { + id: 'opening', title: 'Looping Choice', prose: 'The relay asks again.', + playbackMode: 'decision', audienceConnection: 'connected', isEnding: false, + transitions: [ + transition('again', 'opening', 'Ask again'), + transition('finish', 'ending', 'Finish'), + ], + }, + episode.nodes.at(-1), + ]; + const report = enumerateEpisodePlaythroughs(episode); + + expect(report.paths.some((path) => path.termination === 'cycle')).toBe(true); + expect(report.paths.some((path) => path.termination === 'ending')).toBe(true); + expect(report.issues.some((issue) => issue.code === PLAYTEST_ISSUE_CODES.NON_TERMINATING_CYCLE)).toBe(true); + expect(report.stats.passed).toBe(false); + }); + + it('makes a variation cap explicit and marks untested paths as warnings', () => { + const episode = branchingEpisode(); + episode.nodes[0].transitions = [ + transition('to-a', 'a', 'A'), + transition('to-b', 'b', 'B'), + transition('to-c', 'c', 'C'), + ]; + episode.nodes = [ + episode.nodes[0], + ...['a', 'b', 'c'].map((id) => ({ + id, title: id.toUpperCase(), prose: `${id} ending`, playbackMode: 'decision', + audienceConnection: 'connected', isEnding: true, endingLabel: id, transitions: [], + })), + ]; + + const report = enumerateEpisodePlaythroughs(episode, { maxPaths: 2 }); + + expect(report.stats.variationCount).toBe(2); + expect(report.stats.enumerationComplete).toBe(false); + expect(report.issues).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: PLAYTEST_ISSUE_CODES.VARIATION_LIMIT, severity: 'warning' }), + expect.objectContaining({ code: PLAYTEST_ISSUE_CODES.UNCOVERED_TRANSITION, severity: 'warning' }), + ])); + }); +}); + +describe('analyzeLoomPlaythroughs', () => { + it('aggregates episode coverage and renders compact story traces', () => { + const episode = branchingEpisode(); + const loom = { participationMode: 'helper', episodes: [episode] }; + const report = analyzeLoomPlaythroughs(loom); + const digest = describeLoomPlaythroughsForPrompt(loom, report); + + expect(report).toMatchObject({ + passed: true, + complete: true, + stats: { episodeCount: 1, variationCount: 2, visitedTransitionCount: 4 }, + }); + expect(digest).toContain('## Episode 1: Example Episode'); + expect(digest).toContain('Opening --choice: Take the left route-->'); + expect(digest).toContain('END: Beacon found'); + }); +}); diff --git a/server/lib/fableLoomValidation.js b/server/lib/fableLoomValidation.js index 9dc935bdc..d40e4ac08 100644 --- a/server/lib/fableLoomValidation.js +++ b/server/lib/fableLoomValidation.js @@ -22,6 +22,7 @@ import { FABLELOOM_ASSET_TYPES, FABLELOOM_PRODUCTION_MODES, } from './fableLoomProduction.js'; +import { FABLELOOM_PLAYTEST_LIMITS } from './fableLoomPlaytest.js'; import { EFFORT_LEVELS } from './providerModels.js'; import { QUEUEABLE_IMAGE_MODES, VIDEO_GEN_MODES } from './generationModes.js'; import { llmRoutePinSchema } from './llmRoutePin.js'; @@ -343,6 +344,23 @@ export const seriesPlanFeedbackSchema = z.object({ ...aiRunFields, }); +export const editorialRemediateSchema = z.object({ + guidance: z.string().max(LOOM_LIMITS.FEEDBACK_MAX).optional(), + ...aiRunFields, +}); + +export const playthroughReviewSchema = z.object({ + aiReview: z.boolean().optional().default(true), + maxPaths: z.number().int().min(1).max(FABLELOOM_PLAYTEST_LIMITS.MAX_PATHS).optional(), + ...aiRunFields, +}); + +export const editorialAutopilotStartSchema = z.object({ + maxRounds: z.number().int().min(1).max(LOOM_LIMITS.EDITORIAL_AUTOPILOT_ROUNDS_MAX).optional(), + maxPaths: z.number().int().min(1).max(FABLELOOM_PLAYTEST_LIMITS.MAX_PATHS).optional(), + ...llmPickFields, +}); + export const hostedSessionCreateSchema = z.object({ audioTarget: z.enum(FABLELOOM_AUDIO_TARGETS).optional(), startNodeId: nodeIdStr.optional(), diff --git a/server/lib/index.js b/server/lib/index.js index 8792d8ea6..d7ada2901 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -89,6 +89,7 @@ export * from './fableLoomFormats.js'; export * from './fableLoomProduction.js'; export * from './fableLoomContinuity.js'; export * from './fableLoomOutline.js'; +export * from './fableLoomPlaytest.js'; export * from './scenePrompt.js'; export * from './proseExportSettings.js'; export * from './shotGrammar.js'; diff --git a/server/routes/fableLoom.js b/server/routes/fableLoom.js index 43f660450..e8e70b724 100644 --- a/server/routes/fableLoom.js +++ b/server/routes/fableLoom.js @@ -38,6 +38,9 @@ import { productionPlanSchema, productionBatchCreateSchema, continuityReviewSchema, + editorialAutopilotStartSchema, + editorialRemediateSchema, + playthroughReviewSchema, } from '../lib/fableLoomValidation.js'; import { analyzeEpisodeGraph } from '../lib/fableLoomGraph.js'; import { analyzeSeriesStoryOutlines } from '../lib/fableLoomOutline.js'; @@ -85,6 +88,13 @@ import { updateNodeTransition, validateEpisodeOutline, weaveEpisode, + cancelFableLoomEditorialAutopilot, + evaluateAndRemediateFableLoom, + getFableLoomEditorialAutopilot, + getLatestFableLoomEditorialAutopilot, + publicFableLoomEditorialAutopilot, + reviewFableLoomPlaythroughs, + startFableLoomEditorialAutopilot, } from '../services/fableLoom/index.js'; const router = Router(); @@ -144,6 +154,47 @@ router.post('/:id/plan/feedback', asyncHandler(async (req, res) => { res.json(await feedbackSeriesPlan(req.params.id, input)); })); +// --- Whole-series editorial automation ------------------------------------- + +router.post('/:id/editorial/remediate', asyncHandler(async (req, res) => { + const input = validateRequest(editorialRemediateSchema, req.body ?? {}); + res.json(await evaluateAndRemediateFableLoom(req.params.id, input)); +})); + +router.post('/:id/playtest', asyncHandler(async (req, res) => { + const input = validateRequest(playthroughReviewSchema, req.body ?? {}); + res.json(await reviewFableLoomPlaythroughs(req.params.id, input)); +})); + +router.post('/:id/editorial/autopilot/start', asyncHandler(async (req, res) => { + const input = validateRequest(editorialAutopilotStartSchema, req.body ?? {}); + const run = await startFableLoomEditorialAutopilot(req.params.id, input); + res.status(run.alreadyRunning ? 200 : 202).json(publicFableLoomEditorialAutopilot(run)); +})); + +router.get('/:id/editorial/autopilot/status', asyncHandler(async (req, res) => { + const loom = await getLoom(req.params.id); + if (!loom) throw new ServerError('Loom not found', { status: 404, code: 'NOT_FOUND' }); + const run = getLatestFableLoomEditorialAutopilot(req.params.id); + res.json({ run: publicFableLoomEditorialAutopilot(run) }); +})); + +router.get('/:id/editorial/autopilot/:runId', asyncHandler(async (req, res) => { + const run = getFableLoomEditorialAutopilot(req.params.runId); + if (!run || run.loomId !== req.params.id) { + throw new ServerError('Editorial autopilot run not found', { status: 404, code: 'NOT_FOUND' }); + } + res.json(publicFableLoomEditorialAutopilot(run)); +})); + +router.post('/:id/editorial/autopilot/:runId/cancel', asyncHandler(async (req, res) => { + const run = getFableLoomEditorialAutopilot(req.params.runId); + if (!run || run.loomId !== req.params.id) { + throw new ServerError('Editorial autopilot run not found', { status: 404, code: 'NOT_FOUND' }); + } + res.json(publicFableLoomEditorialAutopilot(cancelFableLoomEditorialAutopilot(req.params.runId))); +})); + // --- Episodes --------------------------------------------------------------- router.post('/:id/episodes', asyncHandler(async (req, res) => { diff --git a/server/routes/fableLoom.test.js b/server/routes/fableLoom.test.js index 4035ad673..e7f844e3c 100644 --- a/server/routes/fableLoom.test.js +++ b/server/routes/fableLoom.test.js @@ -8,6 +8,7 @@ vi.mock('../services/fableLoom/index.js', () => ({ addNode: vi.fn(), addNodeTransition: vi.fn(), branchNode: vi.fn(), + cancelFableLoomEditorialAutopilot: vi.fn(), createLoom: vi.fn(), deleteEpisode: vi.fn(), deleteLoom: vi.fn(), @@ -17,14 +18,20 @@ vi.mock('../services/fableLoom/index.js', () => ({ feedbackSeriesPlan: vi.fn(), generateEpisodeOutline: vi.fn(), generateSeriesPlan: vi.fn(), + getFableLoomEditorialAutopilot: vi.fn(), + getLatestFableLoomEditorialAutopilot: vi.fn(), getLoom: vi.fn(), listLoomSummaries: vi.fn(async () => []), playTurn: vi.fn(), + publicFableLoomEditorialAutopilot: vi.fn((run) => run), reformatEpisodeScenes: vi.fn(), reviewEpisode: vi.fn(), reviewEpisodeOutline: vi.fn(), + reviewFableLoomPlaythroughs: vi.fn(), reviewSeriesPlan: vi.fn(), reviewSeriesTeleplay: vi.fn(), + evaluateAndRemediateFableLoom: vi.fn(), + startFableLoomEditorialAutopilot: vi.fn(), updateEpisode: vi.fn(), updateLoom: vi.fn(), updateNode: vi.fn(), @@ -153,6 +160,77 @@ describe('FableLoom routes', () => { }); }); + it('runs validated whole-series remediation and branching playthrough review', async () => { + fableLoom.evaluateAndRemediateFableLoom.mockResolvedValueOnce({ + loom: { id: 'loom-1' }, changed: true, + }); + const remediated = await request(makeApp()) + .post('/api/fableloom/loom-1/editorial/remediate') + .send({ + guidance: 'Preserve the quiet ending.', providerId: 'writer', effort: 'high', + operationId: '00000000-0000-4000-8000-000000000001', + }); + expect(remediated.status).toBe(200); + expect(fableLoom.evaluateAndRemediateFableLoom).toHaveBeenCalledWith('loom-1', { + guidance: 'Preserve the quiet ending.', providerId: 'writer', effort: 'high', + operationId: '00000000-0000-4000-8000-000000000001', + }); + + fableLoom.reviewFableLoomPlaythroughs.mockResolvedValueOnce({ passed: true }); + const reviewed = await request(makeApp()) + .post('/api/fableloom/loom-1/playtest') + .send({ aiReview: true, maxPaths: 128, model: 'large' }); + expect(reviewed.status).toBe(200); + expect(fableLoom.reviewFableLoomPlaythroughs).toHaveBeenCalledWith('loom-1', { + aiReview: true, maxPaths: 128, model: 'large', + }); + + const invalid = await request(makeApp()) + .post('/api/fableloom/loom-1/playtest') + .send({ maxPaths: 257 }); + expect(invalid.status).toBe(400); + expect(fableLoom.reviewFableLoomPlaythroughs).toHaveBeenCalledTimes(1); + }); + + it('starts, reads, and cooperatively cancels a loom-scoped editorial autopilot', async () => { + const running = { + id: 'editorial-run-1', loomId: 'loom-1', status: 'running', round: 1, maxRounds: 3, + }; + fableLoom.startFableLoomEditorialAutopilot.mockResolvedValueOnce(running); + const started = await request(makeApp()) + .post('/api/fableloom/loom-1/editorial/autopilot/start') + .send({ maxRounds: 3, maxPaths: 128, providerId: 'writer' }); + expect(started.status).toBe(202); + expect(fableLoom.startFableLoomEditorialAutopilot).toHaveBeenCalledWith('loom-1', { + maxRounds: 3, maxPaths: 128, providerId: 'writer', + }); + + fableLoom.getLoom.mockResolvedValueOnce({ id: 'loom-1' }); + fableLoom.getLatestFableLoomEditorialAutopilot.mockReturnValueOnce(running); + const status = await request(makeApp()) + .get('/api/fableloom/loom-1/editorial/autopilot/status'); + expect(status.status).toBe(200); + expect(status.body.run).toMatchObject({ id: 'editorial-run-1', status: 'running' }); + + fableLoom.getFableLoomEditorialAutopilot.mockReturnValueOnce(running); + const fetched = await request(makeApp()) + .get('/api/fableloom/loom-1/editorial/autopilot/editorial-run-1'); + expect(fetched.status).toBe(200); + + fableLoom.getFableLoomEditorialAutopilot.mockReturnValueOnce(running); + fableLoom.cancelFableLoomEditorialAutopilot.mockReturnValueOnce({ ...running, status: 'canceling' }); + const canceled = await request(makeApp()) + .post('/api/fableloom/loom-1/editorial/autopilot/editorial-run-1/cancel') + .send({}); + expect(canceled.status).toBe(200); + expect(canceled.body.status).toBe('canceling'); + + fableLoom.getFableLoomEditorialAutopilot.mockReturnValueOnce({ ...running, loomId: 'loom-other' }); + const wrongLoom = await request(makeApp()) + .get('/api/fableloom/loom-1/editorial/autopilot/editorial-run-1'); + expect(wrongLoom.status).toBe(404); + }); + it('validates and forwards structured series-plan patches', async () => { const seriesPlan = { storyArc: 'A courier becomes a leader.', diff --git a/server/services/fableLoom/README.md b/server/services/fableLoom/README.md index 249391fb0..4dffde1c0 100644 --- a/server/services/fableLoom/README.md +++ b/server/services/fableLoom/README.md @@ -10,6 +10,8 @@ intent to a transition and moves them through the graph until an ending. | `records.js` | Sanitizer + CRUD + peer LWW/tombstone merge for looms/episodes/nodes; transitions are addressable one at a time (`addNodeTransition` / `updateNodeTransition` / `deleteNodeTransition`) as well as replaceable as a whole array via the node patch; `attachNodeImage`, `attachNodeVideo`, and `attachNodePlaybackAsset` for media-job hooks. | | `visualConditioning.js` | Compiles stable scene canon bindings into capability-budgeted prompts, typed reference assets, local character adapters, and durable render provenance. | | `weave.js` | AI ops via `runStagedLLM`: `generateSeriesPlan` (full arc / plot-point / side-quest scaffold), `generateEpisodeOutline` + `validateEpisodeOutline` + `reviewEpisodeOutline` (log-line beat planning before teleplay expansion), `weaveEpisode` (single-camera-cut graph with automatic cuts and looping decisions), `branchNode` (grow paths), `feedbackEpisode` (apply a conversational sparse patch to one episode), `reviewEpisode` + `reviewSeriesTeleplay` (episode or complete-series critique with deterministic analysis), `playTurn` (reader intent → transition; tapped/automatic paths resolve with NO LLM call), `reformatEpisodeScenes` (rewrite ONE episode's scenes into another format; the loom's format pin lands only once every episode is converted). | +| `editorial.js` | Whole-series AI evaluate-and-remediate pass plus deterministic/AI playthrough review. Preserves episode/scene/path membership and IDs, validates generated outlines, rejects graph regressions, and applies story-aware convergence sources. | +| `editorialAutopilot.js` | User-triggered bounded editor/reviewer loop: remediate, exercise every bounded branch variation, judge story quality, then complete, pause on residuals/plateau, fail, or cancel cooperatively. | | `formats.js` | Scene formats (`prose` / `teleplay`) and the prompt contracts each generative stage renders for them. | | `hostedSession.js` | Scoped QR-hosted play session lifecycle, HTTPS readiness preflight, token hashing, live voice gate revalidation, and half-duplex turn taking (#5383). | | `production.js` | Episodic production orchestration: batch planning, DAG generation, cancellable batch runs, and user-triggered episodic continuity review (#5384). | diff --git a/server/services/fableLoom/editorial.js b/server/services/fableLoom/editorial.js new file mode 100644 index 000000000..e0b0a95a0 --- /dev/null +++ b/server/services/fableLoom/editorial.js @@ -0,0 +1,593 @@ +/** + * FableLoom whole-series editorial automation. + * + * One editor call can diagnose and repair the series plan, missing/invalid beat + * outlines, existing scene metadata, path labels/targets, and convergence + * continuity without changing episode, scene, or transition membership. A + * separate playthrough judge reviews the deterministic variation harness after + * edits land; the autopilot composes the two bounded operations. + */ + +import { ServerError } from '../../lib/errorHandler.js'; +import { analyzeEpisodeContinuity, CONTINUITY_CODES } from '../../lib/fableLoomContinuity.js'; +import { analyzeEpisodeGraph, describeGraphForPrompt } from '../../lib/fableLoomGraph.js'; +import { + analyzeSeriesStoryOutlines, + analyzeStoryOutline, + describeStoryOutlineForPrompt, + sanitizeStoryOutline, +} from '../../lib/fableLoomOutline.js'; +import { + analyzeLoomPlaythroughs, + describeLoomPlaythroughsForPrompt, +} from '../../lib/fableLoomPlaytest.js'; +import { computeTopologicalNodeOrder } from '../../lib/fableLoomProduction.js'; +import { + isFableLoomPlaybackMode, + FABLELOOM_PROTAGONIST_PRESENCE, +} from '../../lib/fableLoomPlayback.js'; +import { trimTo } from '../../lib/storyBible.js'; +import { normalizeFableLoomCameraMovement } from '../../lib/fableLoomCameraMovements.js'; +import { startAIOp } from '../aiStatusEvents.js'; +import { runStagedLLM } from '../stageRunner.js'; +import { getUniverse } from '../universeBuilder.js'; +import { listVoiceProfiles } from '../voice/profiles.js'; +import { buildCanonDigest } from './weave.js'; +import { + getLoom, + mutateLoom, + sanitizeLoom, +} from './records.js'; + +const REVIEW_SEVERITIES = new Set(['high', 'medium', 'low']); +const REVIEW_CATEGORIES = new Set([ + 'coherence', 'character', 'choice', 'pacing', 'ending', 'continuity', 'canon', 'structure', +]); +const AUTOPILOT_QUALITY_THRESHOLD = 8; + +const asArray = (value) => (Array.isArray(value) ? value : []); +const hasText = (value) => typeof value === 'string' && value.trim().length > 0; +const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); +const clampScore = (value) => (Number.isFinite(value) + ? Math.max(0, Math.min(10, Math.round(value * 10) / 10)) + : null); + +const aiShapeError = (message) => new ServerError(message, { + status: 502, + code: 'AI_RESPONSE_INVALID', +}); + +const requireLoom = async (loomId) => { + const loom = await getLoom(loomId); + if (!loom) throw new ServerError('Loom not found', { status: 404, code: 'NOT_FOUND' }); + return loom; +}; + +const llmOptions = ({ providerId, model, effort } = {}, source) => ({ + source, + returnsJson: true, + ...(providerId ? { providerOverride: providerId } : {}), + ...(model ? { modelOverride: model } : {}), + ...(effort ? { effortOverride: effort } : {}), +}); + +const runEditorialAi = (stage, variables, route, { action, label, source }) => { + const status = route.operationId ? startAIOp({ + op: `fableloom-${action}`, + label, + operationId: route.operationId, + localOnly: true, + silent: true, + }) : null; + const options = llmOptions(route, source); + if (status) { + options.onRunCreated = (runId, meta = {}) => status.update( + 'running', + `${label} is running…`, + { ...meta, runId, shellReady: false }, + ); + options.onRunReady = (meta = {}) => status.update( + 'ready', + 'TUI run is ready — open Shell to watch and interact', + meta, + ); + options.onRunSettled = (runId) => status.update( + 'applying', + 'AI response received — validating the story changes…', + { runId }, + ); + } + return runStagedLLM(stage, variables, options).then((result) => { + status?.complete('Editorial result ready', { runId: result.runId, shellReady: false }); + return result; + }, (error) => { + status?.error(error?.message || 'FableLoom editorial operation failed', { + ...(error?.runId ? { runId: error.runId } : {}), + }); + throw error; + }); +}; + +const storyContext = (loom) => [ + `Story: ${loom.name}`, + loom.logline ? `Logline: ${loom.logline}` : '', + loom.premise ? `Premise: ${loom.premise}` : '', + loom.styleNotes ? `Style: ${loom.styleNotes}` : '', + `Audience participation mode: ${loom.participationMode || 'protagonist'}`, + loom.audienceCommunicationMedium + ? `Audience communication medium: ${loom.audienceCommunicationMedium}` + : '', + loom.protagonistCharacterId + ? `Canonical protagonist id: ${loom.protagonistCharacterId}` + : '', + loom.protagonistWardrobeId + ? `Canonical protagonist wardrobe id: ${loom.protagonistWardrobeId}` + : '', +].filter(Boolean).join('\n'); + +const seriesPlanDigest = (loom) => JSON.stringify({ + storyArc: trimTo(loom.seriesPlan?.storyArc, 6000), + plotPoints: asArray(loom.seriesPlan?.plotPoints), + sideQuests: asArray(loom.seriesPlan?.sideQuests), + deliveryOptions: loom.seriesPlan?.deliveryOptions || null, + interEpisodeVoicemails: asArray(loom.seriesPlan?.interEpisodeVoicemails), + nextSeasonTeaser: loom.seriesPlan?.nextSeasonTeaser || null, + episodes: asArray(loom.episodes).map((episode) => ({ + id: episode.id, + number: episode.number, + title: episode.title, + synopsis: trimTo(episode.synopsis, 600), + storyOutline: episode.storyOutline + ? describeStoryOutlineForPrompt(episode.storyOutline) + : '(missing)', + })), +}, null, 2); + +const teleplayDigest = (loom) => asArray(loom.episodes).map((episode) => [ + `## Episode ${episode.number}: ${episode.title || 'Untitled'}`, + `Episode id: ${episode.id}`, + episode.synopsis ? `Synopsis: ${trimTo(episode.synopsis, 600)}` : '', + episode.storyOutline + ? `Beat outline:\n${describeStoryOutlineForPrompt(episode.storyOutline)}` + : 'Beat outline: (missing)', + episode.nodes.length + ? describeGraphForPrompt(episode, { proseLimit: 1000, participationMode: loom.participationMode }) + : '(no expanded teleplay scenes)', +].filter(Boolean).join('\n')).join('\n\n'); + +const editorialFingerprint = (loom) => JSON.stringify({ + name: loom.name, + logline: loom.logline, + premise: loom.premise, + styleNotes: loom.styleNotes, + participationMode: loom.participationMode, + audienceCommunicationMedium: loom.audienceCommunicationMedium, + protagonistCharacterId: loom.protagonistCharacterId, + protagonistWardrobeId: loom.protagonistWardrobeId, + protagonistWardrobeLocked: loom.protagonistWardrobeLocked, + seriesPlan: loom.seriesPlan, + episodes: loom.episodes, +}); + +/** Assemble every deterministic series-level authoring/playthrough signal. */ +export async function collectFableLoomEditorialDiagnostics(loom) { + const [universe, voiceProfiles] = await Promise.all([ + loom.universeId ? getUniverse(loom.universeId).catch(() => null) : null, + listVoiceProfiles().catch(() => []), + ]); + const outline = analyzeSeriesStoryOutlines(loom); + const playthrough = analyzeLoomPlaythroughs(loom); + const episodes = loom.episodes.map((episode, index) => { + const graph = analyzeEpisodeGraph(episode, { + participationMode: loom.participationMode, + requireAudienceIntroduction: index === 0, + }); + const continuity = analyzeEpisodeContinuity({ + loom, + episode, + universe, + localVoiceProfiles: voiceProfiles, + }); + const playtest = playthrough.episodes.find((item) => item.episodeId === episode.id); + return { + episodeId: episode.id, + number: episode.number || index + 1, + title: episode.title || `Episode ${episode.number || index + 1}`, + graph, + continuity, + playtest, + }; + }); + const graphErrors = episodes.reduce((total, episode) => total + episode.graph.stats.errorCount, 0); + const graphWarnings = episodes.reduce((total, episode) => total + episode.graph.stats.warningCount, 0); + const continuityErrors = episodes.reduce((total, episode) => total + episode.continuity.summary.errors, 0); + const continuityWarnings = episodes.reduce((total, episode) => total + episode.continuity.summary.warnings, 0); + const convergenceIssues = episodes.reduce((total, episode) => total + episode.continuity.findings.filter((finding) => ( + finding.code === CONTINUITY_CODES.AMBIGUOUS_CONVERGENCE + )).length, 0); + const playthroughErrors = episodes.reduce((total, episode) => ( + total + (episode.playtest?.stats.errorCount || 0) + ), 0); + const stats = { + outlineErrors: outline.stats.errorCount, + outlineWarnings: outline.stats.warningCount, + graphErrors, + graphWarnings, + continuityErrors, + continuityWarnings, + convergenceIssues, + playthroughErrors, + variationCount: playthrough.stats.variationCount, + endingVariationCount: playthrough.stats.endingVariationCount, + visitedTransitionCount: playthrough.stats.visitedTransitionCount, + transitionCount: playthrough.stats.transitionCount, + }; + return { + passed: outline.stats.ready + && graphErrors === 0 + && continuityErrors === 0 + && convergenceIssues === 0 + && playthrough.passed, + outline, + playthrough, + episodes, + stats, + }; +} + +const diagnosticLines = (diagnostics) => { + const lines = [ + `Series outlines: ${diagnostics.stats.outlineErrors} error(s), ${diagnostics.stats.outlineWarnings} warning(s).`, + `Episode graphs: ${diagnostics.stats.graphErrors} error(s), ${diagnostics.stats.graphWarnings} warning(s).`, + `Continuity: ${diagnostics.stats.continuityErrors} error(s), ${diagnostics.stats.continuityWarnings} warning(s), ${diagnostics.stats.convergenceIssues} ambiguous convergence scene(s).`, + `Playthroughs: ${diagnostics.stats.variationCount} variation(s), ${diagnostics.stats.endingVariationCount} ending path(s), ${diagnostics.stats.visitedTransitionCount}/${diagnostics.stats.transitionCount} transitions exercised.`, + ]; + diagnostics.outline.issues.forEach((issue) => lines.push( + `- [outline/${issue.severity}] episode=${issue.episodeId || 'series'} scene=${issue.sceneKey || '-'} code=${issue.code}: ${issue.message}`, + )); + diagnostics.episodes.forEach((episode) => { + episode.graph.issues.forEach((issue) => lines.push( + `- [graph/${issue.severity}] episode=${episode.episodeId} node=${issue.nodeId || '-'} code=${issue.code}: ${issue.message}`, + )); + episode.continuity.findings.forEach((finding) => lines.push( + `- [continuity/${finding.severity}] episode=${episode.episodeId} node=${finding.nodeId || '-'} code=${finding.code}: ${finding.message} Fix: ${finding.remediation}`, + )); + asArray(episode.playtest?.issues).forEach((issue) => lines.push( + `- [playthrough/${issue.severity}] episode=${episode.episodeId} path=${issue.pathId || '-'} node=${issue.nodeId || '-'} code=${issue.code}: ${issue.message}`, + )); + }); + return lines.join('\n'); +}; + +const analysisStrings = (value) => asArray(value) + .filter((item) => typeof item === 'string') + .map((item) => trimTo(item, 1000)) + .filter(Boolean) + .slice(0, 20); + +const sanitizeEvaluation = (content, loom) => { + const episodeIds = new Set(loom.episodes.map((episode) => episode.id)); + const nodesByEpisode = new Map(loom.episodes.map((episode) => [ + episode.id, + new Set(episode.nodes.map((node) => node.id)), + ])); + const findings = asArray(content?.findings) + .filter((finding) => finding && typeof finding === 'object' && hasText(finding.problem)) + .slice(0, 40) + .map((finding) => { + const episodeId = episodeIds.has(finding.episodeId) ? finding.episodeId : null; + const nodeId = episodeId && nodesByEpisode.get(episodeId).has(finding.nodeId) + ? finding.nodeId + : null; + return { + severity: REVIEW_SEVERITIES.has(finding.severity) ? finding.severity : 'medium', + category: REVIEW_CATEGORIES.has(finding.category) ? finding.category : 'coherence', + episodeId, + nodeId, + problem: trimTo(finding.problem, 1200), + suggestion: trimTo(finding.suggestion, 1200), + }; + }); + return { + summary: trimTo(content?.summary, 2500), + strengths: analysisStrings(content?.strengths), + findings, + }; +}; + +const allowedEpisodeFields = ['title', 'synopsis', 'startNodeId']; +const allowedSceneFields = [ + 'title', 'prose', 'imagePrompt', 'videoPrompt', 'cameraMovement', 'playbackMode', + 'audienceConnection', 'protagonistPresence', 'isEnding', 'endingLabel', +]; +const allowedTransitionFields = ['targetNodeId', 'intent', 'triggers', 'description']; + +const applySeriesPlanPatch = (currentPlan, raw) => { + if (!raw || typeof raw !== 'object') return currentPlan; + const next = { ...currentPlan }; + for (const key of [ + 'storyArc', 'plotPoints', 'sideQuests', 'deliveryOptions', + 'interEpisodeVoicemails', 'nextSeasonTeaser', + ]) { + if (hasOwn(raw, key)) next[key] = raw[key]; + } + return next; +}; + +const applyScenePatch = (episode, scene, rawScene) => { + for (const key of allowedSceneFields) { + if (!hasOwn(rawScene, key)) continue; + const value = rawScene[key]; + if (['title', 'prose', 'imagePrompt', 'videoPrompt', 'endingLabel'].includes(key) + && typeof value === 'string') scene[key] = value; + if (key === 'cameraMovement' && typeof value === 'string') { + scene.cameraMovement = normalizeFableLoomCameraMovement(value); + } + if (key === 'playbackMode' && isFableLoomPlaybackMode(value)) scene.playbackMode = value; + if (key === 'audienceConnection' && ['connected', 'disconnected'].includes(value)) { + scene.audienceConnection = value; + } + if (key === 'protagonistPresence' && FABLELOOM_PROTAGONIST_PRESENCE.includes(value)) { + scene.protagonistPresence = value; + } + if (key === 'isEnding' && typeof value === 'boolean') scene.isEnding = value; + } + + const transitionsById = new Map(asArray(scene.transitions).map((transition) => [transition.id, transition])); + const nodeIds = new Set(episode.nodes.map((node) => node.id)); + for (const rawTransition of asArray(rawScene.transitions)) { + const transition = transitionsById.get(rawTransition?.id); + if (!transition) continue; + for (const key of allowedTransitionFields) { + if (!hasOwn(rawTransition, key)) continue; + const value = rawTransition[key]; + if (key === 'targetNodeId' && typeof value === 'string' && nodeIds.has(value)) { + transition.targetNodeId = value; + } + if (['intent', 'description'].includes(key) && typeof value === 'string') transition[key] = value; + if (key === 'triggers' && Array.isArray(value)) transition.triggers = value; + } + } +}; + +const applyContinuitySourcePatch = (scene, sourceId, predecessorsByNodeId) => { + const validPredecessors = new Set( + asArray(predecessorsByNodeId.get(scene.id)).map((item) => item.nodeId), + ); + if (sourceId !== null && (!hasText(sourceId) || !validPredecessors.has(sourceId))) { + throw aiShapeError(`The model selected an invalid continuity predecessor for scene ${scene.id}`); + } + scene.visualCanon = { + ...(scene.visualCanon || {}), + continuitySourceNodeId: sourceId, + }; +}; + +const countGraphErrors = (loom) => loom.episodes.reduce((total, episode, index) => ( + total + analyzeEpisodeGraph(episode, { + participationMode: loom.participationMode, + requireAudienceIntroduction: index === 0, + }).stats.errorCount +), 0); + +/** + * Apply a model response to an in-memory loom while preserving graph + * membership. Exported for focused contract tests and the persistence wrapper. + */ +export function applyFableLoomEditorialPatch(loom, content) { + if (!content || typeof content !== 'object') throw aiShapeError('The model returned no editorial response'); + const candidate = structuredClone(loom); + const beforeGraphErrors = countGraphErrors(candidate); + const beforeOutlineErrors = analyzeSeriesStoryOutlines(candidate).stats.errorCount; + + if (content.seriesPlan && typeof content.seriesPlan === 'object') { + candidate.seriesPlan = applySeriesPlanPatch(candidate.seriesPlan, content.seriesPlan); + } + const episodesById = new Map(candidate.episodes.map((episode) => [episode.id, episode])); + const returnedEpisodeIds = new Set(); + for (const rawEpisode of asArray(content.episodes)) { + const episode = episodesById.get(rawEpisode?.id); + if (!episode || returnedEpisodeIds.has(episode.id)) continue; + returnedEpisodeIds.add(episode.id); + for (const key of allowedEpisodeFields) { + if (!hasOwn(rawEpisode, key)) continue; + const value = rawEpisode[key]; + if (['title', 'synopsis'].includes(key) && typeof value === 'string') episode[key] = value; + if (key === 'startNodeId' && typeof value === 'string' + && episode.nodes.some((node) => node.id === value)) episode.startNodeId = value; + } + if (hasOwn(rawEpisode, 'storyOutline')) { + const storyOutline = sanitizeStoryOutline(rawEpisode.storyOutline, { + participationMode: candidate.participationMode, + }); + if (!storyOutline) throw aiShapeError(`The model returned an unusable outline for episode ${episode.id}`); + const analysis = analyzeStoryOutline(storyOutline, { + participationMode: candidate.participationMode, + requireAudienceIntroduction: candidate.episodes[0]?.id === episode.id, + }); + if (analysis.stats.errorCount) { + throw aiShapeError(`The model returned an invalid outline for episode ${episode.id}: ${analysis.issues.find((issue) => issue.severity === 'error')?.message}`); + } + episode.storyOutline = { + ...storyOutline, + validation: { + status: 'valid', + issues: analysis.issues, + validatedAt: new Date().toISOString(), + }, + }; + } + const scenesById = new Map(episode.nodes.map((scene) => [scene.id, scene])); + const continuitySourcePatches = []; + for (const rawScene of asArray(rawEpisode.scenes)) { + const scene = scenesById.get(rawScene?.id); + if (!scene) continue; + applyScenePatch(episode, scene, rawScene); + if (rawScene.visualCanon && typeof rawScene.visualCanon === 'object' + && hasOwn(rawScene.visualCanon, 'continuitySourceNodeId')) { + continuitySourcePatches.push({ + scene, + sourceId: rawScene.visualCanon.continuitySourceNodeId, + }); + } + } + // Transition targets are patchable, so predecessor validation must run + // against the resulting graph rather than the graph the model inspected. + const { predecessorsByNodeId } = computeTopologicalNodeOrder(episode); + for (const { scene, sourceId } of continuitySourcePatches) { + applyContinuitySourcePatch(scene, sourceId, predecessorsByNodeId); + } + } + + const sanitized = sanitizeLoom(candidate); + if (!sanitized) throw aiShapeError('The editorial response produced an invalid loom'); + const afterGraphErrors = countGraphErrors(sanitized); + const afterOutlineErrors = analyzeSeriesStoryOutlines(sanitized).stats.errorCount; + if (afterGraphErrors > beforeGraphErrors) { + throw aiShapeError('The editorial response introduced new episode graph errors'); + } + if (afterOutlineErrors > beforeOutlineErrors) { + throw aiShapeError('The editorial response introduced new series-outline errors'); + } + + const before = editorialFingerprint(loom); + const after = editorialFingerprint(sanitized); + return { + loom: sanitized, + changed: before !== after, + before: { graphErrors: beforeGraphErrors, outlineErrors: beforeOutlineErrors }, + after: { graphErrors: afterGraphErrors, outlineErrors: afterOutlineErrors }, + }; +} + +/** One AI call that evaluates and repairs the complete existing loom. */ +export async function evaluateAndRemediateFableLoom(loomId, { + guidance = '', providerId, model, effort, operationId, +} = {}) { + const loom = await requireLoom(loomId); + const fingerprint = editorialFingerprint(loom); + const diagnostics = await collectFableLoomEditorialDiagnostics(loom); + const canonDigest = await buildCanonDigest(loom); + const { content, runId } = await runEditorialAi('fableloom-editorial-remediate', { + storyContext: storyContext(loom), + canonDigest: canonDigest || '(none)', + seriesPlanJson: seriesPlanDigest(loom), + teleplayDigest: teleplayDigest(loom), + playthroughDigest: describeLoomPlaythroughsForPrompt(loom, diagnostics.playthrough), + deterministicDigest: diagnosticLines(diagnostics), + guidance: trimTo(guidance, 4000) || '(none)', + }, { providerId, model, effort, operationId }, { + action: 'editorial-remediate', + label: 'Evaluating and remediating the FableLoom series', + source: 'fableloom-editorial-remediate', + }); + const evaluation = sanitizeEvaluation(content, loom); + if (!evaluation.summary && !evaluation.strengths.length && !evaluation.findings.length) { + throw aiShapeError('The model returned no usable editorial evaluation'); + } + const applied = applyFableLoomEditorialPatch(loom, content); + const updated = applied.changed ? await mutateLoom(loomId, (current) => { + if (editorialFingerprint(current) !== fingerprint) { + throw new ServerError('The story changed while the editorial pass was running', { + status: 409, + code: 'LOOM_CHANGED_DURING_GENERATION', + }); + } + return applied.loom; + }) : loom; + const afterDiagnostics = await collectFableLoomEditorialDiagnostics(updated); + return { + loom: updated, + changed: applied.changed, + changes: analysisStrings(content?.changes), + evaluation, + before: diagnostics.stats, + after: afterDiagnostics.stats, + diagnostics: afterDiagnostics, + runId, + }; +} + +const sanitizePlaythroughReview = (content, loom, deterministic) => { + const episodeById = new Map(loom.episodes.map((episode) => [episode.id, episode])); + const pathsByEpisode = new Map(deterministic.episodes.map((episode) => [ + episode.episodeId, + new Set(episode.paths.map((path) => path.id)), + ])); + const findings = asArray(content?.findings) + .filter((finding) => finding && typeof finding === 'object' && hasText(finding.problem)) + .slice(0, 60) + .map((finding) => { + const episode = episodeById.get(finding.episodeId); + const episodeId = episode?.id || null; + const nodeId = episode?.nodes.some((node) => node.id === finding.nodeId) + ? finding.nodeId + : null; + const pathId = episodeId && pathsByEpisode.get(episodeId)?.has(finding.pathId) + ? finding.pathId + : null; + return { + severity: REVIEW_SEVERITIES.has(finding.severity) ? finding.severity : 'medium', + category: REVIEW_CATEGORIES.has(finding.category) ? finding.category : 'coherence', + episodeId, + nodeId, + pathId, + problem: trimTo(finding.problem, 1200), + suggestion: trimTo(finding.suggestion, 1200), + }; + }); + const qualityScore = clampScore(content?.qualityScore); + if (qualityScore === null || !hasText(content?.summary)) { + throw aiShapeError('The model returned no usable playthrough quality verdict'); + } + return { + passed: content?.passed === true, + qualityScore, + summary: trimTo(content.summary, 2500), + strengths: analysisStrings(content?.strengths), + findings, + }; +}; + +/** Run the deterministic variations and optionally one AI quality review. */ +export async function reviewFableLoomPlaythroughs(loomId, { + aiReview = true, maxPaths, providerId, model, effort, operationId, +} = {}) { + const loom = await requireLoom(loomId); + const deterministic = analyzeLoomPlaythroughs(loom, { maxPaths }); + if (!aiReview) return { passed: deterministic.passed, deterministic, review: null, runId: null }; + const canonDigest = await buildCanonDigest(loom); + const diagnostics = await collectFableLoomEditorialDiagnostics(loom); + const { content, runId } = await runEditorialAi('fableloom-review-playthroughs', { + storyContext: storyContext(loom), + canonDigest: canonDigest || '(none)', + seriesPlanJson: seriesPlanDigest(loom), + teleplayDigest: teleplayDigest(loom), + playthroughDigest: describeLoomPlaythroughsForPrompt(loom, deterministic), + deterministicDigest: diagnosticLines(diagnostics), + }, { providerId, model, effort, operationId }, { + action: 'review-playthroughs', + label: 'Reviewing FableLoom playthrough variations', + source: 'fableloom-review-playthroughs', + }); + const review = sanitizePlaythroughReview(content, loom, deterministic); + const hasHighFinding = review.findings.some((finding) => finding.severity === 'high'); + return { + passed: diagnostics.passed + && deterministic.passed + && review.passed + && !hasHighFinding + && review.qualityScore >= AUTOPILOT_QUALITY_THRESHOLD, + deterministic, + review, + runId, + qualityThreshold: AUTOPILOT_QUALITY_THRESHOLD, + }; +} + +export const __testing = { + diagnosticLines, + editorialFingerprint, + sanitizeEvaluation, + sanitizePlaythroughReview, +}; diff --git a/server/services/fableLoom/editorial.test.js b/server/services/fableLoom/editorial.test.js new file mode 100644 index 000000000..27cf84f78 --- /dev/null +++ b/server/services/fableLoom/editorial.test.js @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { applyFableLoomEditorialPatch } from './editorial.js'; +import { sanitizeLoom } from './records.js'; + +const transition = (id, targetNodeId, intent) => ({ id, targetNodeId, intent, triggers: [] }); + +const makeLoom = () => sanitizeLoom({ + id: 'loom-example', + name: 'Example Story', + participationMode: 'protagonist', + seriesPlan: { storyArc: 'A traveler follows a divided signal.', plotPoints: [], sideQuests: [] }, + episodes: [{ + id: 'episode-example', + number: 1, + title: 'The Divided Signal', + synopsis: 'Two routes reveal one source.', + startNodeId: 'opening', + nodes: [ + { + id: 'opening', title: 'The Split', prose: 'The signal forks.', + playbackMode: 'decision', audienceConnection: 'connected', isEnding: false, + transitions: [ + transition('take-left', 'left', 'Take the glass bridge'), + transition('take-right', 'right', 'Follow the buried wire'), + ], + }, + { + id: 'left', title: 'Glass Bridge', prose: 'The bridge remembers every footstep.', + playbackMode: 'cut', audienceConnection: 'disconnected', isEnding: false, + transitions: [transition('left-end', 'ending', 'Reach the beacon')], + }, + { + id: 'right', title: 'Buried Wire', prose: 'The wire hums below the frost.', + playbackMode: 'cut', audienceConnection: 'disconnected', isEnding: false, + transitions: [transition('right-end', 'ending', 'Reach the beacon')], + }, + { + id: 'ending', title: 'The Beacon', prose: 'Both routes reveal the same call.', + playbackMode: 'decision', audienceConnection: 'connected', isEnding: true, + endingLabel: 'Signal found', transitions: [], + }, + ], + }], +}); + +const completeOutline = () => ({ + startKey: 'opening', + scenes: [ + { + key: 'opening', title: 'The Split', summary: 'The traveler must choose how to follow the signal.', + playbackMode: 'decision', audienceConnection: 'connected', protagonistPresence: 'onscreen', + transitions: [ + { targetKey: 'left', intent: 'Take the glass bridge' }, + { targetKey: 'right', intent: 'Follow the buried wire' }, + ], + }, + { + key: 'left', title: 'Glass Bridge', summary: 'The exposed route tests the traveler’s nerve.', + playbackMode: 'cut', audienceConnection: 'disconnected', protagonistPresence: 'onscreen', + transitions: [{ targetKey: 'ending', intent: 'Reach the beacon' }], + }, + { + key: 'right', title: 'Buried Wire', summary: 'The hidden route reveals who buried the signal.', + playbackMode: 'cut', audienceConnection: 'disconnected', protagonistPresence: 'onscreen', + transitions: [{ targetKey: 'ending', intent: 'Reach the beacon' }], + }, + { + key: 'ending', title: 'The Beacon', summary: 'The beacon answers with a costly invitation.', + playbackMode: 'decision', audienceConnection: 'connected', protagonistPresence: 'onscreen', + isEnding: true, endingLabel: 'Signal found', transitions: [], + }, + ], +}); + +describe('applyFableLoomEditorialPatch', () => { + it('repairs a missing outline and selects a valid convergence source without changing membership', () => { + const loom = makeLoom(); + const originalNodeIds = loom.episodes[0].nodes.map((node) => node.id); + const originalTransitionIds = loom.episodes[0].nodes.flatMap((node) => ( + node.transitions.map((item) => item.id) + )); + + const result = applyFableLoomEditorialPatch(loom, { + episodes: [{ + id: 'episode-example', + storyOutline: completeOutline(), + scenes: [{ id: 'ending', visualCanon: { continuitySourceNodeId: 'left' } }], + }], + }); + + expect(result.changed).toBe(true); + expect(result.before.outlineErrors).toBeGreaterThan(0); + expect(result.after.outlineErrors).toBe(0); + expect(result.loom.episodes[0].storyOutline.validation.status).toBe('valid'); + expect(result.loom.episodes[0].nodes.find((node) => node.id === 'ending') + .visualCanon.continuitySourceNodeId).toBe('left'); + expect(result.loom.episodes[0].nodes.map((node) => node.id)).toEqual(originalNodeIds); + expect(result.loom.episodes[0].nodes.flatMap((node) => node.transitions.map((item) => item.id))) + .toEqual(originalTransitionIds); + }); + + it('validates continuity sources against transition rewires in the same patch', () => { + const result = applyFableLoomEditorialPatch(makeLoom(), { + episodes: [{ + id: 'episode-example', + scenes: [ + { id: 'left', transitions: [{ id: 'left-end', targetNodeId: 'right' }] }, + { id: 'ending', visualCanon: { continuitySourceNodeId: 'right' } }, + ], + }], + }); + + expect(result.loom.episodes[0].nodes.find((node) => node.id === 'left') + .transitions[0].targetNodeId).toBe('right'); + expect(result.loom.episodes[0].nodes.find((node) => node.id === 'ending') + .visualCanon.continuitySourceNodeId).toBe('right'); + }); + + it('rejects a continuity source that is not a direct incoming predecessor', () => { + expect(() => applyFableLoomEditorialPatch(makeLoom(), { + episodes: [{ + id: 'episode-example', + scenes: [{ id: 'ending', visualCanon: { continuitySourceNodeId: 'opening' } }], + }], + })).toThrow(/invalid continuity predecessor/i); + }); + + it('rejects a patch that introduces a new deterministic graph error', () => { + expect(() => applyFableLoomEditorialPatch(makeLoom(), { + episodes: [{ + id: 'episode-example', + scenes: [{ id: 'opening', playbackMode: 'cut' }], + }], + })).toThrow(/introduced new episode graph errors/i); + }); +}); diff --git a/server/services/fableLoom/editorialAutopilot.js b/server/services/fableLoom/editorialAutopilot.js new file mode 100644 index 000000000..b18177333 --- /dev/null +++ b/server/services/fableLoom/editorialAutopilot.js @@ -0,0 +1,301 @@ +/** + * Bounded FableLoom editor/reviewer autopilot. + * + * A user-triggered run alternates one whole-series remediation call with one + * branching-playthrough judge call until the story clears the deterministic + * and narrative gates, reaches its round budget, plateaus, is canceled, or a + * provider fails. Runs are process-local like FableLoom production batches; + * the loom writes themselves remain durable. + */ + +import { randomUUID } from 'node:crypto'; +import { ServerError } from '../../lib/errorHandler.js'; +import { LOOM_LIMITS } from '../../lib/fableLoomLimits.js'; +import { trimTo } from '../../lib/storyBible.js'; +import { getLoom } from './records.js'; +import { + evaluateAndRemediateFableLoom, + reviewFableLoomPlaythroughs, +} from './editorial.js'; + +export const FABLELOOM_EDITORIAL_AUTOPILOT_LIMITS = Object.freeze({ + DEFAULT_ROUNDS: 3, + MAX_ROUNDS: LOOM_LIMITS.EDITORIAL_AUTOPILOT_ROUNDS_MAX, + RUN_MAX_AGE_MS: 2 * 60 * 60 * 1000, + MAX_CONCURRENT_RUNS: 10, +}); + +const runs = new Map(); +const latestRunByLoom = new Map(); + +const nowIso = () => new Date().toISOString(); +const errorMessage = (error) => error?.message || String(error); +const isTerminal = (run) => ['completed', 'paused', 'failed', 'canceled'].includes(run?.status); +const boundedRounds = (value) => (Number.isInteger(value) + ? Math.max(1, Math.min(FABLELOOM_EDITORIAL_AUTOPILOT_LIMITS.MAX_ROUNDS, value)) + : FABLELOOM_EDITORIAL_AUTOPILOT_LIMITS.DEFAULT_ROUNDS); + +const cleanStaleRuns = () => { + const cutoff = Date.now() - FABLELOOM_EDITORIAL_AUTOPILOT_LIMITS.RUN_MAX_AGE_MS; + for (const [runId, run] of runs.entries()) { + if (!isTerminal(run)) continue; + const updatedAt = Date.parse(run.updatedAt || run.createdAt || ''); + if (Number.isFinite(updatedAt) && updatedAt < cutoff) { + runs.delete(runId); + if (latestRunByLoom.get(run.loomId) === runId) latestRunByLoom.delete(run.loomId); + } + } +}; + +const touch = (run, patch = {}) => { + Object.assign(run, patch, { updatedAt: nowIso() }); + return run; +}; + +const compactDeterministic = (deterministic) => ({ + passed: deterministic.passed, + complete: deterministic.complete, + stats: deterministic.stats, + issues: deterministic.episodes.flatMap((episode) => episode.issues.map((issue) => ({ + ...issue, + episodeId: episode.episodeId, + }))).slice(0, 80), +}); + +const residualFindings = (playtest) => [ + ...playtest.deterministic.episodes.flatMap((episode) => episode.issues.map((issue) => ({ + severity: issue.severity === 'error' ? 'high' : 'medium', + category: 'structure', + episodeId: episode.episodeId, + nodeId: issue.nodeId || null, + pathId: issue.pathId || null, + problem: issue.message, + suggestion: 'Repair the graph or branch contract before the next playthrough review.', + }))), + ...(playtest.review?.findings || []), +].slice(0, 80); + +const findingSignature = (findings) => findings.map((finding) => [ + finding.severity, + finding.category, + finding.episodeId, + finding.nodeId, + finding.pathId, + finding.problem, +].join('|')).sort().join('\n'); + +const guidanceFromFindings = (findings, summary = '') => trimTo([ + summary ? `Previous playthrough review: ${summary}` : '', + ...findings.map((finding) => [ + `[${finding.severity}/${finding.category}]`, + `episode=${finding.episodeId || 'series'}`, + `node=${finding.nodeId || '-'}`, + `path=${finding.pathId || '-'}`, + finding.problem, + finding.suggestion ? `Fix: ${finding.suggestion}` : '', + ].filter(Boolean).join(' ')), +].filter(Boolean).join('\n'), 4000); + +const routeOptions = (run) => ({ + ...(run.route.providerId ? { providerId: run.route.providerId } : {}), + ...(run.route.model ? { model: run.route.model } : {}), + ...(run.route.effort ? { effort: run.route.effort } : {}), +}); + +const finishCanceled = (run) => touch(run, { + status: 'canceled', + currentStep: null, + message: 'Editorial autopilot canceled after the active AI step finished.', + completedAt: nowIso(), +}); + +const finishFailed = (run, error) => touch(run, { + status: 'failed', + currentStep: null, + message: errorMessage(error), + error: errorMessage(error), + completedAt: nowIso(), +}); + +async function runRound(run, guidance) { + const round = run.round + 1; + touch(run, { + round, + currentStep: 'evaluate-remediate', + message: `Round ${round}: evaluating and remediating the complete series…`, + }); + const remediation = await evaluateAndRemediateFableLoom(run.loomId, { + ...routeOptions(run), + guidance, + }); + if (run.cancelRequested) return finishCanceled(run); + + touch(run, { + currentStep: 'playthrough-review', + message: `Round ${round}: exercising and reviewing branching playthroughs…`, + }); + const playtest = await reviewFableLoomPlaythroughs(run.loomId, { + ...routeOptions(run), + aiReview: true, + ...(run.maxPaths ? { maxPaths: run.maxPaths } : {}), + }); + if (run.cancelRequested) return finishCanceled(run); + + const residual = residualFindings(playtest); + const snapshot = { + round, + changed: remediation.changed, + changes: remediation.changes, + before: remediation.before, + after: remediation.after, + evaluation: remediation.evaluation, + deterministic: compactDeterministic(playtest.deterministic), + review: playtest.review, + passed: playtest.passed, + }; + run.rounds.push(snapshot); + run.residualFindings = residual; + run.lastEvaluation = remediation.evaluation; + run.lastPlaytest = snapshot.deterministic; + run.lastReview = playtest.review; + + if (playtest.passed) { + return touch(run, { + status: 'completed', + currentStep: null, + message: `Editorial autopilot completed after ${round} round${round === 1 ? '' : 's'}.`, + completedAt: nowIso(), + }); + } + + const signature = findingSignature(residual); + const plateau = !remediation.changed && signature === run.previousFindingSignature; + run.previousFindingSignature = signature; + if (plateau) { + return touch(run, { + status: 'paused', + pauseReason: 'plateau', + currentStep: null, + message: 'Editorial autopilot paused because another safe pass produced no changes and the same findings remained.', + completedAt: nowIso(), + }); + } + if (round >= run.maxRounds) { + return touch(run, { + status: 'paused', + pauseReason: 'round-limit', + currentStep: null, + message: `Editorial autopilot reached its ${run.maxRounds}-round limit with review findings still open.`, + completedAt: nowIso(), + }); + } + + touch(run, { + message: `Round ${round} left ${residual.length} finding${residual.length === 1 ? '' : 's'}; preparing another repair pass.`, + }); + return runRound(run, guidanceFromFindings(residual, playtest.review?.summary)); +} + +/** Start and detach a bounded editor/reviewer run. */ +export async function startFableLoomEditorialAutopilot(loomId, { + maxRounds, maxPaths, providerId, model, effort, +} = {}) { + cleanStaleRuns(); + await requireLoomForRun(loomId); + const currentId = latestRunByLoom.get(loomId); + const current = currentId ? runs.get(currentId) : null; + if (current && ['running', 'canceling'].includes(current.status)) { + return { ...current, alreadyRunning: true }; + } + const activeCount = [...runs.values()].filter((run) => ['running', 'canceling'].includes(run.status)).length; + if (activeCount >= FABLELOOM_EDITORIAL_AUTOPILOT_LIMITS.MAX_CONCURRENT_RUNS) { + throw new ServerError('The maximum number of FableLoom editorial autopilots is already running.', { + status: 409, + code: 'FABLELOOM_AUTOPILOT_LIMIT', + }); + } + + const createdAt = nowIso(); + const run = { + id: `editorial-${randomUUID()}`, + loomId, + status: 'running', + currentStep: 'starting', + round: 0, + maxRounds: boundedRounds(maxRounds), + maxPaths: Number.isInteger(maxPaths) ? maxPaths : null, + route: { + providerId: providerId || null, + model: model || null, + effort: effort || null, + }, + createdAt, + updatedAt: createdAt, + completedAt: null, + cancelRequested: false, + pauseReason: null, + message: 'Starting FableLoom editorial autopilot…', + error: null, + rounds: [], + residualFindings: [], + lastEvaluation: null, + lastPlaytest: null, + lastReview: null, + previousFindingSignature: null, + }; + runs.set(run.id, run); + latestRunByLoom.set(loomId, run.id); + void runRound(run, '').catch((error) => ( + run.cancelRequested ? finishCanceled(run) : finishFailed(run, error) + )); + return run; +} + +const requireLoomForRun = async (loomId) => { + const loom = await getLoom(loomId); + if (!loom) throw new ServerError('Loom not found', { status: 404, code: 'NOT_FOUND' }); + return loom; +}; + +export function getFableLoomEditorialAutopilot(runId) { + cleanStaleRuns(); + return runs.get(runId) || null; +} + +export function getLatestFableLoomEditorialAutopilot(loomId) { + cleanStaleRuns(); + const runId = latestRunByLoom.get(loomId); + return runId ? runs.get(runId) || null : null; +} + +/** Cooperative cancellation: the active provider call finishes, then the run stops. */ +export function cancelFableLoomEditorialAutopilot(runId) { + const run = runs.get(runId); + if (!run) throw new ServerError('Editorial autopilot run not found', { status: 404, code: 'NOT_FOUND' }); + if (run.status !== 'running') return run; + run.cancelRequested = true; + return touch(run, { + status: 'canceling', + message: 'Cancel requested; the active AI step will finish before the run stops.', + }); +} + +export function publicFableLoomEditorialAutopilot(run) { + if (!run) return null; + const { previousFindingSignature: _signature, ...publicRun } = run; + return publicRun; +} + +export function _resetFableLoomEditorialAutopilots() { + runs.clear(); + latestRunByLoom.clear(); +} + +export const __testing = { + boundedRounds, + compactDeterministic, + findingSignature, + guidanceFromFindings, + residualFindings, + runs, +}; diff --git a/server/services/fableLoom/editorialAutopilot.test.js b/server/services/fableLoom/editorialAutopilot.test.js new file mode 100644 index 000000000..811afcf2d --- /dev/null +++ b/server/services/fableLoom/editorialAutopilot.test.js @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const getLoomMock = vi.hoisted(() => vi.fn(async (id) => ({ id }))); +const remediateMock = vi.hoisted(() => vi.fn()); +const playtestMock = vi.hoisted(() => vi.fn()); + +vi.mock('./records.js', () => ({ getLoom: getLoomMock })); +vi.mock('./editorial.js', () => ({ + evaluateAndRemediateFableLoom: remediateMock, + reviewFableLoomPlaythroughs: playtestMock, +})); + +const { + _resetFableLoomEditorialAutopilots, + cancelFableLoomEditorialAutopilot, + getFableLoomEditorialAutopilot, + publicFableLoomEditorialAutopilot, + startFableLoomEditorialAutopilot, +} = await import('./editorialAutopilot.js'); + +const remediation = (changed = true) => ({ + changed, + changes: changed ? ['Repaired one beat.'] : [], + before: { outlineErrors: changed ? 1 : 0 }, + after: { outlineErrors: 0 }, + evaluation: { summary: 'Focused editorial pass.', strengths: [], findings: [] }, +}); + +const playtest = ({ passed, findings = [] }) => ({ + passed, + deterministic: { + passed: true, + complete: true, + stats: { variationCount: 2, visitedTransitionCount: 4, transitionCount: 4 }, + episodes: [{ episodeId: 'episode-example', issues: [] }], + }, + review: { + passed, + qualityScore: passed ? 8.5 : 7.2, + summary: passed ? 'Every route holds.' : 'One consequence still disappears.', + strengths: [], + findings, + }, +}); + +const waitForTerminal = async (runId) => { + for (let attempt = 0; attempt < 50; attempt += 1) { + const run = getFableLoomEditorialAutopilot(runId); + if (['completed', 'paused', 'failed', 'canceled'].includes(run?.status)) return run; + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error('Editorial autopilot did not settle'); +}; + +beforeEach(() => { + _resetFableLoomEditorialAutopilots(); + getLoomMock.mockClear().mockImplementation(async (id) => ({ id })); + remediateMock.mockReset(); + playtestMock.mockReset(); +}); + +describe('FableLoom editorial autopilot', () => { + it('completes after one editor/reviewer round when every gate passes', async () => { + remediateMock.mockResolvedValueOnce(remediation(true)); + playtestMock.mockResolvedValueOnce(playtest({ passed: true })); + + const started = await startFableLoomEditorialAutopilot('loom-example', { + maxRounds: 3, providerId: 'writer', model: 'large', effort: 'high', + }); + const finished = await waitForTerminal(started.id); + + expect(finished).toMatchObject({ status: 'completed', round: 1, maxRounds: 3 }); + expect(finished.rounds).toHaveLength(1); + expect(remediateMock).toHaveBeenCalledWith('loom-example', { + providerId: 'writer', model: 'large', effort: 'high', guidance: '', + }); + expect(playtestMock).toHaveBeenCalledWith('loom-example', { + providerId: 'writer', model: 'large', effort: 'high', aiReview: true, + }); + }); + + it('pauses on a plateau after the same finding survives a no-change pass', async () => { + const finding = { + severity: 'medium', category: 'coherence', episodeId: 'episode-example', + nodeId: 'ending', pathId: 'path-1', problem: 'The cost disappears after convergence.', + suggestion: 'Carry the chosen sacrifice into the shared ending.', + }; + remediateMock + .mockResolvedValueOnce(remediation(true)) + .mockResolvedValueOnce(remediation(false)); + playtestMock.mockResolvedValue(playtest({ passed: false, findings: [finding] })); + + const started = await startFableLoomEditorialAutopilot('loom-example', { maxRounds: 4 }); + const finished = await waitForTerminal(started.id); + + expect(finished).toMatchObject({ status: 'paused', pauseReason: 'plateau', round: 2 }); + expect(finished.rounds).toHaveLength(2); + expect(remediateMock.mock.calls[1][1].guidance).toContain('Carry the chosen sacrifice'); + }); + + it('honors the hard round limit when review findings remain', async () => { + remediateMock.mockResolvedValueOnce(remediation(true)); + playtestMock.mockResolvedValueOnce(playtest({ + passed: false, + findings: [{ severity: 'low', category: 'pacing', problem: 'The second route rushes its turn.' }], + })); + + const started = await startFableLoomEditorialAutopilot('loom-example', { maxRounds: 1 }); + const finished = await waitForTerminal(started.id); + + expect(finished).toMatchObject({ status: 'paused', pauseReason: 'round-limit', round: 1 }); + }); + + it('reattaches duplicate starts and cooperatively cancels after the active AI step', async () => { + let finishRemediation; + remediateMock.mockImplementationOnce(() => new Promise((resolve) => { finishRemediation = resolve; })); + const started = await startFableLoomEditorialAutopilot('loom-example', { maxRounds: 3 }); + const duplicate = await startFableLoomEditorialAutopilot('loom-example', { maxRounds: 6 }); + + expect(duplicate).toMatchObject({ id: started.id, alreadyRunning: true, maxRounds: 3 }); + expect(cancelFableLoomEditorialAutopilot(started.id).status).toBe('canceling'); + finishRemediation(remediation(true)); + const finished = await waitForTerminal(started.id); + + expect(finished.status).toBe('canceled'); + expect(playtestMock).not.toHaveBeenCalled(); + expect(publicFableLoomEditorialAutopilot(finished)).not.toHaveProperty('previousFindingSignature'); + }); +}); diff --git a/server/services/fableLoom/index.js b/server/services/fableLoom/index.js index 123b24df2..fc118e8f4 100644 --- a/server/services/fableLoom/index.js +++ b/server/services/fableLoom/index.js @@ -45,6 +45,21 @@ export { weaveEpisode, validateEpisodeOutline, } from './weave.js'; +export { + applyFableLoomEditorialPatch, + collectFableLoomEditorialDiagnostics, + evaluateAndRemediateFableLoom, + reviewFableLoomPlaythroughs, +} from './editorial.js'; +export { + FABLELOOM_EDITORIAL_AUTOPILOT_LIMITS, + _resetFableLoomEditorialAutopilots, + cancelFableLoomEditorialAutopilot, + getFableLoomEditorialAutopilot, + getLatestFableLoomEditorialAutopilot, + publicFableLoomEditorialAutopilot, + startFableLoomEditorialAutopilot, +} from './editorialAutopilot.js'; export { _resetFableLoomBackend, isValidLoomId, From f5a298d3064c18aa3924cf143efc14ed13223397 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Sun, 30 Aug 2026 14:21:30 -0700 Subject: [PATCH 2/5] fix(fableloom): harden editorial automation contracts Fail closed on stale outlines, incomplete path reviews, continuity regressions, invented identifiers, and provider fallback context drift. Add prompt migration and focused regressions for expansion, playthrough coverage, and readiness invalidation. --- .../stages/fableloom-editorial-remediate.md | 88 +- .../prompts/stages/fableloom-weave-episode.md | 2 +- ...21-fableloom-outline-expansion-contract.js | 23 + ...bleloom-outline-expansion-contract.test.js | 16 + scripts/setup-data-drift.test.js | 4 +- server/lib/fableLoomOutline.js | 97 ++- server/lib/fableLoomOutline.test.js | 43 + server/lib/fableLoomPlaytest.js | 186 ++++- server/lib/fableLoomPlaytest.test.js | 146 +++- server/services/fableLoom/editorial.js | 788 +++++++++++++++--- server/services/fableLoom/editorial.test.js | 524 +++++++++++- .../services/fableLoom/editorialAutopilot.js | 2 + .../fableLoom/editorialAutopilot.test.js | 34 +- server/services/fableLoom/records.js | 14 +- server/services/fableLoom/records.test.js | 57 +- server/services/fableLoom/weave.js | 67 +- server/services/fableLoom/weave.test.js | 92 +- server/services/stageRunner.js | 6 +- server/services/stageRunner.test.js | 14 + 19 files changed, 1946 insertions(+), 257 deletions(-) create mode 100644 scripts/migrations/321-fableloom-outline-expansion-contract.js create mode 100644 scripts/migrations/321-fableloom-outline-expansion-contract.test.js diff --git a/data.reference/prompts/stages/fableloom-editorial-remediate.md b/data.reference/prompts/stages/fableloom-editorial-remediate.md index 0612e9c98..d6b480ebe 100644 --- a/data.reference/prompts/stages/fableloom-editorial-remediate.md +++ b/data.reference/prompts/stages/fableloom-editorial-remediate.md @@ -34,95 +34,43 @@ You are the single senior story editor responsible for evaluating and safely rem - Preserve every episode id, scene id, transition id, and all episode/scene/transition membership. Do not add or remove an episode, scene, or transition. - A missing field preserves its current value. A present empty string or `null` intentionally clears a field where the schema permits it. -- When an episode has no valid beat outline, return its complete `storyOutline`, using only that episode's existing scene ids as scene keys. The outline must begin at `startKey`, reach every beat, give a non-ending `cut` exactly one transition, give a non-ending `decision` two to four transitions, and give every ending beat no outgoing transition. +- When an expanded episode has no valid beat outline, return its complete `storyOutline`. It must use every existing scene id exactly once as its scene keys, use the teleplay `startNodeId` as `startKey`, and match every scene's playback/audience/protagonist/ending flags plus every transition target and intent. +- If you change an episode title, synopsis, opening scene, scene title, playback/audience/protagonist/ending flags, or transition target/intent, include that episode's complete synchronized `storyOutline` in the same patch. A stale previously-valid outline is never acceptable. - Outline transition intents must describe genuine audience decisions on decision beats. Automatic story progression belongs in a single `cut` transition, never as a fake choice. - A scene with multiple incoming paths may set `visualCanon.continuitySourceNodeId` only to one of that scene's direct incoming predecessor scene ids. Use `null` only when intentionally removing an existing override. - A non-ending teleplay cut must retain exactly one outgoing transition. A decision scene must retain two or more. An ending must retain none. - Keep the canonical protagonist, wardrobe, participation mode, and world canon intact. Helper-mode audience conversations keep the protagonist off-screen; visible scenes keep the protagonist present. - Preserve strong material. Fix only concrete structural, continuity, coherence, agency, pacing, or payoff problems supported by the supplied evidence. - `findings` describes the evaluated state before this patch. `changes` names edits actually represented in the patch. +- Omit unchanged keys. Never copy instructional labels or sample identifiers into story fields. +- A present empty string intentionally clears a string. To clear a non-empty series-plan collection/object, include its exact path in top-level `clears` as well as its empty replacement. Supported paths are `seriesPlan.plotPoints`, `seriesPlan.sideQuests`, `seriesPlan.deliveryOptions`, `seriesPlan.interEpisodeVoicemails`, and `seriesPlan.nextSeasonTeaser`. -Return ONLY valid JSON matching this shape — no prose, markdown fence, or commentary. Omit unchanged patch fields: +Return ONLY valid JSON — no prose, markdown fence, or commentary. The following is a minimal sparse-patch example, not a form to fill in: ```json { "summary": "concise whole-series editorial assessment", "strengths": ["specific strength worth preserving"], - "findings": [ - { - "severity": "high", - "category": "coherence", - "episodeId": "existing episode id or null", - "nodeId": "existing scene id or null", - "problem": "concrete problem", - "suggestion": "smallest useful fix" - } - ], - "changes": ["specific applied change"], - "seriesPlan": { - "storyArc": "complete replacement only when changed", - "plotPoints": [], - "sideQuests": [], - "deliveryOptions": {}, - "interEpisodeVoicemails": [], - "nextSeasonTeaser": {} - }, + "findings": [], + "changes": ["Sharpened one scene's sensory detail without changing its beat."], "episodes": [ { - "id": "existing episode id", - "title": "only when changed", - "synopsis": "only when changed", - "startNodeId": "existing scene id, only when changed", - "storyOutline": { - "version": 1, - "startKey": "existing scene id", - "scenes": [ - { - "key": "existing scene id", - "title": "beat title", - "summary": "one to three sentence dramatic beat log-line", - "playbackMode": "cut", - "audienceConnection": "connected", - "protagonistPresence": "onscreen", - "isEnding": false, - "endingLabel": "", - "transitions": [ - { - "targetKey": "existing scene id", - "intent": "continue" - } - ] - } - ] - }, + "id": "EPISODE_ID_FROM_INPUT", "scenes": [ { - "id": "existing scene id", - "title": "only when changed", - "prose": "only when changed", - "imagePrompt": "only when changed", - "videoPrompt": "only when changed", - "cameraMovement": "only when changed", - "playbackMode": "only when changed", - "audienceConnection": "only when changed", - "protagonistPresence": "only when changed", - "isEnding": false, - "endingLabel": "only when changed", - "visualCanon": { - "continuitySourceNodeId": "direct incoming predecessor scene id or null" - }, - "transitions": [ - { - "id": "existing transition id", - "targetNodeId": "existing scene id, only when changed", - "intent": "only when changed", - "triggers": ["only when changed"], - "description": "only when changed" - } - ] + "id": "SCENE_ID_FROM_INPUT", + "prose": "The signal shivers through the flooded tunnel walls." } ] } ] } ``` + +Optional patch keys are: + +- top level: `clears`, `seriesPlan`, `episodes` +- `seriesPlan`: `storyArc`, `plotPoints`, `sideQuests`, `deliveryOptions`, `interEpisodeVoicemails`, `nextSeasonTeaser` +- episode: `id`, `title`, `synopsis`, `startNodeId`, `storyOutline`, `scenes` +- scene: `id`, `title`, `prose`, `imagePrompt`, `videoPrompt`, `cameraMovement`, `playbackMode`, `audienceConnection`, `protagonistPresence`, `isEnding`, `endingLabel`, `visualCanon`, `transitions` +- transition: `id`, `targetNodeId`, `intent`, `triggers`, `description` diff --git a/data.reference/prompts/stages/fableloom-weave-episode.md b/data.reference/prompts/stages/fableloom-weave-episode.md index 7997b114d..e8909e1c4 100644 --- a/data.reference/prompts/stages/fableloom-weave-episode.md +++ b/data.reference/prompts/stages/fableloom-weave-episode.md @@ -28,7 +28,7 @@ When an existing graph is present, this is a reweave: preserve its story events, {{outlineDigest}} -When a validated beat outline is present, it is the authoritative story plan for this expansion. Expand every outline beat into one corresponding camera-cut node in the same dramatic order, preserve its path meanings and ending outcomes, and add only the scene-level prose and production directions needed to make those beats playable. Do not replace the established protagonist, world, or episode handoff. +When a validated beat outline is present, it is the authoritative story plan for this expansion. Reuse every supplied outline beat `key` exactly, including `startKey` and transition `targetKey` values; return one node for every beat with no additions or omissions. Preserve each beat's title, playback mode, audience connection, protagonist presence, ending contract, path targets, and path intents exactly. Add only the scene-level prose and production directions needed to make those beats playable. Do not replace the established protagonist, world, or episode handoff. ## Design contract diff --git a/scripts/migrations/321-fableloom-outline-expansion-contract.js b/scripts/migrations/321-fableloom-outline-expansion-contract.js new file mode 100644 index 000000000..737d9a0a2 --- /dev/null +++ b/scripts/migrations/321-fableloom-outline-expansion-contract.js @@ -0,0 +1,23 @@ +/** Upgrade FableLoom episode weaving to preserve validated outline keys and transitions. */ + +import { makePromptReplaceMigration } from './_lib.js'; + +export const ACCEPTED_OLD_MD5 = { + 'fableloom-weave-episode.md': ['e0f8d864caa8746912b56cd567f1c09d'], +}; + +export const NEW_SHIPPED_MD5 = { + 'fableloom-weave-episode.md': 'b4d363db94fd8a9928fa977745c76ff9', +}; + +const { applyMigration, up } = makePromptReplaceMigration({ + accepted: ACCEPTED_OLD_MD5, + current: NEW_SHIPPED_MD5, + label: 'FableLoom validated outline expansion contract', + customizedHint: (filename) => + ` Merge the exact outline-key and transition-preservation rules from\n` + + ` data.reference/prompts/stages/${filename} into the installed template.`, +}); + +export { applyMigration }; +export default { up }; diff --git a/scripts/migrations/321-fableloom-outline-expansion-contract.test.js b/scripts/migrations/321-fableloom-outline-expansion-contract.test.js new file mode 100644 index 000000000..3736f038a --- /dev/null +++ b/scripts/migrations/321-fableloom-outline-expansion-contract.test.js @@ -0,0 +1,16 @@ +import { describe } from 'vitest'; + +import migration, { + applyMigration, ACCEPTED_OLD_MD5, NEW_SHIPPED_MD5, +} from './321-fableloom-outline-expansion-contract.js'; +import { runPromptMigrationTests } from './_testHelpers.js'; + +describe('migration 321 — FableLoom validated outline expansion contract', () => { + runPromptMigrationTests({ + migration, + applyMigration, + ACCEPTED_OLD_MD5, + NEW_SHIPPED_MD5, + prefix: 'migration-321-', + }); +}); diff --git a/scripts/setup-data-drift.test.js b/scripts/setup-data-drift.test.js index d08f0a000..8f93f8124 100644 --- a/scripts/setup-data-drift.test.js +++ b/scripts/setup-data-drift.test.js @@ -55,7 +55,7 @@ const EXPECTED_STAGE_OLD = { 'pipeline-judge-foundation.md': ['74c0244e641dcf7a73e9c83123ebdee9', '4c0bd349ff4d329048c9f4ac068745d4', 'edf7850d0c724c63761bc9fb667227d9', '02a8e9215ba534b333f3a29f11f3ac4f'], 'pipeline-observer.md': ['f3dc51ac077050a887c2161ee7438181'], 'pipeline-self-improve.md': ['ed0b0df42e0690d515b8dd88911931e4'], - 'fableloom-weave-episode.md': ['1fea11b8c4269008561ac22a30494d46', '1b27f5b0073a304c21079aa6e2c71447', '4c9454d1537c4ebb3becbfa04fae3ed8', '18a442e39b973e4074a0d595928a665d'], + 'fableloom-weave-episode.md': ['1fea11b8c4269008561ac22a30494d46', '1b27f5b0073a304c21079aa6e2c71447', '4c9454d1537c4ebb3becbfa04fae3ed8', '18a442e39b973e4074a0d595928a665d', 'e0f8d864caa8746912b56cd567f1c09d'], 'fableloom-branch-node.md': ['f558e4804b056a5961af1ea74fdef2ba', '6279b1c9912c300363a727245d22fe84', 'c14e2b9c435e43a8c3b134a62cd66d08'], 'fableloom-play-turn.md': ['bb33dc9bc483668d88196ca972d5f364'], 'fableloom-feedback-episode.md': ['43d1525fcedce99b933ae5b003516a36', 'd09bb405478d24c294b0c658ef365cd1'], @@ -95,7 +95,7 @@ const EXPECTED_STAGE_NEW = { 'pipeline-judge-foundation.md': 'e44b6c50d741bbd21fc86f481684c410', 'pipeline-observer.md': '29e0212d2252b1be3278f20e2959eb8e', 'pipeline-self-improve.md': '95b378832ff78e5976a6a63fcf328090', - 'fableloom-weave-episode.md': 'e0f8d864caa8746912b56cd567f1c09d', + 'fableloom-weave-episode.md': 'b4d363db94fd8a9928fa977745c76ff9', 'fableloom-branch-node.md': '39a208c8cc593d0531af50760e3cf0da', 'fableloom-play-turn.md': 'e35ad91aae263e3adf28d1e047a46661', 'fableloom-feedback-episode.md': '1aaa6f17acad6a3215e48dcce14e8670', diff --git a/server/lib/fableLoomOutline.js b/server/lib/fableLoomOutline.js index 0c261a2f2..e1e04b970 100644 --- a/server/lib/fableLoomOutline.js +++ b/server/lib/fableLoomOutline.js @@ -36,6 +36,9 @@ export const OUTLINE_ISSUE_CODES = Object.freeze({ LATE_AUDIENCE_CONNECTION: 'LATE_AUDIENCE_CONNECTION', MISSING_EPISODE_OUTLINE: 'MISSING_EPISODE_OUTLINE', EPISODE_OUTLINE_NOT_VALIDATED: 'EPISODE_OUTLINE_NOT_VALIDATED', + TELEPLAY_SCENE_MEMBERSHIP_MISMATCH: 'TELEPLAY_SCENE_MEMBERSHIP_MISMATCH', + TELEPLAY_START_MISMATCH: 'TELEPLAY_START_MISMATCH', + TELEPLAY_SCENE_CONTRACT_MISMATCH: 'TELEPLAY_SCENE_CONTRACT_MISMATCH', MISSING_OVERNIGHT_VOICEMAIL: 'MISSING_OVERNIGHT_VOICEMAIL', EMPTY_OVERNIGHT_VOICEMAIL: 'EMPTY_OVERNIGHT_VOICEMAIL', MISSING_NEXT_SEASON_TEASER: 'MISSING_NEXT_SEASON_TEASER', @@ -298,6 +301,69 @@ export function analyzeStoryOutline(outline, { return { issues, stats }; } +const sortedTransitionContract = (transitions, targetKey) => asArray(transitions) + .map((transition) => `${transition?.[targetKey] || ''}\u0000${transition?.intent || ''}`) + .sort(); + +/** Validate that a persisted beat outline still mirrors an expanded teleplay. */ +export function analyzeStoryOutlineTeleplaySync(episode, outline, { + participationMode = 'protagonist', +} = {}) { + const nodes = asArray(episode?.nodes); + if (!nodes.length) return { issues: [], stats: { errorCount: 0, matches: true } }; + const scenes = asArray(outline?.scenes); + const byKey = new Map(scenes.map((scene) => [scene.key, scene])); + const nodeIds = new Set(nodes.map((node) => node.id)); + const issues = []; + const push = (code, message, extra = {}) => outlineIssue(issues, code, 'error', message, extra); + + if (scenes.length !== nodes.length + || byKey.size !== nodes.length + || scenes.some((scene) => !nodeIds.has(scene.key))) { + push( + OUTLINE_ISSUE_CODES.TELEPLAY_SCENE_MEMBERSHIP_MISMATCH, + 'The beat outline does not cover every expanded teleplay scene exactly once.', + ); + } + if (outline?.startKey !== episode?.startNodeId) { + push( + OUTLINE_ISSUE_CODES.TELEPLAY_START_MISMATCH, + 'The beat outline opening does not match the expanded teleplay opening scene.', + { sceneKey: outline?.startKey || undefined }, + ); + } + for (const node of nodes) { + const scene = byKey.get(node.id); + if (!scene) continue; + const expectedProtagonistPresence = node.protagonistPresence + || (participationMode === 'helper' + && node.audienceConnection === 'connected' + && node.playbackMode !== 'cut' + ? 'offscreen' + : 'onscreen'); + const semanticFieldsMatch = scene.title === node.title + && scene.playbackMode === node.playbackMode + && scene.audienceConnection === node.audienceConnection + && scene.protagonistPresence === expectedProtagonistPresence + && scene.isEnding === node.isEnding + && (scene.endingLabel || '') === (node.endingLabel || ''); + const outlineTransitions = sortedTransitionContract(scene.transitions, 'targetKey'); + const nodeTransitions = sortedTransitionContract(node.transitions, 'targetNodeId'); + if (!semanticFieldsMatch || JSON.stringify(outlineTransitions) !== JSON.stringify(nodeTransitions)) { + push( + OUTLINE_ISSUE_CODES.TELEPLAY_SCENE_CONTRACT_MISMATCH, + `Beat "${scene.title || scene.key}" no longer matches its expanded teleplay scene contract.`, + { sceneKey: node.id }, + ); + } + } + + return { + issues, + stats: { errorCount: issues.length, matches: issues.length === 0 }, + }; +} + /** * Validate outline coverage for the complete series. This is the hard gate * used by teleplay expansion: authors can draft episodes in order, but no @@ -307,6 +373,7 @@ export function analyzeStoryOutline(outline, { export function analyzeSeriesStoryOutlines(loom) { const episodes = asArray(loom?.episodes); const issues = []; + const readyEpisodeIds = new Set(); const push = (code, severity, message, extra = {}) => outlineIssue(issues, code, severity, message, extra); const validVoicemails = new Map(asArray(loom?.seriesPlan?.interEpisodeVoicemails) .filter((item) => item?.fromEpisodeId && item?.toEpisodeId) @@ -326,11 +393,27 @@ export function analyzeSeriesStoryOutlines(loom) { participationMode: loom?.participationMode, requireAudienceIntroduction: index === 0, }); - validation.issues.forEach((issue) => push(issue.code, issue.severity, `Episode ${episode.number || index + 1}: ${issue.message}`, { - episodeId: episode.id, - ...(issue.sceneKey ? { sceneKey: issue.sceneKey } : {}), - ...(Number.isInteger(issue.transitionIndex) ? { transitionIndex: issue.transitionIndex } : {}), - })); + validation.issues.forEach((issue) => { + push(issue.code, issue.severity, `Episode ${episode.number || index + 1}: ${issue.message}`, { + episodeId: episode.id, + ...(issue.sceneKey ? { sceneKey: issue.sceneKey } : {}), + ...(Number.isInteger(issue.transitionIndex) ? { transitionIndex: issue.transitionIndex } : {}), + }); + }); + const teleplaySync = analyzeStoryOutlineTeleplaySync(episode, episode.storyOutline, { + participationMode: loom?.participationMode, + }); + teleplaySync.issues.forEach((issue) => { + push( + issue.code, + issue.severity, + `Episode ${episode.number || index + 1}: ${issue.message}`, + { + episodeId: episode.id, + ...(issue.sceneKey ? { sceneKey: issue.sceneKey } : {}), + }, + ); + }); if (episode.storyOutline.validation?.status !== 'valid') { push( OUTLINE_ISSUE_CODES.EPISODE_OUTLINE_NOT_VALIDATED, @@ -338,6 +421,8 @@ export function analyzeSeriesStoryOutlines(loom) { `Episode ${episode.number || index + 1}'s beat outline must be validated before teleplay expansion.`, { episodeId: episode.id }, ); + } else if (validation.stats.errorCount === 0 && teleplaySync.stats.matches) { + readyEpisodeIds.add(episode.id); } }); @@ -373,7 +458,7 @@ export function analyzeSeriesStoryOutlines(loom) { ); } - const readyEpisodeCount = episodes.filter((episode) => episode.storyOutline?.validation?.status === 'valid').length; + const readyEpisodeCount = readyEpisodeIds.size; return { issues, stats: { diff --git a/server/lib/fableLoomOutline.test.js b/server/lib/fableLoomOutline.test.js index 80519dc0a..24ea025c3 100644 --- a/server/lib/fableLoomOutline.test.js +++ b/server/lib/fableLoomOutline.test.js @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { analyzeSeriesStoryOutlines, analyzeStoryOutline, + analyzeStoryOutlineTeleplaySync, describeStoryOutlineForPrompt, sanitizeStoryOutline, } from './fableLoomOutline.js'; @@ -127,4 +128,46 @@ describe('FableLoom story beat outlines', () => { ])); expect(result.stats.ready).toBe(false); }); + + it('does not count a claimed-valid outline as ready when the expanded teleplay has drifted', () => { + const storyOutline = { + ...sanitizeStoryOutline(validOutline), + validation: { status: 'valid', issues: [] }, + }; + const episode = { + id: 'ep-1', + number: 1, + startNodeId: 's1', + storyOutline, + nodes: storyOutline.scenes.map((scene) => ({ + id: scene.key, + title: scene.title, + playbackMode: scene.playbackMode, + audienceConnection: scene.audienceConnection, + protagonistPresence: scene.protagonistPresence, + isEnding: scene.isEnding, + endingLabel: scene.endingLabel, + transitions: scene.transitions.map((item) => ({ + targetNodeId: item.targetKey, + intent: item.intent, + })), + })), + }; + episode.nodes.push({ + id: 'new-scene', title: 'New scene', playbackMode: 'decision', + audienceConnection: 'connected', protagonistPresence: 'onscreen', + isEnding: true, endingLabel: 'New ending', transitions: [], + }); + + const sync = analyzeStoryOutlineTeleplaySync(episode, storyOutline); + const series = analyzeSeriesStoryOutlines({ + participationMode: 'protagonist', episodes: [episode], seriesPlan: {}, + }); + + expect(sync.stats.matches).toBe(false); + expect(sync.issues).toContainEqual(expect.objectContaining({ + code: 'TELEPLAY_SCENE_MEMBERSHIP_MISMATCH', + })); + expect(series.stats).toMatchObject({ ready: false, readyEpisodeCount: 0 }); + }); }); diff --git a/server/lib/fableLoomPlaytest.js b/server/lib/fableLoomPlaytest.js index 2c161d3b5..4102170c8 100644 --- a/server/lib/fableLoomPlaytest.js +++ b/server/lib/fableLoomPlaytest.js @@ -15,11 +15,16 @@ const hasText = (value) => typeof value === 'string' && value.trim().length > 0; export const FABLELOOM_PLAYTEST_LIMITS = Object.freeze({ DEFAULT_MAX_PATHS: 96, MAX_PATHS: 256, + MAX_TOTAL_PATHS: 256, + DEFAULT_PROMPT_MAX_CHARS: 400_000, + MAX_PROMPT_MAX_CHARS: 1_000_000, DEFAULT_MAX_STEPS: 256, MAX_STEPS: 1000, MAX_NODE_VISITS: 2, }); +export const PLAYTEST_PROMPT_TRUNCATION_MARKER = '[PLAYTHROUGH TRACE INCOMPLETE]'; + export const PLAYTEST_ISSUE_CODES = Object.freeze({ NO_START: 'NO_START', DANGLING_PATH: 'DANGLING_PATH', @@ -27,6 +32,7 @@ export const PLAYTEST_ISSUE_CODES = Object.freeze({ NON_TERMINATING_CYCLE: 'NON_TERMINATING_CYCLE', STEP_LIMIT: 'STEP_LIMIT', VARIATION_LIMIT: 'VARIATION_LIMIT', + UNCOVERED_NODE: 'UNCOVERED_NODE', UNCOVERED_TRANSITION: 'UNCOVERED_TRANSITION', }); @@ -38,6 +44,10 @@ const transitionKey = (nodeId, transition, index) => ( hasText(transition?.id) ? transition.id : `${nodeId}:transition-${index + 1}` ); +const transitionCoverageKey = (nodeId, transitionId, index) => ( + `${nodeId}\u0000${index}\u0000${transitionId}` +); + const publicPath = (path, index) => ({ id: `path-${index + 1}`, nodeIds: path.nodeIds, @@ -84,7 +94,7 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { return true; }; - const walk = ({ nodeId, nodeIds, transitionIds, choices, visits }) => { + const walk = ({ nodeId, nodeIds, transitionIds, transitionCoverageKeys, choices, visits }) => { if (rawPaths.length >= maxPaths) { capped = true; return; @@ -94,6 +104,7 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { record({ nodeIds, transitionIds, + transitionCoverageKeys, choices, termination: 'dangling-path', problemNodeId: nodeId, @@ -106,6 +117,7 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { record({ nodeIds: nextNodeIds, transitionIds, + transitionCoverageKeys, choices, termination: 'ending', endingNodeId: node.id, @@ -117,6 +129,7 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { record({ nodeIds: nextNodeIds, transitionIds, + transitionCoverageKeys, choices, termination: 'step-limit', problemNodeId: node.id, @@ -129,6 +142,7 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { record({ nodeIds: nextNodeIds, transitionIds, + transitionCoverageKeys, choices, termination: 'dead-end', problemNodeId: node.id, @@ -144,6 +158,10 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { const id = transitionKey(node.id, transition, index); const targetId = transition?.targetNodeId; const nextTransitionIds = [...transitionIds, id]; + const nextTransitionCoverageKeys = [ + ...transitionCoverageKeys, + transitionCoverageKey(node.id, id, index), + ]; const nextChoices = [...choices, { nodeId: node.id, transitionId: id, @@ -154,6 +172,7 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { record({ nodeIds: nextNodeIds, transitionIds: nextTransitionIds, + transitionCoverageKeys: nextTransitionCoverageKeys, choices: nextChoices, termination: 'dangling-path', problemNodeId: node.id, @@ -166,6 +185,7 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { record({ nodeIds: [...nextNodeIds, targetId], transitionIds: nextTransitionIds, + transitionCoverageKeys: nextTransitionCoverageKeys, choices: nextChoices, termination: 'cycle', problemNodeId: targetId, @@ -179,6 +199,7 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { nodeId: targetId, nodeIds: nextNodeIds, transitionIds: nextTransitionIds, + transitionCoverageKeys: nextTransitionCoverageKeys, choices: nextChoices, visits: nextVisits, }); @@ -190,6 +211,7 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { nodeId: episode.startNodeId, nodeIds: [], transitionIds: [], + transitionCoverageKeys: [], choices: [], visits: new Map([[episode.startNodeId, 1]]), }); @@ -197,15 +219,17 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { const paths = rawPaths.map(publicPath); const visitedNodeIds = new Set(paths.flatMap((path) => path.nodeIds)); - const visitedTransitionIds = new Set(paths.flatMap((path) => path.transitionIds)); + const visitedTransitionKeys = new Set(rawPaths.flatMap((path) => path.transitionCoverageKeys)); const allTransitions = nodes.flatMap((node) => asArray(node.transitions).map((transition, index) => ({ nodeId: node.id, transitionId: transitionKey(node.id, transition, index), + coverageKey: transitionCoverageKey(node.id, transitionKey(node.id, transition, index), index), targetNodeId: transition?.targetNodeId || null, }))); const uncoveredTransitions = allTransitions.filter((transition) => ( - !visitedTransitionIds.has(transition.transitionId) + !visitedTransitionKeys.has(transition.coverageKey) )); + const uncoveredNodes = nodes.filter((node) => !visitedNodeIds.has(node.id)); const issues = []; const push = (code, severity, message, extra = {}) => issues.push({ code, severity, message, ...extra }); @@ -244,12 +268,21 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { `Playthrough enumeration reached the ${maxPaths}-variation limit; the report is representative rather than exhaustive.`, ); } + for (const node of uncoveredNodes) { + push( + PLAYTEST_ISSUE_CODES.UNCOVERED_NODE, + capped ? 'warning' : 'error', + 'A story scene was not reached by any generated playthrough variation.', + { nodeId: node.id }, + ); + } for (const transition of uncoveredTransitions) { + const { coverageKey: _coverageKey, ...publicTransition } = transition; push( PLAYTEST_ISSUE_CODES.UNCOVERED_TRANSITION, capped ? 'warning' : 'error', 'A story path was not exercised by the generated playthrough variations.', - transition, + publicTransition, ); } @@ -272,7 +305,7 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { nodeCount: nodes.length, visitedNodeCount: visitedNodeIds.size, transitionCount: allTransitions.length, - visitedTransitionCount: visitedTransitionIds.size, + visitedTransitionCount: visitedTransitionKeys.size, endingCounts, errorCount, warningCount, @@ -287,17 +320,37 @@ export function enumerateEpisodePlaythroughs(episode, options = {}) { /** Aggregate the deterministic harness across every episode in a loom. */ export function analyzeLoomPlaythroughs(loom, options = {}) { - const episodes = asArray(loom?.episodes).map((episode, index) => ({ - number: episode.number || index + 1, - title: episode.title || `Episode ${episode.number || index + 1}`, - ...enumerateEpisodePlaythroughs(episode, { + const sourceEpisodes = asArray(loom?.episodes); + const perEpisodeMaxPaths = boundedInteger( + options.maxPaths, + FABLELOOM_PLAYTEST_LIMITS.DEFAULT_MAX_PATHS, + FABLELOOM_PLAYTEST_LIMITS.MAX_PATHS, + ); + let remainingPaths = FABLELOOM_PLAYTEST_LIMITS.MAX_TOTAL_PATHS; + const episodes = sourceEpisodes.map((episode, index) => { + // Reserve one variation for every later episode so an early branch-heavy + // graph cannot starve the rest of the series. The series-wide cap keeps + // API responses and AI review prompts bounded even at the record limits. + const remainingEpisodes = sourceEpisodes.length - index - 1; + const episodeMaxPaths = Math.max(1, Math.min( + perEpisodeMaxPaths, + remainingPaths - remainingEpisodes, + )); + const report = enumerateEpisodePlaythroughs(episode, { ...options, + maxPaths: episodeMaxPaths, graphOptions: { participationMode: loom?.participationMode, requireAudienceIntroduction: index === 0, }, - }), - })); + }); + remainingPaths -= report.stats.variationCount; + return { + number: episode.number || index + 1, + title: episode.title || `Episode ${episode.number || index + 1}`, + ...report, + }; + }); const errorCount = episodes.reduce((total, episode) => ( total + episode.stats.errorCount + episode.structural.stats.errorCount ), 0); @@ -323,29 +376,114 @@ export function analyzeLoomPlaythroughs(loom, options = {}) { }; } -/** Compact path traces for the AI playthrough-quality stage. */ -export function describeLoomPlaythroughsForPrompt(loom, report) { +const choiceDigestKey = (choice, targetNodeId) => [ + choice?.nodeId || '', + choice?.transitionId || '', + targetNodeId || '', + choice?.automatic === true ? 'auto' : 'choice', + choice?.intent || '', +].join('\u0000'); + +/** + * Build bounded path traces for the AI playthrough-quality stage. + * + * Scene and choice legends keep long authored labels out of every repeated + * path. The result says explicitly when the caller's context budget cannot + * hold every variation; service callers fail closed rather than presenting a + * partial review as whole-series quality assurance. + */ +export function buildLoomPlaythroughPromptDigest(loom, report, options = {}) { + const maxChars = boundedInteger( + options.maxChars, + FABLELOOM_PLAYTEST_LIMITS.DEFAULT_PROMPT_MAX_CHARS, + FABLELOOM_PLAYTEST_LIMITS.MAX_PROMPT_MAX_CHARS, + ); const episodesById = new Map(asArray(loom?.episodes).map((episode) => [episode.id, episode])); - return asArray(report?.episodes).map((episodeReport) => { + const episodeSections = []; + const totalVariationCount = asArray(report?.episodes).reduce((total, episode) => ( + total + asArray(episode?.paths).length + ), 0); + let includedVariationCount = 0; + + for (const episodeReport of asArray(report?.episodes)) { const episode = episodesById.get(episodeReport.episodeId); const nodesById = new Map(asArray(episode?.nodes).map((node) => [node.id, node])); - const traces = episodeReport.paths.map((path) => { - const beats = path.nodeIds.map((nodeId, index) => { - const node = nodesById.get(nodeId); + const nodeIds = [...new Set(asArray(episodeReport.paths).flatMap((path) => path.nodeIds))]; + const nodeAliases = new Map(nodeIds.map((nodeId, index) => [nodeId, `N${index + 1}`])); + const choicesByKey = new Map(); + asArray(episodeReport.paths).forEach((path) => { + asArray(path.choices).forEach((choice, index) => { + const targetNodeId = path.nodeIds[index + 1] || null; + const key = choiceDigestKey(choice, targetNodeId); + if (!choicesByKey.has(key)) choicesByKey.set(key, { choice, targetNodeId }); + }); + }); + const choiceAliases = new Map([...choicesByKey.keys()].map((key, index) => [key, `C${index + 1}`])); + const sceneLegend = nodeIds.map((nodeId) => { + const node = nodesById.get(nodeId); + return `${nodeAliases.get(nodeId)} = [${nodeId}] ${node?.title || 'Untitled scene'}`; + }); + const choiceLegend = [...choicesByKey.entries()].map(([key, { choice, targetNodeId }]) => [ + `${choiceAliases.get(key)} = [${choice?.transitionId || 'unlabeled'}]`, + `${nodeAliases.get(choice?.nodeId) || `[${choice?.nodeId || '?'}]`} -> ${nodeAliases.get(targetNodeId) || `[${targetNodeId || '?'}]`};`, + `${choice?.automatic ? 'auto' : 'choice'}; intent: ${choice?.intent || '(unlabeled)'}`, + ].join(' ')); + const traces = asArray(episodeReport.paths).map((path) => { + const beats = path.nodeIds.flatMap((nodeId, index) => { + const nodeAlias = nodeAliases.get(nodeId) || `[${nodeId}]`; const choice = path.choices[index]; - const label = node?.title || nodeId; - if (!choice) return label; - return `${label} --${choice.automatic ? 'auto' : 'choice'}: ${choice.intent || '(unlabeled)'}-->`; + if (!choice) return [nodeAlias]; + const key = choiceDigestKey(choice, path.nodeIds[index + 1] || null); + return [nodeAlias, `-${choiceAliases.get(key) || `[${choice.transitionId || '?'}]`}->`]; }); const end = path.ended - ? `END: ${path.endingLabel || path.endingNodeId}` - : `STOPPED: ${path.termination}`; + ? `END ${nodeAliases.get(path.endingNodeId) || `[${path.endingNodeId}]`}` + : `STOPPED ${path.termination}`; return `[${path.id}] ${[...beats, end].join(' ')}`; }); - return [ + const section = [ `## Episode ${episodeReport.number}: ${episodeReport.title}`, `${episodeReport.stats.variationCount} variation(s); ${episodeReport.stats.visitedTransitionCount}/${episodeReport.stats.transitionCount} paths exercised; exhaustive: ${episodeReport.stats.enumerationComplete ? 'yes' : 'no'}`, + 'Scene aliases:', + ...sceneLegend, + 'Choice aliases:', + ...choiceLegend, + 'Variations:', ...traces, ].join('\n'); - }).join('\n\n'); + const candidate = [...episodeSections, section].join('\n\n'); + if (candidate.length > maxChars) break; + episodeSections.push(section); + includedVariationCount += traces.length; + } + + const complete = includedVariationCount === totalVariationCount; + if (complete) { + return { + text: episodeSections.join('\n\n'), + complete, + includedVariationCount, + totalVariationCount, + maxChars, + }; + } + + const markerText = () => `${PLAYTEST_PROMPT_TRUNCATION_MARKER} Included ${includedVariationCount}/${totalVariationCount} variations within the ${maxChars}-character digest budget.`; + while (episodeSections.length && [...episodeSections, markerText()].join('\n\n').length > maxChars) { + const removed = episodeSections.pop(); + const removedMatch = removed.match(/^## Episode[\s\S]*?\n(\d+) variation\(s\);/); + includedVariationCount -= Number(removedMatch?.[1] || 0); + } + return { + text: [...episodeSections, markerText()].join('\n\n').slice(0, maxChars), + complete: false, + includedVariationCount, + totalVariationCount, + maxChars, + }; +} + +/** Compact path traces as text for legacy callers and prompt previews. */ +export function describeLoomPlaythroughsForPrompt(loom, report, options) { + return buildLoomPlaythroughPromptDigest(loom, report, options).text; } diff --git a/server/lib/fableLoomPlaytest.test.js b/server/lib/fableLoomPlaytest.test.js index b9d7eed5a..4bb924615 100644 --- a/server/lib/fableLoomPlaytest.test.js +++ b/server/lib/fableLoomPlaytest.test.js @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest'; import { analyzeLoomPlaythroughs, + buildLoomPlaythroughPromptDigest, describeLoomPlaythroughsForPrompt, enumerateEpisodePlaythroughs, PLAYTEST_ISSUE_CODES, + PLAYTEST_PROMPT_TRUNCATION_MARKER, } from './fableLoomPlaytest.js'; const transition = (id, targetNodeId, intent) => ({ id, targetNodeId, intent, triggers: [] }); @@ -106,6 +108,50 @@ describe('enumerateEpisodePlaythroughs', () => { expect.objectContaining({ code: PLAYTEST_ISSUE_CODES.UNCOVERED_TRANSITION, severity: 'warning' }), ])); }); + + it('fails an exhaustive run when an authored scene and ending are unreachable', () => { + const episode = branchingEpisode(); + episode.nodes.push({ + id: 'orphan', title: 'Orphan Ending', prose: 'No route reaches this ending.', + playbackMode: 'decision', audienceConnection: 'connected', isEnding: true, + endingLabel: 'Unreachable', transitions: [], + }); + + const report = enumerateEpisodePlaythroughs(episode); + + expect(report.stats).toMatchObject({ + nodeCount: 5, + visitedNodeCount: 4, + passed: false, + endingCounts: { ending: 2, orphan: 0 }, + }); + expect(report.issues).toContainEqual(expect.objectContaining({ + code: PLAYTEST_ISSUE_CODES.UNCOVERED_NODE, + severity: 'error', + nodeId: 'orphan', + })); + }); + + it('counts transition records by owning scene even when ids repeat', () => { + const episode = branchingEpisode(); + episode.nodes[0].transitions[0].id = 'branch'; + episode.nodes[0].transitions[1].id = 'branch'; + episode.nodes[1].transitions[0].id = 'finish'; + episode.nodes[2].transitions[0].id = 'finish'; + + const report = enumerateEpisodePlaythroughs(episode); + + expect(report.stats).toMatchObject({ + transitionCount: 4, + visitedTransitionCount: 4, + passed: true, + }); + expect(report.issues.some((issue) => issue.code === PLAYTEST_ISSUE_CODES.UNCOVERED_TRANSITION)).toBe(false); + expect(report.paths.map((path) => path.transitionIds)).toEqual([ + ['branch', 'finish'], + ['branch', 'finish'], + ]); + }); }); describe('analyzeLoomPlaythroughs', () => { @@ -121,7 +167,103 @@ describe('analyzeLoomPlaythroughs', () => { stats: { episodeCount: 1, variationCount: 2, visitedTransitionCount: 4 }, }); expect(digest).toContain('## Episode 1: Example Episode'); - expect(digest).toContain('Opening --choice: Take the left route-->'); - expect(digest).toContain('END: Beacon found'); + expect(digest).toContain('C1 = [take-left] N1 -> N2; choice; intent: Take the left route'); + expect(digest).toContain('[path-1] N1 -C1-> N2'); + expect(digest).toContain('END N3'); + }); + + it('bounds maximal authored labels and reports when every trace cannot fit', () => { + const sharedNodes = Array.from({ length: 103 }, (_, index) => ({ + id: `node-${index + 1}`, + title: 'T'.repeat(300), + prose: 'The route continues.', + playbackMode: index === 102 ? 'decision' : 'cut', + audienceConnection: 'connected', + isEnding: false, + transitions: index === 102 ? [] : [transition( + `transition-${index + 1}`, + `node-${index + 2}`, + 'I'.repeat(120), + )], + })); + const endings = Array.from({ length: 96 }, (_, index) => ({ + id: `ending-${index + 1}`, + title: 'E'.repeat(300), + prose: 'The route resolves.', + playbackMode: 'decision', + audienceConnection: 'connected', + isEnding: true, + endingLabel: `Ending ${index + 1}`, + transitions: [], + })); + sharedNodes.at(-1).transitions = endings.map((ending, index) => transition( + `ending-transition-${index + 1}`, + ending.id, + 'I'.repeat(120), + )); + const episode = { + id: 'maximal-episode', + number: 1, + title: 'Maximal Episode', + startNodeId: 'node-1', + nodes: [...sharedNodes, ...endings], + }; + const loom = { participationMode: 'protagonist', episodes: [episode] }; + const report = analyzeLoomPlaythroughs(loom); + const complete = buildLoomPlaythroughPromptDigest(loom, report, { maxChars: 400_000 }); + const bounded = buildLoomPlaythroughPromptDigest(loom, report, { maxChars: 20_000 }); + + expect(complete.complete).toBe(true); + expect(complete.text.length).toBeLessThanOrEqual(400_000); + expect(complete.includedVariationCount).toBe(96); + expect(bounded.complete).toBe(false); + expect(bounded.text.length).toBeLessThanOrEqual(20_000); + expect(bounded.text).toContain(PLAYTEST_PROMPT_TRUNCATION_MARKER); + }); + + it('enforces one variation budget across a branch-heavy series', () => { + const episodes = Array.from({ length: 30 }, (_, episodeIndex) => { + const id = `episode-${episodeIndex + 1}`; + const endNodes = Array.from({ length: 12 }, (_, pathIndex) => ({ + id: `${id}-end-${pathIndex + 1}`, + title: `Ending ${pathIndex + 1}`, + prose: 'The route resolves.', + playbackMode: 'decision', + audienceConnection: 'connected', + isEnding: true, + endingLabel: `Ending ${pathIndex + 1}`, + transitions: [], + })); + return { + id, + number: episodeIndex + 1, + title: `Episode ${episodeIndex + 1}`, + startNodeId: `${id}-opening`, + nodes: [{ + id: `${id}-opening`, + title: 'Opening', + prose: 'Twelve routes open.', + playbackMode: 'decision', + audienceConnection: 'connected', + isEnding: false, + transitions: endNodes.map((node, pathIndex) => transition( + `${id}-path-${pathIndex + 1}`, + node.id, + `Choose route ${pathIndex + 1}`, + )), + }, ...endNodes], + }; + }); + + const report = analyzeLoomPlaythroughs({ participationMode: 'protagonist', episodes }, { + maxPaths: 256, + }); + + expect(report.stats.variationCount).toBeLessThanOrEqual(256); + expect(report.episodes.every((episode) => episode.stats.variationCount >= 1)).toBe(true); + expect(report.complete).toBe(false); + expect(report.episodes.some((episode) => ( + episode.issues.some((issue) => issue.code === PLAYTEST_ISSUE_CODES.VARIATION_LIMIT) + ))).toBe(true); }); }); diff --git a/server/services/fableLoom/editorial.js b/server/services/fableLoom/editorial.js index e0b0a95a0..869f3e270 100644 --- a/server/services/fableLoom/editorial.js +++ b/server/services/fableLoom/editorial.js @@ -14,25 +14,28 @@ import { analyzeEpisodeGraph, describeGraphForPrompt } from '../../lib/fableLoom import { analyzeSeriesStoryOutlines, analyzeStoryOutline, + analyzeStoryOutlineTeleplaySync, describeStoryOutlineForPrompt, sanitizeStoryOutline, } from '../../lib/fableLoomOutline.js'; import { analyzeLoomPlaythroughs, - describeLoomPlaythroughsForPrompt, + buildLoomPlaythroughPromptDigest, } from '../../lib/fableLoomPlaytest.js'; import { computeTopologicalNodeOrder } from '../../lib/fableLoomProduction.js'; +import { CHARS_PER_TOKEN, usableInputTokens } from '../../lib/contextBudget.js'; import { isFableLoomPlaybackMode, FABLELOOM_PROTAGONIST_PRESENCE, } from '../../lib/fableLoomPlayback.js'; import { trimTo } from '../../lib/storyBible.js'; +import { renderCanonForPrompt } from '../../lib/universePromptRenderers.js'; import { normalizeFableLoomCameraMovement } from '../../lib/fableLoomCameraMovements.js'; import { startAIOp } from '../aiStatusEvents.js'; -import { runStagedLLM } from '../stageRunner.js'; +import { buildPrompt } from '../promptService.js'; +import { resolveStageContext, runStageScopedInlineLLM } from '../stageRunner.js'; import { getUniverse } from '../universeBuilder.js'; import { listVoiceProfiles } from '../voice/profiles.js'; -import { buildCanonDigest } from './weave.js'; import { getLoom, mutateLoom, @@ -44,10 +47,26 @@ const REVIEW_CATEGORIES = new Set([ 'coherence', 'character', 'choice', 'pacing', 'ending', 'continuity', 'canon', 'structure', ]); const AUTOPILOT_QUALITY_THRESHOLD = 8; +const EDITORIAL_PROMPT_HARD_MAX_CHARS = 1_000_000; +const EDITORIAL_OUTPUT_RESERVE_TOKENS = 8_000; +const INSTRUCTION_PLACEHOLDER_VALUES = new Set([ + 'concise whole-series editorial assessment', + 'episode_id_from_input', + 'only when changed', + 'complete replacement only when changed', + 'existing scene id, only when changed', + 'existing scene id or null', + 'existing episode id or null', + 'existing transition id', + 'scene_id_from_input', + "sharpened one scene's sensory detail without changing its beat.", + 'specific strength worth preserving', + 'the signal shivers through the flooded tunnel walls.', +]); const asArray = (value) => (Array.isArray(value) ? value : []); const hasText = (value) => typeof value === 'string' && value.trim().length > 0; -const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); +const hasOwn = (value, key) => Object.hasOwn(value, key); const clampScore = (value) => (Number.isFinite(value) ? Math.max(0, Math.min(10, Math.round(value * 10) / 10)) : null); @@ -66,12 +85,15 @@ const requireLoom = async (loomId) => { const llmOptions = ({ providerId, model, effort } = {}, source) => ({ source, returnsJson: true, + // The complete path trace is budgeted against this exact resolved route. + // A smaller proactive/runtime fallback could silently exceed its context. + allowFallback: false, ...(providerId ? { providerOverride: providerId } : {}), ...(model ? { modelOverride: model } : {}), ...(effort ? { effortOverride: effort } : {}), }); -const runEditorialAi = (stage, variables, route, { action, label, source }) => { +const runEditorialAi = (stage, prompt, route, { action, label, source }) => { const status = route.operationId ? startAIOp({ op: `fableloom-${action}`, label, @@ -97,9 +119,8 @@ const runEditorialAi = (stage, variables, route, { action, label, source }) => { { runId }, ); } - return runStagedLLM(stage, variables, options).then((result) => { - status?.complete('Editorial result ready', { runId: result.runId, shellReady: false }); - return result; + return runStageScopedInlineLLM(stage, prompt, options).then((result) => { + return { ...result, status }; }, (error) => { status?.error(error?.message || 'FableLoom editorial operation failed', { ...(error?.runId ? { runId: error.runId } : {}), @@ -108,6 +129,18 @@ const runEditorialAi = (stage, variables, route, { action, label, source }) => { }); }; +const finalizeEditorialOperation = (status, runId, work) => Promise.resolve() + .then(work) + .then((result) => { + status?.complete('Editorial operation complete', { runId, shellReady: false }); + return result; + }, (error) => { + status?.error(error?.message || 'FableLoom editorial operation failed', { + ...(runId ? { runId } : {}), + }); + throw error; + }); + const storyContext = (loom) => [ `Story: ${loom.name}`, loom.logline ? `Logline: ${loom.logline}` : '', @@ -125,6 +158,52 @@ const storyContext = (loom) => [ : '', ].filter(Boolean).join('\n'); +const withoutTemporalMetadata = (value) => { + if (Array.isArray(value)) return value.map(withoutTemporalMetadata); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries(Object.entries(value) + .filter(([key]) => !['createdAt', 'updatedAt'].includes(key)) + .map(([key, item]) => [key, withoutTemporalMetadata(item)])); +}; + +const editorialDependencyFingerprint = ({ universe, voiceProfiles, canonDigest }) => JSON.stringify({ + canonDigest, + continuityUniverse: universe ? withoutTemporalMetadata({ + characters: universe.characters, + places: universe.places, + objects: universe.objects, + }) : null, + voiceProfiles: asArray(voiceProfiles).map((profile) => ({ + id: profile.id, + version: profile.version, + binding: profile.binding, + approval: profile.approval, + engine: profile.engine, + modelRevision: profile.modelRevision, + })).sort((a, b) => String(a.id).localeCompare(String(b.id))), +}); + +const loadEditorialDependencies = async (loom, { + getUniverseFn = getUniverse, + listVoiceProfilesFn = listVoiceProfiles, +} = {}) => { + const [universe, voiceProfiles] = await Promise.all([ + loom.universeId ? getUniverseFn(loom.universeId) : null, + listVoiceProfilesFn(), + ]); + return { + universe, + voiceProfiles, + canonDigest: universe ? renderCanonForPrompt(universe) : '', + }; +}; + +const assertEditorialDependenciesUnchanged = (current, fingerprint, { code, message }) => { + if (editorialDependencyFingerprint(current) !== fingerprint) { + throw new ServerError(message, { status: 409, code }); + } +}; + const seriesPlanDigest = (loom) => JSON.stringify({ storyArc: trimTo(loom.seriesPlan?.storyArc, 6000), plotPoints: asArray(loom.seriesPlan?.plotPoints), @@ -155,28 +234,78 @@ const teleplayDigest = (loom) => asArray(loom.episodes).map((episode) => [ : '(no expanded teleplay scenes)', ].filter(Boolean).join('\n')).join('\n\n'); -const editorialFingerprint = (loom) => JSON.stringify({ - name: loom.name, - logline: loom.logline, - premise: loom.premise, - styleNotes: loom.styleNotes, - participationMode: loom.participationMode, - audienceCommunicationMedium: loom.audienceCommunicationMedium, - protagonistCharacterId: loom.protagonistCharacterId, - protagonistWardrobeId: loom.protagonistWardrobeId, - protagonistWardrobeLocked: loom.protagonistWardrobeLocked, - seriesPlan: loom.seriesPlan, - episodes: loom.episodes, -}); +// Whole-record writes must conflict on every semantic persisted field. Only +// timestamps are excluded: they can change without changing the story input. +const editorialFingerprint = (loom) => { + const { createdAt: _createdAt, updatedAt: _updatedAt, ...semantic } = loom || {}; + return JSON.stringify(semantic); +}; + +const assertEditorialSnapshotUnchanged = (current, fingerprint, { code, message }) => { + if (editorialFingerprint(current) !== fingerprint) { + throw new ServerError(message, { status: 409, code }); + } +}; + +const editorialPromptBudgetChars = (contextWindow) => Math.min( + EDITORIAL_PROMPT_HARD_MAX_CHARS, + usableInputTokens({ + contextWindow, + outputReserveTokens: EDITORIAL_OUTPUT_RESERVE_TOKENS, + }) * CHARS_PER_TOKEN, +); + +const editorialPromptCharacterCount = (variables) => Object.values(variables).reduce((total, value) => ( + total + (typeof value === 'string' ? value.length : 0) + ), 0); + +const assertEditorialPromptBudget = (variables, maxChars = EDITORIAL_PROMPT_HARD_MAX_CHARS) => { + const characterCount = editorialPromptCharacterCount(variables); + if (characterCount > maxChars) { + throw new ServerError( + `This story needs ${characterCount.toLocaleString()} prompt characters, above the selected model's ${maxChars.toLocaleString()}-character single-editor limit. Shorten the series, choose a larger-context model, or review it in smaller sections.`, + { status: 413, code: 'FABLELOOM_EDITORIAL_CONTEXT_TOO_LARGE' }, + ); + } + return variables; +}; + +const withCompletePlaythroughDigest = ({ loom, report, variables, maxPromptChars }) => { + const digest = buildLoomPlaythroughPromptDigest(loom, report, { maxChars: maxPromptChars }); + if (!digest.complete) { + throw new ServerError( + `The selected model can hold ${digest.includedVariationCount}/${digest.totalVariationCount} complete playthrough variations after the story context. No paths were silently omitted. Choose a larger-context model or review a smaller series.`, + { status: 413, code: 'FABLELOOM_PLAYTHROUGH_CONTEXT_TOO_LARGE' }, + ); + } + return { ...variables, playthroughDigest: digest.text }; +}; + +const renderEditorialPrompt = async ( + stage, + variables, + maxPromptChars, + { buildPromptFn = buildPrompt } = {}, +) => { + const prompt = await buildPromptFn(stage, variables); + assertEditorialPromptBudget({ renderedPrompt: prompt }, maxPromptChars); + return prompt; +}; + +const resolveEditorialPromptBudgetChars = async (stage, route, source) => { + const { contextWindow } = await resolveStageContext(stage, llmOptions(route, source)); + return editorialPromptBudgetChars(contextWindow); +}; /** Assemble every deterministic series-level authoring/playthrough signal. */ -export async function collectFableLoomEditorialDiagnostics(loom) { - const [universe, voiceProfiles] = await Promise.all([ - loom.universeId ? getUniverse(loom.universeId).catch(() => null) : null, - listVoiceProfiles().catch(() => []), - ]); +export async function collectFableLoomEditorialDiagnostics( + loom, + dependencySnapshot = null, + { playthroughReport = null } = {}, +) { + const { universe, voiceProfiles } = dependencySnapshot || await loadEditorialDependencies(loom); const outline = analyzeSeriesStoryOutlines(loom); - const playthrough = analyzeLoomPlaythroughs(loom); + const playthrough = playthroughReport || analyzeLoomPlaythroughs(loom); const episodes = loom.episodes.map((episode, index) => { const graph = analyzeEpisodeGraph(episode, { participationMode: loom.participationMode, @@ -242,23 +371,67 @@ const diagnosticLines = (diagnostics) => { `Continuity: ${diagnostics.stats.continuityErrors} error(s), ${diagnostics.stats.continuityWarnings} warning(s), ${diagnostics.stats.convergenceIssues} ambiguous convergence scene(s).`, `Playthroughs: ${diagnostics.stats.variationCount} variation(s), ${diagnostics.stats.endingVariationCount} ending path(s), ${diagnostics.stats.visitedTransitionCount}/${diagnostics.stats.transitionCount} transitions exercised.`, ]; - diagnostics.outline.issues.forEach((issue) => lines.push( - `- [outline/${issue.severity}] episode=${issue.episodeId || 'series'} scene=${issue.sceneKey || '-'} code=${issue.code}: ${issue.message}`, - )); + diagnostics.outline.issues.forEach((issue) => { + lines.push(`- [outline/${issue.severity}] episode=${issue.episodeId || 'series'} scene=${issue.sceneKey || '-'} code=${issue.code}: ${issue.message}`); + }); diagnostics.episodes.forEach((episode) => { - episode.graph.issues.forEach((issue) => lines.push( - `- [graph/${issue.severity}] episode=${episode.episodeId} node=${issue.nodeId || '-'} code=${issue.code}: ${issue.message}`, - )); - episode.continuity.findings.forEach((finding) => lines.push( - `- [continuity/${finding.severity}] episode=${episode.episodeId} node=${finding.nodeId || '-'} code=${finding.code}: ${finding.message} Fix: ${finding.remediation}`, - )); - asArray(episode.playtest?.issues).forEach((issue) => lines.push( - `- [playthrough/${issue.severity}] episode=${episode.episodeId} path=${issue.pathId || '-'} node=${issue.nodeId || '-'} code=${issue.code}: ${issue.message}`, - )); + episode.graph.issues.forEach((issue) => { + lines.push(`- [graph/${issue.severity}] episode=${episode.episodeId} node=${issue.nodeId || '-'} code=${issue.code}: ${issue.message}`); + }); + episode.continuity.findings.forEach((finding) => { + lines.push(`- [continuity/${finding.severity}] episode=${episode.episodeId} node=${finding.nodeId || '-'} code=${finding.code}: ${finding.message} Fix: ${finding.remediation}`); + }); + asArray(episode.playtest?.issues).forEach((issue) => { + lines.push(`- [playthrough/${issue.severity}] episode=${episode.episodeId} path=${issue.pathId || '-'} node=${issue.nodeId || '-'} code=${issue.code}: ${issue.message}`); + }); }); return lines.join('\n'); }; +const compactEditorialDiagnostics = (diagnostics) => { + const findings = [ + ...diagnostics.outline.issues + .filter((issue) => issue.severity === 'error') + .map((issue) => ({ + severity: 'high', + category: 'structure', + episodeId: issue.episodeId || null, + nodeId: issue.sceneKey || null, + pathId: null, + problem: issue.message, + suggestion: 'Repair and revalidate the complete episode beat outline.', + })), + ...diagnostics.episodes.flatMap((episode) => episode.graph.issues + .filter((issue) => issue.severity === 'error') + .map((issue) => ({ + severity: 'high', + category: 'structure', + episodeId: episode.episodeId, + nodeId: issue.nodeId || null, + pathId: null, + problem: issue.message, + suggestion: 'Repair the episode graph contract before another playthrough review.', + }))), + ...diagnostics.episodes.flatMap((episode) => episode.continuity.findings + .filter((finding) => finding.severity === 'error' + || finding.code === CONTINUITY_CODES.AMBIGUOUS_CONVERGENCE) + .map((finding) => ({ + severity: finding.severity === 'error' ? 'high' : 'medium', + category: 'continuity', + episodeId: episode.episodeId, + nodeId: finding.nodeId || null, + pathId: null, + problem: finding.message, + suggestion: finding.remediation || 'Repair the continuity break.', + }))), + ]; + return { + passed: diagnostics.passed, + stats: diagnostics.stats, + findings: findings.slice(0, 80), + }; +}; + const analysisStrings = (value) => asArray(value) .filter((item) => typeof item === 'string') .map((item) => trimTo(item, 1000)) @@ -301,19 +474,133 @@ const allowedSceneFields = [ 'audienceConnection', 'protagonistPresence', 'isEnding', 'endingLabel', ]; const allowedTransitionFields = ['targetNodeId', 'intent', 'triggers', 'description']; +const clearableSeriesPlanFields = new Set([ + 'plotPoints', 'sideQuests', 'deliveryOptions', 'interEpisodeVoicemails', 'nextSeasonTeaser', +]); + +const containsInstructionPlaceholder = (value) => { + if (typeof value === 'string') return INSTRUCTION_PLACEHOLDER_VALUES.has(value.trim().toLowerCase()); + if (Array.isArray(value)) return value.some(containsInstructionPlaceholder); + if (value && typeof value === 'object') return Object.values(value).some(containsInstructionPlaceholder); + return false; +}; + +const collectionHasContent = (value) => ( + (Array.isArray(value) && value.length > 0) + || (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0) +); + +const isPlainObject = (value) => value && typeof value === 'object' && !Array.isArray(value); +const isOptionalId = (value) => value === undefined + || (typeof value === 'string' && value.trim().length > 0); +const isPlanItem = (value) => isPlainObject(value) + && isOptionalId(value.id) + && typeof value.title === 'string' + && typeof value.description === 'string'; +const isEpisodeRef = (value, episodeIds) => value === undefined + || value === null + || (typeof value === 'string' && episodeIds.has(value)); + +const seriesPlanFieldValueIsValid = (key, value, episodeIds) => { + if (key === 'storyArc') return typeof value === 'string'; + if (key === 'plotPoints') { + return Array.isArray(value) && value.every((item) => ( + isPlanItem(item) && isEpisodeRef(item.episodeId, episodeIds) + )); + } + if (key === 'sideQuests') { + return Array.isArray(value) && value.every((item) => ( + isPlanItem(item) + && ['idea', 'planned', 'active', 'resolved'].includes(item.status) + && isEpisodeRef(item.startEpisodeId, episodeIds) + && isEpisodeRef(item.endEpisodeId, episodeIds) + )); + } + if (key === 'deliveryOptions') { + return isPlainObject(value) + && Object.keys(value).every((field) => ['overnightVoicemails', 'nextSeasonTeaser'].includes(field)) + && Object.values(value).every((field) => typeof field === 'boolean'); + } + if (key === 'interEpisodeVoicemails') { + return Array.isArray(value) && value.every((item) => ( + isPlainObject(item) + && isOptionalId(item.id) + && isEpisodeRef(item.fromEpisodeId, episodeIds) + && isEpisodeRef(item.toEpisodeId, episodeIds) + && typeof item.title === 'string' + && typeof item.transcript === 'string' + )); + } + if (key === 'nextSeasonTeaser') { + return value === null || (isPlainObject(value) + && typeof value.title === 'string' + && typeof value.transcript === 'string'); + } + return false; +}; + +const seriesPlanFieldValueIsClear = (key, value) => ( + (['plotPoints', 'sideQuests', 'interEpisodeVoicemails'].includes(key) + && Array.isArray(value) && value.length === 0) + || (key === 'deliveryOptions' + && value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) + || (key === 'nextSeasonTeaser' && ( + value === null + || (isPlainObject(value) + && !value.title.trim() + && !value.transcript.trim()) + )) +); -const applySeriesPlanPatch = (currentPlan, raw) => { +const applySeriesPlanPatch = (currentPlan, raw, explicitClears, episodeIds) => { if (!raw || typeof raw !== 'object') return currentPlan; const next = { ...currentPlan }; for (const key of [ 'storyArc', 'plotPoints', 'sideQuests', 'deliveryOptions', 'interEpisodeVoicemails', 'nextSeasonTeaser', ]) { - if (hasOwn(raw, key)) next[key] = raw[key]; + if (!hasOwn(raw, key)) continue; + if (!seriesPlanFieldValueIsValid(key, raw[key], episodeIds)) { + throw aiShapeError(`The model returned an invalid value for seriesPlan.${key}`); + } + if (clearableSeriesPlanFields.has(key) + && seriesPlanFieldValueIsClear(key, raw[key]) + && collectionHasContent(currentPlan?.[key]) + && !explicitClears.has(`seriesPlan.${key}`)) { + throw aiShapeError(`The model tried to clear seriesPlan.${key} without listing it in clears`); + } + next[key] = key === 'deliveryOptions' && !seriesPlanFieldValueIsClear(key, raw[key]) + ? { ...(currentPlan?.deliveryOptions || {}), ...raw[key] } + : raw[key]; } return next; }; +const outlineRelevantEpisodeFingerprint = (episode) => JSON.stringify({ + title: episode.title, + synopsis: episode.synopsis, + startNodeId: episode.startNodeId, + nodes: episode.nodes.map((node) => ({ + id: node.id, + title: node.title, + playbackMode: node.playbackMode, + audienceConnection: node.audienceConnection, + protagonistPresence: node.protagonistPresence, + isEnding: node.isEnding, + endingLabel: node.endingLabel, + transitions: asArray(node.transitions).map((transition) => ({ + targetNodeId: transition.targetNodeId, + intent: transition.intent, + })), + })), +}); + +const assertOutlineMatchesExpandedEpisode = (episode, outline, participationMode) => { + const sync = analyzeStoryOutlineTeleplaySync(episode, outline, { participationMode }); + if (sync.stats.matches) return; + throw aiShapeError(`The model returned a stale outline for episode ${episode.id}: ${sync.issues[0].message}`); +}; + const applyScenePatch = (episode, scene, rawScene) => { for (const key of allowedSceneFields) { if (!hasOwn(rawScene, key)) continue; @@ -335,17 +622,38 @@ const applyScenePatch = (episode, scene, rawScene) => { const transitionsById = new Map(asArray(scene.transitions).map((transition) => [transition.id, transition])); const nodeIds = new Set(episode.nodes.map((node) => node.id)); + if (hasOwn(rawScene, 'transitions') && !Array.isArray(rawScene.transitions)) { + throw aiShapeError(`The model returned invalid transitions for scene ${scene.id}`); + } + const returnedTransitionIds = new Set(); for (const rawTransition of asArray(rawScene.transitions)) { + if (!isPlainObject(rawTransition) || !hasText(rawTransition.id)) { + throw aiShapeError(`The model returned a transition without an existing id for scene ${scene.id}`); + } const transition = transitionsById.get(rawTransition?.id); - if (!transition) continue; + if (!transition) { + throw aiShapeError(`The model returned unknown transition id ${rawTransition.id} for scene ${scene.id}`); + } + if (returnedTransitionIds.has(transition.id)) { + throw aiShapeError(`The model returned duplicate transition id ${transition.id} for scene ${scene.id}`); + } + returnedTransitionIds.add(transition.id); for (const key of allowedTransitionFields) { if (!hasOwn(rawTransition, key)) continue; const value = rawTransition[key]; - if (key === 'targetNodeId' && typeof value === 'string' && nodeIds.has(value)) { + if (key === 'targetNodeId') { + if (typeof value !== 'string' || !nodeIds.has(value)) { + throw aiShapeError(`The model returned an invalid transition target for ${transition.id}`); + } transition.targetNodeId = value; } if (['intent', 'description'].includes(key) && typeof value === 'string') transition[key] = value; - if (key === 'triggers' && Array.isArray(value)) transition.triggers = value; + if (key === 'triggers') { + if (!Array.isArray(value) || value.some((trigger) => typeof trigger !== 'string')) { + throw aiShapeError(`The model returned invalid triggers for transition ${transition.id}`); + } + transition.triggers = value; + } } } }; @@ -370,58 +678,142 @@ const countGraphErrors = (loom) => loom.episodes.reduce((total, episode, index) }).stats.errorCount ), 0); +const graphErrorIdentities = (loom) => new Set(loom.episodes.flatMap((episode, index) => ( + analyzeEpisodeGraph(episode, { + participationMode: loom.participationMode, + requireAudienceIntroduction: index === 0, + }).issues + .filter((issue) => issue.severity === 'error') + .map((issue) => JSON.stringify([ + episode.id, + issue.code, + issue.nodeId || null, + issue.transitionId || null, + ])) +))); + +const outlineErrorIdentities = (loom) => new Set(analyzeSeriesStoryOutlines(loom).issues + .filter((issue) => issue.severity === 'error') + .map((issue) => JSON.stringify([ + issue.episodeId || null, + issue.code, + issue.sceneKey || null, + Number.isInteger(issue.transitionIndex) ? issue.transitionIndex : null, + ]))); + +const playthroughErrorIdentities = (report) => new Set(report.episodes.flatMap((episode) => ( + episode.issues + .filter((issue) => issue.severity === 'error') + .map((issue) => JSON.stringify([ + episode.episodeId, + issue.code, + issue.nodeId || null, + issue.transitionId || null, + ])) +))); + +const continuityBlockers = (loom, { universe = null, voiceProfiles = [] } = {}) => ( + loom.episodes.flatMap((episode) => analyzeEpisodeContinuity({ + loom, + episode, + universe, + localVoiceProfiles: voiceProfiles, + }).findings + .filter((finding) => finding.severity === 'error' + || finding.code === CONTINUITY_CODES.AMBIGUOUS_CONVERGENCE) + .map((finding) => ({ episodeId: episode.id, ...finding }))) +); + +const continuityBlockerIdentities = (findings) => new Set(findings.map((finding) => JSON.stringify([ + finding.episodeId, + finding.code, + finding.severity, + finding.nodeId || null, + finding.characterId || null, + finding.assetId || null, +]))); + +const hasIntroducedIdentity = (before, after) => [...after].some((identity) => !before.has(identity)); + /** * Apply a model response to an in-memory loom while preserving graph * membership. Exported for focused contract tests and the persistence wrapper. */ -export function applyFableLoomEditorialPatch(loom, content) { +export function applyFableLoomEditorialPatch( + loom, + content, + { universe = null, voiceProfiles = [] } = {}, +) { if (!content || typeof content !== 'object') throw aiShapeError('The model returned no editorial response'); + if (containsInstructionPlaceholder(content)) { + throw aiShapeError('The model copied an instructional placeholder instead of returning authored story data'); + } const candidate = structuredClone(loom); const beforeGraphErrors = countGraphErrors(candidate); const beforeOutlineErrors = analyzeSeriesStoryOutlines(candidate).stats.errorCount; + const beforePlaythrough = analyzeLoomPlaythroughs(candidate); + const beforeContinuityBlockers = continuityBlockers(candidate, { universe, voiceProfiles }); + const beforeGraphErrorIdentities = graphErrorIdentities(candidate); + const beforeOutlineErrorIdentities = outlineErrorIdentities(candidate); + const beforePlaythroughErrorIdentities = playthroughErrorIdentities(beforePlaythrough); + const beforeContinuityBlockerIdentities = continuityBlockerIdentities(beforeContinuityBlockers); + const explicitClears = new Set(asArray(content.clears).filter((value) => typeof value === 'string')); + if (hasOwn(content, 'episodes') && !Array.isArray(content.episodes)) { + throw aiShapeError('The model returned an invalid episode patch list'); + } if (content.seriesPlan && typeof content.seriesPlan === 'object') { - candidate.seriesPlan = applySeriesPlanPatch(candidate.seriesPlan, content.seriesPlan); + const episodeIds = new Set(candidate.episodes.map((episode) => episode.id)); + candidate.seriesPlan = applySeriesPlanPatch( + candidate.seriesPlan, + content.seriesPlan, + explicitClears, + episodeIds, + ); } const episodesById = new Map(candidate.episodes.map((episode) => [episode.id, episode])); const returnedEpisodeIds = new Set(); for (const rawEpisode of asArray(content.episodes)) { + if (!isPlainObject(rawEpisode) || !hasText(rawEpisode.id)) { + throw aiShapeError('The model returned an episode patch without an existing id'); + } const episode = episodesById.get(rawEpisode?.id); - if (!episode || returnedEpisodeIds.has(episode.id)) continue; + if (!episode) throw aiShapeError(`The model returned unknown episode id ${rawEpisode.id}`); + if (returnedEpisodeIds.has(episode.id)) { + throw aiShapeError(`The model returned duplicate episode id ${episode.id}`); + } returnedEpisodeIds.add(episode.id); + const beforeOutlineContract = outlineRelevantEpisodeFingerprint(episode); + const replacementOutline = hasOwn(rawEpisode, 'storyOutline') + ? rawEpisode.storyOutline + : undefined; for (const key of allowedEpisodeFields) { if (!hasOwn(rawEpisode, key)) continue; const value = rawEpisode[key]; if (['title', 'synopsis'].includes(key) && typeof value === 'string') episode[key] = value; - if (key === 'startNodeId' && typeof value === 'string' - && episode.nodes.some((node) => node.id === value)) episode.startNodeId = value; - } - if (hasOwn(rawEpisode, 'storyOutline')) { - const storyOutline = sanitizeStoryOutline(rawEpisode.storyOutline, { - participationMode: candidate.participationMode, - }); - if (!storyOutline) throw aiShapeError(`The model returned an unusable outline for episode ${episode.id}`); - const analysis = analyzeStoryOutline(storyOutline, { - participationMode: candidate.participationMode, - requireAudienceIntroduction: candidate.episodes[0]?.id === episode.id, - }); - if (analysis.stats.errorCount) { - throw aiShapeError(`The model returned an invalid outline for episode ${episode.id}: ${analysis.issues.find((issue) => issue.severity === 'error')?.message}`); + if (key === 'startNodeId') { + if (typeof value !== 'string' || !episode.nodes.some((node) => node.id === value)) { + throw aiShapeError(`The model returned an invalid opening scene for episode ${episode.id}`); + } + episode.startNodeId = value; } - episode.storyOutline = { - ...storyOutline, - validation: { - status: 'valid', - issues: analysis.issues, - validatedAt: new Date().toISOString(), - }, - }; } const scenesById = new Map(episode.nodes.map((scene) => [scene.id, scene])); const continuitySourcePatches = []; + if (hasOwn(rawEpisode, 'scenes') && !Array.isArray(rawEpisode.scenes)) { + throw aiShapeError(`The model returned an invalid scene patch list for episode ${episode.id}`); + } + const returnedSceneIds = new Set(); for (const rawScene of asArray(rawEpisode.scenes)) { + if (!isPlainObject(rawScene) || !hasText(rawScene.id)) { + throw aiShapeError(`The model returned a scene patch without an existing id for episode ${episode.id}`); + } const scene = scenesById.get(rawScene?.id); - if (!scene) continue; + if (!scene) throw aiShapeError(`The model returned unknown scene id ${rawScene.id}`); + if (returnedSceneIds.has(scene.id)) { + throw aiShapeError(`The model returned duplicate scene id ${scene.id}`); + } + returnedSceneIds.add(scene.id); applyScenePatch(episode, scene, rawScene); if (rawScene.visualCanon && typeof rawScene.visualCanon === 'object' && hasOwn(rawScene.visualCanon, 'continuitySourceNodeId')) { @@ -437,21 +829,82 @@ export function applyFableLoomEditorialPatch(loom, content) { for (const { scene, sourceId } of continuitySourcePatches) { applyContinuitySourcePatch(scene, sourceId, predecessorsByNodeId); } + const outlineContractChanged = beforeOutlineContract !== outlineRelevantEpisodeFingerprint(episode); + if (replacementOutline !== undefined) { + const storyOutline = sanitizeStoryOutline(replacementOutline, { + participationMode: candidate.participationMode, + }); + if (!storyOutline) throw aiShapeError(`The model returned an unusable outline for episode ${episode.id}`); + const analysis = analyzeStoryOutline(storyOutline, { + participationMode: candidate.participationMode, + requireAudienceIntroduction: candidate.episodes[0]?.id === episode.id, + }); + if (analysis.stats.errorCount) { + throw aiShapeError(`The model returned an invalid outline for episode ${episode.id}: ${analysis.issues.find((issue) => issue.severity === 'error')?.message}`); + } + assertOutlineMatchesExpandedEpisode(episode, storyOutline, candidate.participationMode); + episode.storyOutline = { + ...storyOutline, + validation: { + status: 'valid', + issues: analysis.issues, + validatedAt: new Date().toISOString(), + }, + }; + } else if (outlineContractChanged && episode.storyOutline?.validation?.status === 'valid') { + throw aiShapeError(`The model changed episode ${episode.id}'s outline contract without returning a synchronized storyOutline`); + } else if (outlineContractChanged && episode.storyOutline) { + episode.storyOutline.validation = { status: 'draft', issues: [] }; + } + } + + for (const episode of candidate.episodes) { + if (!episode.nodes.length || episode.storyOutline?.validation?.status !== 'valid') continue; + const sync = analyzeStoryOutlineTeleplaySync(episode, episode.storyOutline, { + participationMode: candidate.participationMode, + }); + if (!sync.stats.matches) { + throw aiShapeError(`Episode ${episode.id} has a stale validated outline; return a synchronized storyOutline before applying other edits`); + } } const sanitized = sanitizeLoom(candidate); if (!sanitized) throw aiShapeError('The editorial response produced an invalid loom'); const afterGraphErrors = countGraphErrors(sanitized); const afterOutlineErrors = analyzeSeriesStoryOutlines(sanitized).stats.errorCount; - if (afterGraphErrors > beforeGraphErrors) { + const afterPlaythrough = analyzeLoomPlaythroughs(sanitized); + const afterContinuityBlockers = continuityBlockers(sanitized, { universe, voiceProfiles }); + const afterGraphErrorIdentities = graphErrorIdentities(sanitized); + const afterOutlineErrorIdentities = outlineErrorIdentities(sanitized); + const afterPlaythroughErrorIdentities = playthroughErrorIdentities(afterPlaythrough); + const afterContinuityBlockerIdentities = continuityBlockerIdentities(afterContinuityBlockers); + if (afterGraphErrors > beforeGraphErrors + || hasIntroducedIdentity(beforeGraphErrorIdentities, afterGraphErrorIdentities)) { throw aiShapeError('The editorial response introduced new episode graph errors'); } - if (afterOutlineErrors > beforeOutlineErrors) { + if (afterOutlineErrors > beforeOutlineErrors + || hasIntroducedIdentity(beforeOutlineErrorIdentities, afterOutlineErrorIdentities)) { throw aiShapeError('The editorial response introduced new series-outline errors'); } + if ((beforePlaythrough.passed && !afterPlaythrough.passed) + || afterPlaythrough.stats.errorCount > beforePlaythrough.stats.errorCount + || afterPlaythrough.stats.nonEndingVariationCount > beforePlaythrough.stats.nonEndingVariationCount + || hasIntroducedIdentity(beforePlaythroughErrorIdentities, afterPlaythroughErrorIdentities)) { + throw aiShapeError('The editorial response introduced new playthrough failures'); + } + if (afterContinuityBlockers.length > beforeContinuityBlockers.length + || hasIntroducedIdentity( + beforeContinuityBlockerIdentities, + afterContinuityBlockerIdentities, + )) { + throw aiShapeError('The editorial response introduced new continuity blockers'); + } const before = editorialFingerprint(loom); const after = editorialFingerprint(sanitized); + if (before === after && analysisStrings(content?.changes).length) { + throw aiShapeError('The model claimed editorial changes but returned no applicable patch'); + } return { loom: sanitized, changed: before !== after, @@ -466,46 +919,81 @@ export async function evaluateAndRemediateFableLoom(loomId, { } = {}) { const loom = await requireLoom(loomId); const fingerprint = editorialFingerprint(loom); - const diagnostics = await collectFableLoomEditorialDiagnostics(loom); - const canonDigest = await buildCanonDigest(loom); - const { content, runId } = await runEditorialAi('fableloom-editorial-remediate', { - storyContext: storyContext(loom), - canonDigest: canonDigest || '(none)', - seriesPlanJson: seriesPlanDigest(loom), - teleplayDigest: teleplayDigest(loom), - playthroughDigest: describeLoomPlaythroughsForPrompt(loom, diagnostics.playthrough), - deterministicDigest: diagnosticLines(diagnostics), - guidance: trimTo(guidance, 4000) || '(none)', - }, { providerId, model, effort, operationId }, { + const dependencies = await loadEditorialDependencies(loom); + const dependencyFingerprint = editorialDependencyFingerprint(dependencies); + const diagnostics = await collectFableLoomEditorialDiagnostics(loom, dependencies); + const stage = 'fableloom-editorial-remediate'; + const maxPromptChars = await resolveEditorialPromptBudgetChars( + stage, + { providerId, model, effort }, + 'fableloom-editorial-remediate', + ); + const variables = withCompletePlaythroughDigest({ + loom, + report: diagnostics.playthrough, + maxPromptChars, + variables: { + storyContext: storyContext(loom), + canonDigest: dependencies.canonDigest || '(none)', + seriesPlanJson: seriesPlanDigest(loom), + teleplayDigest: teleplayDigest(loom), + deterministicDigest: diagnosticLines(diagnostics), + guidance: trimTo(guidance, 4000) || '(none)', + }, + }); + const prompt = await renderEditorialPrompt(stage, variables, maxPromptChars); + const { content, runId, status } = await runEditorialAi(stage, prompt, { + providerId, model, effort, operationId, + }, { action: 'editorial-remediate', label: 'Evaluating and remediating the FableLoom series', source: 'fableloom-editorial-remediate', }); - const evaluation = sanitizeEvaluation(content, loom); - if (!evaluation.summary && !evaluation.strengths.length && !evaluation.findings.length) { - throw aiShapeError('The model returned no usable editorial evaluation'); - } - const applied = applyFableLoomEditorialPatch(loom, content); - const updated = applied.changed ? await mutateLoom(loomId, (current) => { - if (editorialFingerprint(current) !== fingerprint) { - throw new ServerError('The story changed while the editorial pass was running', { - status: 409, + return finalizeEditorialOperation(status, runId, async () => { + const evaluation = sanitizeEvaluation(content, loom); + if (!evaluation.summary && !evaluation.strengths.length && !evaluation.findings.length) { + throw aiShapeError('The model returned no usable editorial evaluation'); + } + const applied = applyFableLoomEditorialPatch(loom, content, dependencies); + let verifiedDependencies; + const updated = applied.changed ? await mutateLoom(loomId, async (current) => { + assertEditorialSnapshotUnchanged(current, fingerprint, { code: 'LOOM_CHANGED_DURING_GENERATION', + message: 'The story changed while the editorial pass was running', + }); + verifiedDependencies = await loadEditorialDependencies(current); + assertEditorialDependenciesUnchanged(verifiedDependencies, dependencyFingerprint, { + code: 'LOOM_DEPENDENCIES_CHANGED_DURING_GENERATION', + message: 'Linked canon or voice profiles changed while the editorial pass was running', + }); + return applied.loom; + }) : await requireLoom(loomId); + if (!applied.changed) { + assertEditorialSnapshotUnchanged(updated, fingerprint, { + code: 'LOOM_CHANGED_DURING_GENERATION', + message: 'The story changed while the editorial pass was running', + }); + verifiedDependencies = await loadEditorialDependencies(updated); + assertEditorialDependenciesUnchanged(verifiedDependencies, dependencyFingerprint, { + code: 'LOOM_DEPENDENCIES_CHANGED_DURING_GENERATION', + message: 'Linked canon or voice profiles changed while the editorial pass was running', }); } - return applied.loom; - }) : loom; - const afterDiagnostics = await collectFableLoomEditorialDiagnostics(updated); - return { - loom: updated, - changed: applied.changed, - changes: analysisStrings(content?.changes), - evaluation, - before: diagnostics.stats, - after: afterDiagnostics.stats, - diagnostics: afterDiagnostics, - runId, - }; + const afterDiagnostics = await collectFableLoomEditorialDiagnostics( + updated, + verifiedDependencies || dependencies, + ); + return { + loom: updated, + changed: applied.changed, + changes: analysisStrings(content?.changes), + evaluation, + before: diagnostics.stats, + after: afterDiagnostics.stats, + diagnostics: afterDiagnostics, + runId, + }; + }); } const sanitizePlaythroughReview = (content, loom, deterministic) => { @@ -554,40 +1042,84 @@ export async function reviewFableLoomPlaythroughs(loomId, { aiReview = true, maxPaths, providerId, model, effort, operationId, } = {}) { const loom = await requireLoom(loomId); + const fingerprint = editorialFingerprint(loom); const deterministic = analyzeLoomPlaythroughs(loom, { maxPaths }); if (!aiReview) return { passed: deterministic.passed, deterministic, review: null, runId: null }; - const canonDigest = await buildCanonDigest(loom); - const diagnostics = await collectFableLoomEditorialDiagnostics(loom); - const { content, runId } = await runEditorialAi('fableloom-review-playthroughs', { - storyContext: storyContext(loom), - canonDigest: canonDigest || '(none)', - seriesPlanJson: seriesPlanDigest(loom), - teleplayDigest: teleplayDigest(loom), - playthroughDigest: describeLoomPlaythroughsForPrompt(loom, deterministic), - deterministicDigest: diagnosticLines(diagnostics), - }, { providerId, model, effort, operationId }, { + const dependencies = await loadEditorialDependencies(loom); + const dependencyFingerprint = editorialDependencyFingerprint(dependencies); + const diagnostics = await collectFableLoomEditorialDiagnostics( + loom, + dependencies, + { playthroughReport: deterministic }, + ); + const stage = 'fableloom-review-playthroughs'; + const maxPromptChars = await resolveEditorialPromptBudgetChars( + stage, + { providerId, model, effort }, + 'fableloom-review-playthroughs', + ); + const variables = withCompletePlaythroughDigest({ + loom, + report: deterministic, + maxPromptChars, + variables: { + storyContext: storyContext(loom), + canonDigest: dependencies.canonDigest || '(none)', + seriesPlanJson: seriesPlanDigest(loom), + teleplayDigest: teleplayDigest(loom), + deterministicDigest: diagnosticLines(diagnostics), + }, + }); + const prompt = await renderEditorialPrompt(stage, variables, maxPromptChars); + const { content, runId, status } = await runEditorialAi(stage, prompt, { + providerId, model, effort, operationId, + }, { action: 'review-playthroughs', label: 'Reviewing FableLoom playthrough variations', source: 'fableloom-review-playthroughs', }); - const review = sanitizePlaythroughReview(content, loom, deterministic); - const hasHighFinding = review.findings.some((finding) => finding.severity === 'high'); - return { - passed: diagnostics.passed - && deterministic.passed - && review.passed - && !hasHighFinding - && review.qualityScore >= AUTOPILOT_QUALITY_THRESHOLD, - deterministic, - review, - runId, - qualityThreshold: AUTOPILOT_QUALITY_THRESHOLD, - }; + return finalizeEditorialOperation(status, runId, async () => { + const current = await requireLoom(loomId); + assertEditorialSnapshotUnchanged(current, fingerprint, { + code: 'LOOM_CHANGED_DURING_REVIEW', + message: 'The story changed while the playthrough review was running', + }); + const currentDependencies = await loadEditorialDependencies(current); + assertEditorialDependenciesUnchanged(currentDependencies, dependencyFingerprint, { + code: 'LOOM_DEPENDENCIES_CHANGED_DURING_REVIEW', + message: 'Linked canon or voice profiles changed while the playthrough review was running', + }); + const review = sanitizePlaythroughReview(content, loom, deterministic); + const hasHighFinding = review.findings.some((finding) => finding.severity === 'high'); + return { + passed: diagnostics.passed + && deterministic.passed + && review.passed + && !hasHighFinding + && review.qualityScore >= AUTOPILOT_QUALITY_THRESHOLD, + deterministic, + diagnostics: compactEditorialDiagnostics(diagnostics), + review, + runId, + qualityThreshold: AUTOPILOT_QUALITY_THRESHOLD, + }; + }); } export const __testing = { + assertEditorialDependenciesUnchanged, + assertEditorialPromptBudget, + assertEditorialSnapshotUnchanged, + compactEditorialDiagnostics, diagnosticLines, + editorialDependencyFingerprint, + editorialPromptBudgetChars, + editorialPromptCharacterCount, editorialFingerprint, + finalizeEditorialOperation, + loadEditorialDependencies, + renderEditorialPrompt, sanitizeEvaluation, sanitizePlaythroughReview, + withCompletePlaythroughDigest, }; diff --git a/server/services/fableLoom/editorial.test.js b/server/services/fableLoom/editorial.test.js index 27cf84f78..6a603a02c 100644 --- a/server/services/fableLoom/editorial.test.js +++ b/server/services/fableLoom/editorial.test.js @@ -1,6 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { applyFableLoomEditorialPatch } from './editorial.js'; +import { analyzeLoomPlaythroughs } from '../../lib/fableLoomPlaytest.js'; +import { + applyFableLoomEditorialPatch, + collectFableLoomEditorialDiagnostics, + __testing, +} from './editorial.js'; import { sanitizeLoom } from './records.js'; const transition = (id, targetNodeId, intent) => ({ id, targetNodeId, intent, triggers: [] }); @@ -44,6 +49,77 @@ const makeLoom = () => sanitizeLoom({ }], }); +const makeHighVariationLoom = () => { + const depth = 7; + const nodes = []; + for (let level = 0; level < depth; level += 1) { + const decisionId = `decision-${level + 1}`; + const nextDecisionId = `decision-${level + 2}`; + const leftId = level === depth - 1 ? 'ending-left' : `branch-${level + 1}-left`; + const rightId = level === depth - 1 ? 'ending-right' : `branch-${level + 1}-right`; + nodes.push({ + id: decisionId, + title: `Decision ${level + 1}`, + prose: `The traveler weighs signal fork ${level + 1}.`, + playbackMode: 'decision', + audienceConnection: 'connected', + isEnding: false, + transitions: [ + transition(`decision-${level + 1}-left`, leftId, 'Follow the left signal'), + transition(`decision-${level + 1}-right`, rightId, 'Follow the right signal'), + ], + }); + if (level < depth - 1) { + nodes.push( + { + id: leftId, + title: `Left Passage ${level + 1}`, + prose: 'The left passage reveals one piece of the signal.', + playbackMode: 'cut', + audienceConnection: 'disconnected', + isEnding: false, + transitions: [transition(`${leftId}-next`, nextDecisionId, 'Continue toward the source')], + }, + { + id: rightId, + title: `Right Passage ${level + 1}`, + prose: 'The right passage reveals another piece of the signal.', + playbackMode: 'cut', + audienceConnection: 'disconnected', + isEnding: false, + transitions: [transition(`${rightId}-next`, nextDecisionId, 'Continue toward the source')], + }, + ); + } + } + nodes.push( + { + id: 'ending-left', title: 'Left Answer', prose: 'The left answer resolves the signal.', + playbackMode: 'decision', audienceConnection: 'connected', isEnding: true, + endingLabel: 'Left answer', transitions: [], + }, + { + id: 'ending-right', title: 'Right Answer', prose: 'The right answer resolves the signal.', + playbackMode: 'decision', audienceConnection: 'connected', isEnding: true, + endingLabel: 'Right answer', transitions: [], + }, + ); + return sanitizeLoom({ + id: 'loom-many-variations', + name: 'Example Many-Path Story', + participationMode: 'protagonist', + seriesPlan: { storyArc: 'A traveler follows a layered signal.', plotPoints: [], sideQuests: [] }, + episodes: [{ + id: 'episode-many-variations', + number: 1, + title: 'The Layered Signal', + synopsis: 'Seven choices reveal one source.', + startNodeId: 'decision-1', + nodes, + }], + }); +}; + const completeOutline = () => ({ startKey: 'opening', scenes: [ @@ -106,6 +182,7 @@ describe('applyFableLoomEditorialPatch', () => { id: 'episode-example', scenes: [ { id: 'left', transitions: [{ id: 'left-end', targetNodeId: 'right' }] }, + { id: 'right', visualCanon: { continuitySourceNodeId: 'left' } }, { id: 'ending', visualCanon: { continuitySourceNodeId: 'right' } }, ], }], @@ -115,6 +192,8 @@ describe('applyFableLoomEditorialPatch', () => { .transitions[0].targetNodeId).toBe('right'); expect(result.loom.episodes[0].nodes.find((node) => node.id === 'ending') .visualCanon.continuitySourceNodeId).toBe('right'); + expect(result.loom.episodes[0].nodes.find((node) => node.id === 'right') + .visualCanon.continuitySourceNodeId).toBe('left'); }); it('rejects a continuity source that is not a direct incoming predecessor', () => { @@ -134,4 +213,445 @@ describe('applyFableLoomEditorialPatch', () => { }], })).toThrow(/introduced new episode graph errors/i); }); + + it('rejects a new graph error even when the patch fixes a different error', () => { + const loom = makeLoom(); + loom.episodes[0].nodes[0].playbackMode = 'cut'; + + expect(() => applyFableLoomEditorialPatch(loom, { + episodes: [{ + id: 'episode-example', + scenes: [{ + id: 'opening', + playbackMode: 'decision', + transitions: [{ id: 'take-left', intent: '' }], + }], + }], + })).toThrow(/introduced new episode graph errors/i); + }); + + it('rejects a transition rewire that introduces a non-terminating playthrough', () => { + expect(() => applyFableLoomEditorialPatch(makeLoom(), { + episodes: [{ + id: 'episode-example', + scenes: [{ + id: 'left', + transitions: [{ id: 'left-end', targetNodeId: 'left' }], + }], + }], + })).toThrow(/introduced new playthrough failures/i); + }); + + it('rejects a patch that breaks an additional route with an existing cycle identity', () => { + const loom = sanitizeLoom({ + id: 'loom-existing-cycle', + name: 'Example Cycle Story', + participationMode: 'protagonist', + episodes: [{ + id: 'episode-existing-cycle', + number: 1, + title: 'The Repeating Signal', + startNodeId: 'opening', + nodes: [ + { + id: 'opening', title: 'Four Routes', playbackMode: 'decision', + audienceConnection: 'connected', protagonistPresence: 'onscreen', + transitions: [ + transition('already-cycles', 'cycle-c', 'Enter the loop'), + transition('ending-route-one', 'ending-one', 'Take the first answer'), + transition('ending-route-two', 'ending-one', 'Take the second answer'), + transition('ending-route-three', 'ending-two', 'Take the third answer'), + ], + }, + { + id: 'cycle-c', title: 'Cycle C', playbackMode: 'cut', + audienceConnection: 'disconnected', protagonistPresence: 'onscreen', + transitions: [transition('cycle-c-to-d', 'cycle-d', 'Continue')], + }, + { + id: 'cycle-d', title: 'Cycle D', playbackMode: 'cut', + audienceConnection: 'disconnected', protagonistPresence: 'onscreen', + transitions: [transition('cycle-d-to-c', 'cycle-c', 'Repeat')], + }, + { + id: 'ending-one', title: 'First Answer', playbackMode: 'decision', + audienceConnection: 'connected', protagonistPresence: 'onscreen', + isEnding: true, endingLabel: 'First answer', transitions: [], + }, + { + id: 'ending-two', title: 'Second Answer', playbackMode: 'decision', + audienceConnection: 'connected', protagonistPresence: 'onscreen', + isEnding: true, endingLabel: 'Second answer', transitions: [], + }, + ], + }], + }); + expect(analyzeLoomPlaythroughs(loom).stats.nonEndingVariationCount).toBe(1); + + expect(() => applyFableLoomEditorialPatch(loom, { + episodes: [{ + id: 'episode-existing-cycle', + scenes: [{ + id: 'opening', + transitions: [{ id: 'ending-route-one', targetNodeId: 'cycle-c' }], + }], + }], + })).toThrow(/introduced new playthrough failures/i); + }); + + it('rejects a patch that introduces a blocking continuity defect', () => { + const loom = makeLoom(); + const scene = loom.episodes[0].nodes.find((node) => node.id === 'left'); + scene.playbackMode = 'decision'; + scene.protagonistPresence = 'offscreen'; + scene.interactionWindow = { enabled: true }; + + expect(() => applyFableLoomEditorialPatch(loom, { + episodes: [{ + id: 'episode-example', + scenes: [{ id: 'left', playbackMode: 'cut' }], + }], + })).toThrow(/introduced new continuity blockers/i); + }); + + it('requires a replacement outline to cover the expanded teleplay exactly', () => { + const subsetOutline = { + version: 1, + startKey: 'opening', + scenes: [ + { + key: 'opening', title: 'Opening', summary: 'The route begins.', playbackMode: 'cut', + audienceConnection: 'connected', protagonistPresence: 'onscreen', isEnding: false, + transitions: [{ targetKey: 'ending', intent: 'Continue' }], + }, + { + key: 'ending', title: 'Ending', summary: 'The route resolves.', playbackMode: 'decision', + audienceConnection: 'connected', protagonistPresence: 'onscreen', isEnding: true, + endingLabel: 'Signal found', transitions: [], + }, + ], + }; + + expect(() => applyFableLoomEditorialPatch(makeLoom(), { + episodes: [{ id: 'episode-example', storyOutline: subsetOutline }], + })).toThrow(/cover every expanded teleplay scene exactly once/i); + }); + + it('rejects outline-relevant edits without a synchronized validated outline', () => { + const loom = makeLoom(); + loom.episodes[0].storyOutline = { + ...completeOutline(), + validation: { status: 'valid', issues: [] }, + }; + + expect(() => applyFableLoomEditorialPatch(loom, { + episodes: [{ id: 'episode-example', synopsis: 'A different dramatic contract.' }], + })).toThrow(/without returning a synchronized storyOutline/i); + + expect(() => applyFableLoomEditorialPatch(loom, { + episodes: [{ + id: 'episode-example', + scenes: [{ id: 'opening', transitions: [{ id: 'take-left', intent: 'Flee left' }] }], + }], + })).toThrow(/without returning a synchronized storyOutline/i); + }); + + it('requires explicit intent before clearing populated series-plan collections', () => { + const loom = makeLoom(); + loom.seriesPlan.sideQuests = [{ + id: 'quest-example', title: 'Recover the map', description: 'Find the missing map.', + status: 'planned', startEpisodeId: 'episode-example', endEpisodeId: 'episode-example', + }]; + + expect(() => applyFableLoomEditorialPatch(loom, { + seriesPlan: { sideQuests: [] }, + })).toThrow(/without listing it in clears/i); + + expect(applyFableLoomEditorialPatch(loom, { + clears: ['seriesPlan.sideQuests'], + seriesPlan: { sideQuests: [] }, + }).loom.seriesPlan.sideQuests).toEqual([]); + }); + + it('rejects malformed series-plan field types before sanitization can erase data', () => { + const loom = makeLoom(); + loom.seriesPlan = { + ...loom.seriesPlan, + plotPoints: [{ id: 'plot-example', title: 'Signal', description: 'Follow it.' }], + sideQuests: [{ + id: 'quest-example', title: 'Map', description: 'Recover it.', status: 'planned', + startEpisodeId: 'episode-example', endEpisodeId: 'episode-example', + }], + deliveryOptions: { nextSeasonTeaser: false }, + interEpisodeVoicemails: [{ + id: 'message-example', fromEpisodeId: 'episode-example', toEpisodeId: 'episode-example', + title: 'Message', transcript: 'Return.', + }], + nextSeasonTeaser: { title: 'Beyond', transcript: 'The signal answers.' }, + }; + + for (const seriesPlan of [ + { plotPoints: null }, + { sideQuests: {} }, + { deliveryOptions: [] }, + { interEpisodeVoicemails: 'none' }, + { nextSeasonTeaser: [] }, + { plotPoints: [null] }, + { plotPoints: [{ id: null, title: 'Signal', description: 'Follow it.' }] }, + { sideQuests: [{ title: 'Missing fields' }] }, + { deliveryOptions: { ignoredBySanitizer: true } }, + { interEpisodeVoicemails: [{}] }, + { nextSeasonTeaser: {} }, + ]) { + expect(() => applyFableLoomEditorialPatch(loom, { seriesPlan })) + .toThrow(/invalid value for seriesPlan/i); + } + expect(() => applyFableLoomEditorialPatch(loom, { + seriesPlan: { nextSeasonTeaser: null }, + })).toThrow(/without listing it in clears/i); + expect(applyFableLoomEditorialPatch(loom, { + clears: ['seriesPlan.nextSeasonTeaser'], + seriesPlan: { nextSeasonTeaser: null }, + }).loom.seriesPlan.nextSeasonTeaser).toBeNull(); + expect(() => applyFableLoomEditorialPatch(loom, { + seriesPlan: { nextSeasonTeaser: { title: '', transcript: '' } }, + })).toThrow(/without listing it in clears/i); + expect(applyFableLoomEditorialPatch(loom, { + clears: ['seriesPlan.nextSeasonTeaser'], + seriesPlan: { nextSeasonTeaser: { title: '', transcript: '' } }, + }).loom.seriesPlan.nextSeasonTeaser).toEqual({ title: '', transcript: '' }); + }); + + it('rejects invented episode references instead of silently unlinking plan items', () => { + const invalidRef = 'episode-not-in-this-loom'; + for (const seriesPlan of [ + { plotPoints: [{ title: 'Signal', description: 'Follow it.', episodeId: invalidRef }] }, + { + sideQuests: [{ + title: 'Map', description: 'Recover it.', status: 'planned', + startEpisodeId: invalidRef, endEpisodeId: null, + }], + }, + { + interEpisodeVoicemails: [{ + fromEpisodeId: 'episode-example', toEpisodeId: invalidRef, + title: 'Message', transcript: 'Return.', + }], + }, + ]) { + expect(() => applyFableLoomEditorialPatch(makeLoom(), { seriesPlan })) + .toThrow(/invalid value for seriesPlan/i); + } + }); + + it('merges sparse delivery-option edits without clearing an omitted flag', () => { + const loom = makeLoom(); + loom.seriesPlan.deliveryOptions = { + overnightVoicemails: true, + nextSeasonTeaser: false, + }; + + const updated = applyFableLoomEditorialPatch(loom, { + seriesPlan: { + deliveryOptions: { nextSeasonTeaser: true }, + nextSeasonTeaser: { title: 'Beyond', transcript: 'The signal answers.' }, + }, + }).loom; + + expect(updated.seriesPlan.deliveryOptions).toEqual({ + overnightVoicemails: true, + nextSeasonTeaser: true, + }); + }); + + it('rejects any edit while a claimed-valid expanded outline is already stale', () => { + const loom = makeLoom(); + loom.episodes[0].storyOutline = { + ...completeOutline(), + scenes: completeOutline().scenes.slice(0, 2), + validation: { status: 'valid', issues: [] }, + }; + + expect(() => applyFableLoomEditorialPatch(loom, { + seriesPlan: { storyArc: 'A revised arc.' }, + })).toThrow(/stale validated outline/i); + }); + + it('rejects instructional exemplar text copied into story fields', () => { + expect(() => applyFableLoomEditorialPatch(makeLoom(), { + seriesPlan: { storyArc: 'complete replacement only when changed' }, + })).toThrow(/instructional placeholder/i); + expect(() => applyFableLoomEditorialPatch(makeLoom(), { + summary: 'concise whole-series editorial assessment', + strengths: ['specific strength worth preserving'], + findings: [], + changes: ["Sharpened one scene's sensory detail without changing its beat."], + episodes: [{ + id: 'EPISODE_ID_FROM_INPUT', + scenes: [{ + id: 'SCENE_ID_FROM_INPUT', + prose: 'The signal shivers through the flooded tunnel walls.', + }], + }], + })).toThrow(/instructional placeholder/i); + }); + + it('rejects unknown or duplicate graph ids instead of silently ignoring patches', () => { + for (const content of [ + { episodes: [{ id: 'episode-unknown' }] }, + { episodes: [{ id: 'episode-example' }, { id: 'episode-example' }] }, + { episodes: [{ id: 'episode-example', scenes: [{ id: 'scene-unknown' }] }] }, + { + episodes: [{ + id: 'episode-example', + scenes: [{ id: 'opening' }, { id: 'opening' }], + }], + }, + { + episodes: [{ + id: 'episode-example', + scenes: [{ id: 'opening', transitions: [{ id: 'transition-unknown' }] }], + }], + }, + { + episodes: [{ + id: 'episode-example', + scenes: [{ + id: 'opening', + transitions: [{ id: 'take-left' }, { id: 'take-left' }], + }], + }], + }, + ]) { + expect(() => applyFableLoomEditorialPatch(makeLoom(), content)) + .toThrow(/unknown|duplicate/i); + } + }); + + it('rejects malformed transition triggers instead of sanitizing authored phrases away', () => { + const loom = makeLoom(); + const existing = loom.episodes[0].nodes.find((node) => node.id === 'left').transitions[0]; + existing.triggers = ['go', 'continue']; + + expect(() => applyFableLoomEditorialPatch(loom, { + episodes: [{ + id: 'episode-example', + scenes: [{ + id: 'left', + transitions: [{ id: 'left-end', triggers: [null, 42, {}] }], + }], + }], + })).toThrow(/invalid triggers/i); + expect(existing.triggers).toEqual(['go', 'continue']); + }); + + it('rejects a claimed remediation that contains no applicable change', () => { + expect(() => applyFableLoomEditorialPatch(makeLoom(), { + summary: 'The series is already coherent.', + changes: ['Adjusted the opening.'], + })).toThrow(/claimed editorial changes/i); + }); +}); + +describe('editorial generation guards', () => { + it('uses the requested complete playthrough report throughout diagnostics', async () => { + const loom = makeHighVariationLoom(); + const deterministic = analyzeLoomPlaythroughs(loom, { maxPaths: 128 }); + + expect(deterministic.complete).toBe(true); + expect(deterministic.stats.variationCount).toBe(128); + const diagnostics = await collectFableLoomEditorialDiagnostics( + loom, + { universe: null, voiceProfiles: [], canonDigest: '' }, + { playthroughReport: deterministic }, + ); + + expect(diagnostics.playthrough).toBe(deterministic); + expect(diagnostics.stats.variationCount).toBe(128); + expect(diagnostics.playthrough.complete).toBe(true); + }); + + it('fingerprints every semantic persisted field and ignores timestamps only', () => { + const loom = makeLoom(); + const fingerprint = __testing.editorialFingerprint(loom); + + for (const patch of [ + { format: 'teleplay' }, + { playSettings: { providerId: 'writer' } }, + { universeId: 'universe-example' }, + { seriesId: 'series-example' }, + ]) { + expect(__testing.editorialFingerprint({ ...loom, ...patch })).not.toBe(fingerprint); + } + expect(__testing.editorialFingerprint({ ...loom, updatedAt: 'later' })).toBe(fingerprint); + }); + + it('rejects stale review snapshots and oversized single-editor prompts', () => { + const loom = makeLoom(); + expect(() => __testing.assertEditorialSnapshotUnchanged( + { ...loom, format: 'teleplay' }, + __testing.editorialFingerprint(loom), + { code: 'LOOM_CHANGED_DURING_REVIEW', message: 'Story changed' }, + )).toThrow(/story changed/i); + + expect(() => __testing.assertEditorialPromptBudget({ + teleplayDigest: 'x'.repeat(240_001), + }, 240_000)).toThrow(/selected model's .* single-editor limit/i); + expect(__testing.editorialPromptBudgetChars(128_000)).toBeGreaterThan(0); + expect(__testing.editorialPromptBudgetChars(128_000)).toBeLessThan(1_000_000); + }); + + it('budgets the fully rendered customized stage prompt before any provider run', async () => { + const buildPromptFn = vi.fn(async () => `Custom instructions\n${'x'.repeat(240_000)}`); + + await expect(__testing.renderEditorialPrompt( + 'fableloom-review-playthroughs', + { playthroughDigest: 'small' }, + 200_000, + { buildPromptFn }, + )).rejects.toMatchObject({ + status: 413, + code: 'FABLELOOM_EDITORIAL_CONTEXT_TOO_LARGE', + }); + expect(buildPromptFn).toHaveBeenCalledTimes(1); + }); + + it('fails dependency reads closed and fingerprints canon and voice changes', async () => { + const loom = { ...makeLoom(), universeId: 'universe-example' }; + await expect(__testing.loadEditorialDependencies(loom, { + getUniverseFn: async () => { throw new Error('canon unavailable'); }, + listVoiceProfilesFn: async () => [], + })).rejects.toThrow(/canon unavailable/i); + await expect(__testing.loadEditorialDependencies(loom, { + getUniverseFn: async () => ({ characters: [], places: [], objects: [] }), + listVoiceProfilesFn: async () => { throw new Error('voices unavailable'); }, + })).rejects.toThrow(/voices unavailable/i); + + const original = { + universe: { characters: [], places: [], objects: [] }, + voiceProfiles: [], + canonDigest: 'Original canon', + }; + expect(() => __testing.assertEditorialDependenciesUnchanged( + { ...original, canonDigest: 'Changed canon' }, + __testing.editorialDependencyFingerprint(original), + { code: 'STALE', message: 'Dependencies changed' }, + )).toThrow(/dependencies changed/i); + }); + + it('emits success only after validation and reports post-provider failures', async () => { + const status = { complete: vi.fn(), error: vi.fn() }; + await expect(__testing.finalizeEditorialOperation(status, 'run-example', async () => { + throw new Error('Story changed'); + })).rejects.toThrow(/story changed/i); + expect(status.complete).not.toHaveBeenCalled(); + expect(status.error).toHaveBeenCalledWith('Story changed', { runId: 'run-example' }); + + await expect(__testing.finalizeEditorialOperation(status, 'run-example', async () => 'done')) + .resolves.toBe('done'); + expect(status.complete).toHaveBeenCalledWith( + 'Editorial operation complete', + { runId: 'run-example', shellReady: false }, + ); + }); }); diff --git a/server/services/fableLoom/editorialAutopilot.js b/server/services/fableLoom/editorialAutopilot.js index b18177333..4e99d7b35 100644 --- a/server/services/fableLoom/editorialAutopilot.js +++ b/server/services/fableLoom/editorialAutopilot.js @@ -63,6 +63,7 @@ const compactDeterministic = (deterministic) => ({ }); const residualFindings = (playtest) => [ + ...(playtest.diagnostics?.findings || []), ...playtest.deterministic.episodes.flatMap((episode) => episode.issues.map((issue) => ({ severity: issue.severity === 'error' ? 'high' : 'medium', category: 'structure', @@ -149,6 +150,7 @@ async function runRound(run, guidance) { before: remediation.before, after: remediation.after, evaluation: remediation.evaluation, + diagnostics: playtest.diagnostics, deterministic: compactDeterministic(playtest.deterministic), review: playtest.review, passed: playtest.passed, diff --git a/server/services/fableLoom/editorialAutopilot.test.js b/server/services/fableLoom/editorialAutopilot.test.js index 811afcf2d..92fe7ae3c 100644 --- a/server/services/fableLoom/editorialAutopilot.test.js +++ b/server/services/fableLoom/editorialAutopilot.test.js @@ -26,7 +26,7 @@ const remediation = (changed = true) => ({ evaluation: { summary: 'Focused editorial pass.', strengths: [], findings: [] }, }); -const playtest = ({ passed, findings = [] }) => ({ +const playtest = ({ passed, findings = [], diagnosticFindings = [] }) => ({ passed, deterministic: { passed: true, @@ -34,6 +34,11 @@ const playtest = ({ passed, findings = [] }) => ({ stats: { variationCount: 2, visitedTransitionCount: 4, transitionCount: 4 }, episodes: [{ episodeId: 'episode-example', issues: [] }], }, + diagnostics: { + passed: diagnosticFindings.length === 0, + stats: { outlineErrors: diagnosticFindings.length }, + findings: diagnosticFindings, + }, review: { passed, qualityScore: passed ? 8.5 : 7.2, @@ -111,6 +116,33 @@ describe('FableLoom editorial autopilot', () => { expect(finished).toMatchObject({ status: 'paused', pauseReason: 'round-limit', round: 1 }); }); + it('keeps diagnostics-only blockers actionable in the residual findings', async () => { + const diagnosticFinding = { + severity: 'high', + category: 'structure', + episodeId: 'episode-example', + nodeId: null, + pathId: null, + problem: 'The beat outline must be revalidated.', + suggestion: 'Repair and revalidate the complete episode beat outline.', + }; + remediateMock.mockResolvedValueOnce(remediation(false)); + playtestMock.mockResolvedValueOnce(playtest({ + passed: false, + diagnosticFindings: [diagnosticFinding], + })); + + const started = await startFableLoomEditorialAutopilot('loom-example', { maxRounds: 1 }); + const finished = await waitForTerminal(started.id); + + expect(finished).toMatchObject({ + status: 'paused', + pauseReason: 'round-limit', + residualFindings: [expect.objectContaining({ problem: diagnosticFinding.problem })], + }); + expect(finished.rounds[0].diagnostics.findings).toHaveLength(1); + }); + it('reattaches duplicate starts and cooperatively cancels after the active AI step', async () => { let finishRemediation; remediateMock.mockImplementationOnce(() => new Promise((resolve) => { finishRemediation = resolve; })); diff --git a/server/services/fableLoom/records.js b/server/services/fableLoom/records.js index 3b27452fb..ec947d524 100644 --- a/server/services/fableLoom/records.js +++ b/server/services/fableLoom/records.js @@ -56,7 +56,10 @@ import { asFableLoomParticipationMode, } from '../../lib/fableLoomParticipation.js'; import { normalizeFableLoomCameraMovement } from '../../lib/fableLoomCameraMovements.js'; -import { sanitizeStoryOutline } from '../../lib/fableLoomOutline.js'; +import { + analyzeStoryOutlineTeleplaySync, + sanitizeStoryOutline, +} from '../../lib/fableLoomOutline.js'; export { LOOM_LIMITS }; @@ -258,6 +261,15 @@ export function sanitizeLoom(raw) { .filter(Boolean) .slice(0, LOOM_LIMITS.EPISODES_MAX) .sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt)); + for (const episode of episodes) { + if (episode.storyOutline?.validation?.status !== 'valid') continue; + const sync = analyzeStoryOutlineTeleplaySync(episode, episode.storyOutline, { + participationMode, + }); + if (!sync.stats.matches) { + episode.storyOutline.validation = { status: 'draft', issues: sync.issues }; + } + } const protagonistCharacterId = nullableRef(raw.protagonistCharacterId); const protagonistWardrobeId = nullableRef(raw.protagonistWardrobeId); return { diff --git a/server/services/fableLoom/records.test.js b/server/services/fableLoom/records.test.js index 1153a29c6..f2ddd58ad 100644 --- a/server/services/fableLoom/records.test.js +++ b/server/services/fableLoom/records.test.js @@ -26,10 +26,10 @@ vi.mock('../pipeline/series.js', () => ({ getSeries: getSeriesMock })); const { LOOM_LIMITS, addEpisode, addNode, addNodeTransition, attachNodeImage, attachNodePlaybackAsset, attachNodeVideo, createLoom, - deleteEpisode, deleteLoom, deleteNode, deleteNodeTransition, getLoom, + deleteEpisode, deleteLoom, deleteNode, deleteNodeTransition, findEpisode, getLoom, listLooms, listLoomSummaries, mergeLoomsFromSync, pruneTombstonedLooms, restoreLoom, sanitizeLoom, updateEpisode, updateLoom, - updateNode, updateNodeTransition, + mutateLoom, updateNode, updateNodeTransition, } = await import('./records.js'); const { _resetFableLoomBackend } = await import('./store.js'); const conflictJournal = await import('../../lib/conflictJournal.js'); @@ -588,6 +588,59 @@ describe('nodes and transitions', () => { expect(aNow.transitions[0].id).toMatch(/^tr-/); }); + it('demotes a validated outline when a scene contract changes, but not for prose', async () => { + const { loomId, episodeId } = await setup(); + let updated = await addNode(loomId, episodeId, { title: 'Opening' }); + updated = await addNode(loomId, episodeId, { title: 'Ending', isEnding: true }); + const [opening, ending] = updated.episodes[0].nodes; + const withPath = await addNodeTransition(loomId, episodeId, opening.id, { + targetNodeId: ending.id, + intent: 'Answer the signal', + }); + updated = await mutateLoom(loomId, (record) => { + const episode = findEpisode(record, episodeId); + const [currentOpening, currentEnding] = episode.nodes; + episode.storyOutline = { + startKey: currentOpening.id, + scenes: [ + { + key: currentOpening.id, + title: currentOpening.title, + summary: 'The signal asks for an answer.', + playbackMode: currentOpening.playbackMode, + audienceConnection: currentOpening.audienceConnection, + protagonistPresence: currentOpening.protagonistPresence || 'onscreen', + transitions: [{ targetKey: currentEnding.id, intent: 'Answer the signal' }], + }, + { + key: currentEnding.id, + title: currentEnding.title, + summary: 'The answer opens a door.', + playbackMode: currentEnding.playbackMode, + audienceConnection: currentEnding.audienceConnection, + protagonistPresence: currentEnding.protagonistPresence || 'onscreen', + isEnding: true, + endingLabel: currentEnding.endingLabel, + transitions: [], + }, + ], + validation: { status: 'valid', issues: [] }, + }; + return record; + }); + expect(updated.episodes[0].storyOutline.validation.status).toBe('valid'); + + updated = await updateNode(loomId, episodeId, opening.id, { prose: 'The signal hums.' }); + expect(updated.episodes[0].storyOutline.validation.status).toBe('valid'); + + updated = await updateNode(loomId, episodeId, opening.id, { title: 'A Changed Opening' }); + expect(updated.episodes[0].storyOutline.validation.status).toBe('draft'); + expect(updated.episodes[0].storyOutline.validation.issues).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'TELEPLAY_SCENE_CONTRACT_MISMATCH' })]), + ); + expect(withPath.transition.targetNodeId).toBe(ending.id); + }); + it('deleting a node strips inbound transitions and repoints the start', async () => { const { loomId, episodeId } = await setup(); let updated = await addNode(loomId, episodeId, { title: 'A' }); diff --git a/server/services/fableLoom/weave.js b/server/services/fableLoom/weave.js index d92981515..a022cd774 100644 --- a/server/services/fableLoom/weave.js +++ b/server/services/fableLoom/weave.js @@ -28,6 +28,7 @@ import { import { analyzeSeriesStoryOutlines, analyzeStoryOutline, + analyzeStoryOutlineTeleplaySync, describeStoryOutlineForPrompt, sanitizeStoryOutline, } from '../../lib/fableLoomOutline.js'; @@ -318,9 +319,22 @@ export function mapGeneratedGraph(parsed) { })); if (!nodes.some((n) => n.isEnding)) throw aiShapeError('The model returned a graph with no endings'); const startNodeId = idByKey.get(parsed?.startKey) ?? nodes[0].id; - return { nodes, startNodeId }; + return { nodes, startNodeId, idByKey }; } +const remapOutlineToExpandedNodeIds = (outline, idByKey) => ({ + ...outline, + startKey: idByKey.get(outline.startKey) || outline.startKey, + scenes: outline.scenes.map((scene) => ({ + ...scene, + key: idByKey.get(scene.key) || scene.key, + transitions: (scene.transitions || []).map((transition) => ({ + ...transition, + targetKey: idByKey.get(transition.targetKey) || transition.targetKey, + })), + })), +}); + const episodeOutlineFingerprint = (loom, episode) => JSON.stringify({ loom: { name: loom.name, @@ -340,10 +354,22 @@ const episodeOutlineFingerprint = (loom, episode) => JSON.stringify({ episodeId: episode.id, }); -const outlineStructuralAnalysis = (loom, episode) => analyzeStoryOutline(episode.storyOutline, { - participationMode: loom.participationMode, - requireAudienceIntroduction: requiresAudienceIntroduction(loom, episode), -}); +const outlineStructuralAnalysis = (loom, episode) => { + const structural = analyzeStoryOutline(episode.storyOutline, { + participationMode: loom.participationMode, + requireAudienceIntroduction: requiresAudienceIntroduction(loom, episode), + }); + const teleplaySync = analyzeStoryOutlineTeleplaySync(episode, episode.storyOutline, { + participationMode: loom.participationMode, + }); + return { + issues: [...structural.issues, ...teleplaySync.issues], + stats: { + ...structural.stats, + errorCount: structural.stats.errorCount + teleplaySync.stats.errorCount, + }, + }; +}; const outlineInvalidError = (analysis, message = 'The episode outline must be valid before teleplay expansion') => { const firstIssue = analysis?.issues?.find((issue) => issue.severity === 'error'); @@ -451,7 +477,7 @@ export async function weaveEpisode(loomId, episodeId, { const loom = await requireLoom(loomId); const episode = findEpisode(loom, episodeId); const outlineValidation = expandFromOutline ? outlineStructuralAnalysis(loom, episode) : null; - if (expandFromOutline && (!episode.storyOutline || episode.storyOutline.validation?.status !== 'valid')) { + if (expandFromOutline && episode.storyOutline?.validation?.status !== 'valid') { throw outlineInvalidError(outlineValidation); } if (expandFromOutline && outlineValidation.stats.errorCount) { @@ -470,6 +496,7 @@ export async function weaveEpisode(loomId, episodeId, { if (episode.nodes.length && !replace) { throw new ServerError('Episode already has scenes — pass replace to regenerate', { status: 409, code: 'EPISODE_NOT_EMPTY' }); } + const sourceFingerprint = episodeOutlineFingerprint(loom, episode); const canonDigest = await buildCanonDigest(loom); const { content, runId } = await runLoomAi('fableloom-weave-episode', { storyContext: storyContext(loom, episode), @@ -488,7 +515,20 @@ export async function weaveEpisode(loomId, episodeId, { action: 'weave-episode', label: 'Weaving episode', source: 'fableloom-weave', }); - const { nodes, startNodeId } = mapGeneratedGraph(content); + const { nodes, startNodeId, idByKey } = mapGeneratedGraph(content); + const expandedOutline = expandFromOutline + ? remapOutlineToExpandedNodeIds(episode.storyOutline, idByKey) + : null; + if (expandedOutline) { + const sync = analyzeStoryOutlineTeleplaySync( + { ...episode, nodes, startNodeId }, + expandedOutline, + { participationMode: loom.participationMode }, + ); + if (!sync.stats.matches) { + throw aiShapeError(`The expanded teleplay changed its validated beat contract: ${sync.issues[0].message}`); + } + } const generatedAnalysis = analyzeEpisodeGraph( { ...episode, nodes, startNodeId }, { @@ -517,10 +557,21 @@ export async function weaveEpisode(loomId, episodeId, { } const updated = await mutateLoom(loomId, (current) => { const ep = findEpisode(current, episodeId); + if (episodeOutlineFingerprint(current, ep) !== sourceFingerprint) { + throw new ServerError('The episode changed while its teleplay was being woven', { + status: 409, + code: 'LOOM_CHANGED_DURING_GENERATION', + }); + } // Stamped with the format they were generated in, so a later reformat can // tell them apart from scenes already in the target format. ep.nodes = nodes.map((n) => ({ ...n, format: asLoomFormat(loom.format) })); ep.startNodeId = startNodeId; + if (expandedOutline) { + ep.storyOutline = expandedOutline; + } else if (ep.storyOutline) { + ep.storyOutline.validation = { status: 'draft', issues: [] }; + } ep.updatedAt = new Date().toISOString(); return current; }); @@ -644,7 +695,7 @@ const FEEDBACK_NODE_FIELDS = [ const FEEDBACK_TRANSITION_FIELDS = ['targetNodeId', 'intent', 'triggers', 'description']; const FEEDBACK_STRING_NODE_FIELDS = new Set(['title', 'prose', 'imagePrompt', 'videoPrompt', 'endingLabel']); -const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); +const hasOwn = (value, key) => Object.hasOwn(value, key); /** * Keep feedback edits sparse and graph-safe. The model may revise metadata, diff --git a/server/services/fableLoom/weave.test.js b/server/services/fableLoom/weave.test.js index 22b2c3dec..ab17025ac 100644 --- a/server/services/fableLoom/weave.test.js +++ b/server/services/fableLoom/weave.test.js @@ -73,6 +73,35 @@ const generatedOutline = () => ({ ], }); +const generatedGraphFromOutline = () => ({ + startKey: 's1', + nodes: [ + { + key: 's1', title: 'Signal', prose: 'The signal breaks through the static.', + playbackMode: 'cut', audienceConnection: 'disconnected', protagonistPresence: 'onscreen', + transitions: [{ targetKey: 's2', intent: 'follow the signal' }], + }, + { + key: 's2', title: 'The choice', prose: 'Two routes demand different costs.', + playbackMode: 'decision', audienceConnection: 'disconnected', protagonistPresence: 'onscreen', + transitions: [ + { targetKey: 's3', intent: 'protect the survivors' }, + { targetKey: 's4', intent: 'take the shortcut' }, + ], + }, + { + key: 's3', title: 'Rescue', prose: 'The survivors escape.', + playbackMode: 'decision', audienceConnection: 'disconnected', protagonistPresence: 'onscreen', + isEnding: true, endingLabel: 'The long way home', transitions: [], + }, + { + key: 's4', title: 'Shortcut', prose: 'The door opens.', + playbackMode: 'decision', audienceConnection: 'disconnected', protagonistPresence: 'onscreen', + isEnding: true, endingLabel: 'The open door', transitions: [], + }, + ], +}); + describe('mapGeneratedGraph', () => { it('mints server ids, remaps targets, and drops unknown-target transitions', () => { const { nodes, startNodeId } = mapGeneratedGraph(generatedGraph()); @@ -185,12 +214,16 @@ describe('weaveEpisode', () => { expect(checked.validation.issues).toEqual([]); expect(checked.outline.validation.status).toBe('valid'); - runStagedLLM.mockResolvedValueOnce({ content: generatedGraph(), runId: 'expand-run' }); + runStagedLLM.mockResolvedValueOnce({ content: generatedGraphFromOutline(), runId: 'expand-run' }); const expanded = await weaveEpisode(loomId, episodeId, { guidance: 'Write the full teleplay now.', replace: false, expandFromOutline: true, }); expect(expanded.runId).toBe('expand-run'); - expect(expanded.loom.episodes[0].nodes).toHaveLength(3); + expect(expanded.loom.episodes[0].nodes).toHaveLength(4); + expect(expanded.loom.episodes[0].storyOutline.scenes.map((scene) => scene.key)) + .toEqual(expanded.loom.episodes[0].nodes.map((node) => node.id)); + expect(expanded.loom.episodes[0].storyOutline.startKey) + .toBe(expanded.loom.episodes[0].startNodeId); expect(runStagedLLM.mock.calls[1][0]).toBe('fableloom-weave-episode'); expect(runStagedLLM.mock.calls[1][1].outlineDigest).toContain('[s1] Signal'); }); @@ -222,6 +255,47 @@ describe('weaveEpisode', () => { expect(withSecond.episodes).toHaveLength(2); expect(runStagedLLM).toHaveBeenCalledTimes(1); }); + + it('keeps remapped outlines ready while expanding episodes in series order', async () => { + const { loomId, episodeId } = await setup(); + const withSecond = await addEpisode(loomId, { title: 'Second', synopsis: 'The consequence.' }); + const secondEpisodeId = withSecond.episodes[1].id; + + runStagedLLM.mockResolvedValueOnce({ content: generatedOutline(), runId: 'outline-1' }); + await generateEpisodeOutline(loomId, episodeId, {}); + await validateEpisodeOutline(loomId, episodeId); + runStagedLLM.mockResolvedValueOnce({ content: generatedOutline(), runId: 'outline-2' }); + await generateEpisodeOutline(loomId, secondEpisodeId, {}); + await validateEpisodeOutline(loomId, secondEpisodeId); + + runStagedLLM.mockResolvedValueOnce({ content: generatedGraphFromOutline(), runId: 'expand-1' }); + const firstExpanded = await weaveEpisode(loomId, episodeId, { expandFromOutline: true }); + expect(firstExpanded.loom.episodes[0].storyOutline.validation.status).toBe('valid'); + + runStagedLLM.mockResolvedValueOnce({ content: generatedGraphFromOutline(), runId: 'expand-2' }); + const secondExpanded = await weaveEpisode(loomId, secondEpisodeId, { expandFromOutline: true }); + expect(secondExpanded.loom.episodes.every((item) => ( + item.storyOutline.validation.status === 'valid' + ))).toBe(true); + }); + + it('marks validation invalid when an expanded teleplay has drifted from its outline', async () => { + const { loomId, episodeId } = await setup(); + runStagedLLM.mockResolvedValueOnce({ content: generatedOutline(), runId: 'outline-run' }); + await generateEpisodeOutline(loomId, episodeId, {}); + await validateEpisodeOutline(loomId, episodeId); + runStagedLLM.mockResolvedValueOnce({ content: generatedGraphFromOutline(), runId: 'expand-run' }); + await weaveEpisode(loomId, episodeId, { expandFromOutline: true }); + await updateNode(loomId, episodeId, (await getLoom(loomId)).episodes[0].nodes[0].id, { + title: 'Changed after expansion', + }); + + const checked = await validateEpisodeOutline(loomId, episodeId); + expect(checked.outline.validation.status).toBe('invalid'); + expect(checked.validation.issues).toContainEqual(expect.objectContaining({ + code: 'TELEPLAY_SCENE_CONTRACT_MISMATCH', + })); + }); }); describe('episode outline AI review', () => { @@ -614,7 +688,7 @@ describe('reformatEpisodeScenes', () => { it('rewrites every returned scene, pins the format, and leaves the graph alone', async () => { const { loomId, episodeId, gateId, insideId } = await proseSetup(); - runStagedLLM.mockImplementation(async (stage, variables) => ({ + runStagedLLM.mockImplementation(async (_stage, variables) => ({ content: { scenes: JSON.parse(variables.scenesJson).map((sc) => ({ id: sc.id, prose: `INT. GATE - NIGHT\n\n${sc.prose}` })), }, @@ -668,7 +742,7 @@ describe('reformatEpisodeScenes', () => { await addNode(loomId, episodeId, { title: `Scene ${i}`, prose: `Prose ${i}.` }); } let call = 0; - runStagedLLM.mockImplementation(async (stage, variables) => { + runStagedLLM.mockImplementation(async (_stage, variables) => { call += 1; if (call > 1) throw new Error('provider died mid-run'); return { content: { scenes: JSON.parse(variables.scenesJson).map((sc) => ({ id: sc.id, prose: `INT. ${sc.prose}` })) } }; @@ -684,7 +758,7 @@ describe('reformatEpisodeScenes', () => { const { loomId, episodeId } = await setup(); await addNode(loomId, episodeId, { title: 'Written', prose: 'You stand before it.' }); await addNode(loomId, episodeId, { title: 'Placeholder with no prose yet' }); - runStagedLLM.mockImplementation(async (stage, variables) => ({ + runStagedLLM.mockImplementation(async (_stage, variables) => ({ content: { scenes: JSON.parse(variables.scenesJson).map((sc) => ({ id: sc.id, prose: 'INT. SOMEWHERE' })) }, })); @@ -706,7 +780,7 @@ describe('reformatEpisodeScenes', () => { })); return current; }); - runStagedLLM.mockImplementation(async (stage, variables) => ({ + runStagedLLM.mockImplementation(async (_stage, variables) => ({ content: { scenes: JSON.parse(variables.scenesJson).map((sc) => ({ id: sc.id, prose: `INT. ${sc.prose}` })) }, })); @@ -741,7 +815,7 @@ describe('reformatEpisodeScenes', () => { const withEp2 = await addEpisode(loomId, { title: 'Two' }); const episode2Id = withEp2.episodes[1].id; await addNode(loomId, episode2Id, { title: 'Elsewhere', prose: 'Rain on the roof.' }); - runStagedLLM.mockImplementation(async (stage, variables) => ({ + runStagedLLM.mockImplementation(async (_stage, variables) => ({ content: { scenes: JSON.parse(variables.scenesJson).map((sc) => ({ id: sc.id, prose: `INT. ${sc.prose}` })) }, })); @@ -759,7 +833,7 @@ describe('reformatEpisodeScenes', () => { it('is a no-op on an episode with nothing left to convert, and still pins the loom', async () => { const { loomId, episodeId } = await proseSetup(); - runStagedLLM.mockImplementation(async (stage, variables) => ({ + runStagedLLM.mockImplementation(async (_stage, variables) => ({ content: { scenes: JSON.parse(variables.scenesJson).map((sc) => ({ id: sc.id, prose: `INT. ${sc.prose}` })) }, })); await reformatEpisodeScenes(loomId, episodeId, { format: 'teleplay' }); @@ -802,7 +876,7 @@ describe('reformatEpisodeScenes', () => { it('asks the model for the TARGET format, not the one the loom still holds', async () => { const { loomId, episodeId } = await proseSetup(); - runStagedLLM.mockImplementation(async (stage, variables) => ({ + runStagedLLM.mockImplementation(async (_stage, variables) => ({ content: { scenes: JSON.parse(variables.scenesJson).map((sc) => ({ id: sc.id, prose: 'INT. GATE' })) }, })); await reformatEpisodeScenes(loomId, episodeId, { format: 'teleplay' }); diff --git a/server/services/stageRunner.js b/server/services/stageRunner.js index 195dc2646..7700acd8e 100644 --- a/server/services/stageRunner.js +++ b/server/services/stageRunner.js @@ -517,7 +517,9 @@ export async function runStagedLLM(stageName, variables, options = {}) { * user-defined editorial checks (#1346) whose prompt is authored from the UI. * * Same options as runStagedLLM (providerOverride / modelOverride / - * timeoutOverride / returnsJson / source). + * timeoutOverride / returnsJson / source / allowFallback). Set + * `allowFallback: false` when the caller budgeted an already-rendered prompt + * against the resolved provider and a smaller fallback could not accept it. */ export async function runInlineLLM(prompt, options = {}) { if (typeof prompt !== 'string' || !prompt.trim()) { @@ -600,6 +602,7 @@ async function executeStagePrompt({ stage, label, prompt, options }) { prompt, source: options.source || 'staged-llm', effort: effectiveEffort, + allowFallback: options.allowFallback !== false, // createRun.timeout is returned but not persisted into metadata.json // by the toolkit (only providerId/model/source/etc. are written at // creation time). We always patch below to record the effective @@ -653,6 +656,7 @@ async function executeStagePrompt({ stage, label, prompt, options }) { // provider's ladder and omits the flag entirely for a provider with no // effort control, so no capability check is needed here. effort: effectiveEffort, + allowFallback: options.allowFallback !== false, onRunCreated: options.onRunCreated, onRunReady: options.onRunReady, onRunSettled: options.onRunSettled, diff --git a/server/services/stageRunner.test.js b/server/services/stageRunner.test.js index fdb346a53..f62de1af6 100644 --- a/server/services/stageRunner.test.js +++ b/server/services/stageRunner.test.js @@ -529,6 +529,20 @@ describe('stageRunner — runStagedLLM dispatch', () => { expect(runner.createRun).toHaveBeenCalledWith(expect.objectContaining({ source: 'pipeline-text-stage' })); }); + it('disables proactive and runtime fallback for provider-budgeted prompts', async () => { + prompts.getStage.mockReturnValue(null); + providers.getActiveProvider.mockResolvedValue(apiProvider({ contextWindow: 1_000_000 })); + runner.executeApiRun.mockImplementation(async ({ onComplete }) => { + onComplete({ error: 'primary failed' }); + }); + + await expect(runStagedLLM('s', {}, { allowFallback: false })) + .rejects.toThrow(/primary failed/); + + expect(runner.createRun).toHaveBeenCalledWith(expect.objectContaining({ allowFallback: false })); + expect(runner.executeApiRun).toHaveBeenCalledTimes(1); + }); + it('passes stage.timeout to executeCliRun when set', async () => { prompts.getStage.mockReturnValue({ timeout: 900000 }); providers.getActiveProvider.mockResolvedValue(cliProvider({ timeout: 5000 })); From 3ae9921c9ead670518c06fa7653cb358b23ea898 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Sun, 30 Aug 2026 14:27:22 -0700 Subject: [PATCH 3/5] chore(fableloom): refresh prompt stage call sites --- server/lib/promptStageCallSites.generated.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/lib/promptStageCallSites.generated.json b/server/lib/promptStageCallSites.generated.json index e51ff4fd2..5ba62f6c8 100644 --- a/server/lib/promptStageCallSites.generated.json +++ b/server/lib/promptStageCallSites.generated.json @@ -34,6 +34,9 @@ "fableloom-branch-node": [ "server/services/fableLoom/weave.js" ], + "fableloom-editorial-remediate": [ + "server/services/fableLoom/editorial.js" + ], "fableloom-feedback-episode": [ "server/services/fableLoom/weave.js" ], @@ -58,6 +61,9 @@ "fableloom-review-episode-outline": [ "server/services/fableLoom/weave.js" ], + "fableloom-review-playthroughs": [ + "server/services/fableLoom/editorial.js" + ], "fableloom-review-series-plan": [ "server/services/fableLoom/weave.js" ], From da07e77e3eea774a53bd3fa34c2477923c7cdacb Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Sun, 30 Aug 2026 14:31:57 -0700 Subject: [PATCH 4/5] fix(fableloom): keep prompt migration hashes current --- scripts/migrations/288-fableloom-scene-format.js | 2 +- scripts/migrations/310-fableloom-camera-cuts.js | 2 +- scripts/migrations/311-fableloom-audience-participation.js | 2 +- scripts/migrations/319-fableloom-protagonist-continuity.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/migrations/288-fableloom-scene-format.js b/scripts/migrations/288-fableloom-scene-format.js index b1059935e..da2cb1c25 100644 --- a/scripts/migrations/288-fableloom-scene-format.js +++ b/scripts/migrations/288-fableloom-scene-format.js @@ -32,7 +32,7 @@ export const ACCEPTED_OLD_MD5 = { // Post-change shipped hashes (format contract rendered from the loom record). export const NEW_SHIPPED_MD5 = { - 'fableloom-weave-episode.md': 'e0f8d864caa8746912b56cd567f1c09d', // post-319 continuity contract + 'fableloom-weave-episode.md': 'b4d363db94fd8a9928fa977745c76ff9', // post-321 outline expansion contract 'fableloom-branch-node.md': '39a208c8cc593d0531af50760e3cf0da', 'fableloom-play-turn.md': 'e35ad91aae263e3adf28d1e047a46661', }; diff --git a/scripts/migrations/310-fableloom-camera-cuts.js b/scripts/migrations/310-fableloom-camera-cuts.js index c67478d54..deb6da4a3 100644 --- a/scripts/migrations/310-fableloom-camera-cuts.js +++ b/scripts/migrations/310-fableloom-camera-cuts.js @@ -17,7 +17,7 @@ export const ACCEPTED_OLD_MD5 = { }; export const NEW_SHIPPED_MD5 = { - 'fableloom-weave-episode.md': 'e0f8d864caa8746912b56cd567f1c09d', // post-319 continuity contract + 'fableloom-weave-episode.md': 'b4d363db94fd8a9928fa977745c76ff9', // post-321 outline expansion contract 'fableloom-branch-node.md': '39a208c8cc593d0531af50760e3cf0da', 'fableloom-feedback-episode.md': '1aaa6f17acad6a3215e48dcce14e8670', }; diff --git a/scripts/migrations/311-fableloom-audience-participation.js b/scripts/migrations/311-fableloom-audience-participation.js index d8684de23..f1f83315c 100644 --- a/scripts/migrations/311-fableloom-audience-participation.js +++ b/scripts/migrations/311-fableloom-audience-participation.js @@ -17,7 +17,7 @@ export const ACCEPTED_OLD_MD5 = { }; export const NEW_SHIPPED_MD5 = { - 'fableloom-weave-episode.md': 'e0f8d864caa8746912b56cd567f1c09d', // post-319 continuity contract + 'fableloom-weave-episode.md': 'b4d363db94fd8a9928fa977745c76ff9', // post-321 outline expansion contract 'fableloom-branch-node.md': '39a208c8cc593d0531af50760e3cf0da', 'fableloom-feedback-episode.md': '1aaa6f17acad6a3215e48dcce14e8670', 'fableloom-review.md': 'c26a641f6d0530caef7d1186c3b09937', diff --git a/scripts/migrations/319-fableloom-protagonist-continuity.js b/scripts/migrations/319-fableloom-protagonist-continuity.js index 783561849..dd483aebd 100644 --- a/scripts/migrations/319-fableloom-protagonist-continuity.js +++ b/scripts/migrations/319-fableloom-protagonist-continuity.js @@ -12,7 +12,7 @@ export const ACCEPTED_OLD_MD5 = { }; export const NEW_SHIPPED_MD5 = { - 'fableloom-weave-episode.md': 'e0f8d864caa8746912b56cd567f1c09d', + 'fableloom-weave-episode.md': 'b4d363db94fd8a9928fa977745c76ff9', 'fableloom-outline-episode.md': '513b2b5b8fa98766852cdde7b87198c9', 'fableloom-review-episode-outline.md': '8154b4c289b10268df8fd3c625bcdac2', }; From c320ea4926c2abb8755996770103f9b9b272de44 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Sun, 30 Aug 2026 14:35:33 -0700 Subject: [PATCH 5/5] test: repair full-suite regression fixtures --- scripts/checkNpmVersion.test.js | 36 ++++++++++++++++---- server/services/fableLoom/production.test.js | 25 +++++++------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/scripts/checkNpmVersion.test.js b/scripts/checkNpmVersion.test.js index 1dad789a9..49ca854e0 100644 --- a/scripts/checkNpmVersion.test.js +++ b/scripts/checkNpmVersion.test.js @@ -8,8 +8,16 @@ * failure, and it names the shadowed-global-npm case that makes the version * skew hard to spot. */ -import { describe, it, expect, vi } from 'vitest'; -import { existsSync, readFileSync } from 'fs'; +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; @@ -23,6 +31,11 @@ import { const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const readJson = (rel) => JSON.parse(readFileSync(join(REPO_ROOT, rel), 'utf8')); +const tempRoots = []; + +afterEach(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); describe('MIN_NPM', () => { it('is the npm major whose lockfile writer records `libc`', () => { @@ -70,10 +83,21 @@ describe('readBundledNpmVersion', () => { expect(readBundledNpmVersion(join(REPO_ROOT, 'no', 'such', 'node'))).toBeNull(); }); - it('reads the running Node’s own bundled npm', () => { - // Every supported Node ships an npm; a null here means the layout probe - // stopped matching reality, which would silently drop the shadowing hint. - expect(readBundledNpmVersion()).toMatch(/^\d+\.\d+\.\d+/); + it('reads bundled npm from both supported Node install layouts', () => { + const root = mkdtempSync(join(tmpdir(), 'portos-npm-version-')); + tempRoots.push(root); + + const adjacentBin = join(root, 'adjacent', 'bin'); + const adjacentManifest = join(adjacentBin, 'node_modules', 'npm', 'package.json'); + mkdirSync(dirname(adjacentManifest), { recursive: true }); + writeFileSync(adjacentManifest, JSON.stringify({ version: '11.17.0' })); + expect(readBundledNpmVersion(join(adjacentBin, 'node'))).toBe('11.17.0'); + + const prefixBin = join(root, 'prefix', 'bin'); + const prefixManifest = join(root, 'prefix', 'lib', 'node_modules', 'npm', 'package.json'); + mkdirSync(dirname(prefixManifest), { recursive: true }); + writeFileSync(prefixManifest, JSON.stringify({ version: '12.0.1' })); + expect(readBundledNpmVersion(join(prefixBin, 'node'))).toBe('12.0.1'); }); }); diff --git a/server/services/fableLoom/production.test.js b/server/services/fableLoom/production.test.js index e7890754f..653ca0558 100644 --- a/server/services/fableLoom/production.test.js +++ b/server/services/fableLoom/production.test.js @@ -71,24 +71,15 @@ describe('fableLoom production service', () => { startNodeId: 'node-1', storyOutline: { version: 1, - startKey: 's1', + startKey: 'node-1', scenes: [ { - key: 's1', title: 'Opening', summary: 'The hero reaches the threshold.', + key: 'node-1', title: 'Node 1', summary: 'The hero reaches the threshold.', playbackMode: 'cut', audienceConnection: 'disconnected', protagonistPresence: 'onscreen', - isEnding: false, transitions: [{ targetKey: 's2', intent: 'Continue' }], + isEnding: false, transitions: [{ targetKey: 'node-2', intent: 'Go forward' }], }, { - key: 's2', title: 'Choice', summary: 'The hero chooses a direction.', - playbackMode: 'decision', audienceConnection: 'connected', protagonistPresence: 'onscreen', - isEnding: false, - transitions: [ - { targetKey: 's3', intent: 'Take the left path' }, - { targetKey: 's3', intent: 'Take the right path' }, - ], - }, - { - key: 's3', title: 'Arrival', summary: 'The choice opens a way forward.', + key: 'node-2', title: 'Node 2', summary: 'The choice opens a way forward.', playbackMode: 'cut', audienceConnection: 'disconnected', protagonistPresence: 'onscreen', isEnding: true, endingLabel: 'Forward', transitions: [], }, @@ -102,6 +93,10 @@ describe('fableLoom production service', () => { prose: 'Opening scene prose.', imagePrompt: 'Visual prompt 1', videoPrompt: 'Video prompt 1', + playbackMode: 'cut', + audienceConnection: 'disconnected', + protagonistPresence: 'onscreen', + isEnding: false, transitions: [{ id: 'tr-1', targetNodeId: 'node-2', intent: 'Go forward' }], }, { @@ -110,7 +105,11 @@ describe('fableLoom production service', () => { prose: 'Second scene prose.', imagePrompt: 'Visual prompt 2', videoPrompt: 'Video prompt 2', + playbackMode: 'cut', + audienceConnection: 'disconnected', + protagonistPresence: 'onscreen', isEnding: true, + endingLabel: 'Forward', transitions: [], }, ],