Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
384 changes: 384 additions & 0 deletions client/src/components/fableloom/LoomEditorialAutomation.jsx

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions client/src/components/fableloom/LoomEditorialAutomation.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<LoomEditorialAutomation
loom={loom}
dirty={false}
onLoomUpdate={vi.fn()}
{...props}
/>
</MemoryRouter>,
);

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();
});
});
9 changes: 6 additions & 3 deletions client/src/components/fableloom/LoomSeriesPlan.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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()}`;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -179,6 +180,8 @@ export default function LoomSeriesPlan({ loom, onLoomUpdate }) {

<EpisodeBeatReadiness loom={loom} />

<LoomEditorialAutomation loom={loom} dirty={dirty} onLoomUpdate={adoptServerPlan} />

<PlanCollection
title="Plot points"
description="Order the tentpole beats and connect each one to the episode where it should land."
Expand Down
3 changes: 3 additions & 0 deletions client/src/components/fableloom/LoomSeriesPlan.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ vi.mock('../../services/socket', () => ({
default: { on: vi.fn(), off: vi.fn() },
}));
vi.mock('../ProviderModelSelector', () => ({ default: () => <div>AI route picker</div> }));
vi.mock('./LoomEditorialAutomation', () => ({
default: () => <div>Editorial automation</div>,
}));

import * as api from '../../services/api';
import LoomSeriesPlan from './LoomSeriesPlan';
Expand Down
18 changes: 18 additions & 0 deletions client/src/services/apiFableLoom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions client/src/services/apiFableLoom.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions data.reference/prompts/stage-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
}
}
76 changes: 76 additions & 0 deletions data.reference/prompts/stages/fableloom-editorial-remediate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 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 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 — 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": [],
"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."
}
]
}
]
}
```

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`
Loading