Add durable fiction authoring workflows

This commit is contained in:
Daniel Maddern 2026-08-28 23:51:02 +07:00
parent 203bc7917a
commit 829a0361d4
65 changed files with 13934 additions and 25 deletions

View file

@ -12,6 +12,7 @@ Artifex V1 is the bootstrap autonomous engineering control plane defined in `doc
- Model access through `ModelRouter`
- LangGraph hidden behind `GraphRuntime`
- Git worktrees for mutable autonomous tasks
- Checkpoint-native fiction planning, drafting, parallel editorial review, approval, canon, and EPUB publication
## Run Locally
@ -28,3 +29,5 @@ For lightweight local checks only, SQLite can be selected explicitly:
```bash
DATABASE_URL=sqlite:///db.sqlite3 python manage.py migrate
```
See `docs/story_authoring_workflow.md` for the durable story-authoring workflow and Spark deployment instructions.

View file

@ -27,6 +27,7 @@ INSTALLED_APPS = [
"control_plane.secrets",
"control_plane.knowledge",
"control_plane.verification",
"control_plane.authoring",
"graph",
]

View file

@ -3,8 +3,9 @@ from __future__ import annotations
from django.contrib import admin
from django.urls import path
from control_plane.projects import views
from control_plane.authoring import views as authoring_views
from control_plane.model_studio import views as model_studio_views
from control_plane.projects import views
from control_plane.trading_studio import views as trading_studio_views
urlpatterns = [
@ -40,6 +41,39 @@ urlpatterns = [
path("trading-studio/<uuid:project_id>/", trading_studio_views.trading_studio_project, name="trading_studio_project"),
path("approvals/", views.approvals, name="approvals"),
path("approvals/<int:approval_id>/action/", views.approval_action, name="approval_action"),
path("api/authoring/book-states/", authoring_views.book_states, name="book_states"),
path(
"api/authoring/book-states/<uuid:state_id>/",
authoring_views.book_state_detail,
name="book_state_detail",
),
path(
"api/authoring/book-states/<uuid:state_id>/actions/",
authoring_views.book_state_action,
name="book_state_action",
),
path("api/authoring/ideas/", authoring_views.scene_ideas, name="scene_ideas"),
path(
"api/authoring/ideas/<uuid:idea_id>/",
authoring_views.scene_idea_detail,
name="scene_idea_detail",
),
path(
"api/authoring/ideas/<uuid:idea_id>/actions/",
authoring_views.scene_idea_action,
name="scene_idea_action",
),
path("api/authoring/scenes/", authoring_views.standalone_scenes, name="standalone_scenes"),
path(
"api/authoring/scenes/<uuid:scene_id>/",
authoring_views.standalone_scene_detail,
name="standalone_scene_detail",
),
path(
"api/authoring/scenes/<uuid:scene_id>/actions/",
authoring_views.standalone_scene_action,
name="standalone_scene_action",
),
path("activity/", views.activity, name="activity"),
path("admin/", admin.site.urls),
]

View file

View file

@ -0,0 +1,186 @@
from __future__ import annotations
from django.contrib import admin
from control_plane.authoring.models import (
BookRun,
BookStateVersion,
SceneContextCitation,
SceneIdeation,
Series,
SourceDocument,
SourceDocumentVersion,
SourcePassage,
StandaloneScene,
Work,
)
@admin.register(Series)
class SeriesAdmin(admin.ModelAdmin):
list_display = ("title", "slug", "updated_at")
search_fields = ("title", "slug")
@admin.register(Work)
class WorkAdmin(admin.ModelAdmin):
list_display = (
"title",
"series",
"work_type",
"sequence",
"current_book_state",
"updated_at",
)
list_filter = ("work_type", "series")
search_fields = ("title", "slug", "series__title")
class SourceDocumentVersionInline(admin.TabularInline):
model = SourceDocumentVersion
fields = ("version", "authority", "source_path", "source_sha256", "created_at")
readonly_fields = fields
extra = 0
show_change_link = True
@admin.register(SourceDocument)
class SourceDocumentAdmin(admin.ModelAdmin):
list_display = ("title", "work", "document_type", "logical_key", "updated_at")
list_filter = ("document_type", "work__series", "work")
search_fields = ("title", "logical_key", "work__title")
inlines = (SourceDocumentVersionInline,)
def get_readonly_fields(self, request, obj=None):
return tuple(field.name for field in self.model._meta.fields)
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(SourceDocumentVersion)
class SourceDocumentVersionAdmin(admin.ModelAdmin):
list_display = ("document", "version", "authority", "byte_size", "created_at")
list_filter = ("authority", "document__document_type", "document__work")
search_fields = ("document__title", "document__logical_key", "source_path", "source_sha256")
def get_readonly_fields(self, request, obj=None):
return tuple(field.name for field in self.model._meta.fields)
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(SourcePassage)
class SourcePassageAdmin(admin.ModelAdmin):
list_display = ("document_version", "ordinal", "start_line", "end_line", "sha256")
search_fields = ("content", "document_version__document__logical_key")
def get_readonly_fields(self, request, obj=None):
return tuple(field.name for field in self.model._meta.fields)
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
class SceneContextCitationInline(admin.TabularInline):
model = SceneContextCitation
fields = ("rank", "passage", "score", "reason")
readonly_fields = fields
extra = 0
@admin.register(StandaloneScene)
class StandaloneSceneAdmin(admin.ModelAdmin):
list_display = (
"title",
"work",
"book_state",
"book_chapter_key",
"revision",
"status",
"target_words",
"word_count",
"updated_at",
)
list_filter = ("status", "work__series", "work")
search_fields = ("title", "scene_key", "brief", "prose")
inlines = (SceneContextCitationInline,)
def get_readonly_fields(self, request, obj=None):
return tuple(field.name for field in self.model._meta.fields)
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(SceneIdeation)
class SceneIdeationAdmin(admin.ModelAdmin):
list_display = (
"work",
"book_state",
"target_book",
"candidate_count",
"context_pack_sha256",
"created_at",
)
list_filter = ("work__series", "work")
search_fields = ("target_book", "focus", "work__title")
def get_readonly_fields(self, request, obj=None):
return tuple(field.name for field in self.model._meta.fields)
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(BookStateVersion)
class BookStateVersionAdmin(admin.ModelAdmin):
list_display = ("work", "version", "status", "sha256", "created_at")
list_filter = ("status", "work__series", "work")
search_fields = ("work__title", "sha256", "approved_by")
def get_readonly_fields(self, request, obj=None):
return tuple(field.name for field in self.model._meta.fields)
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(BookRun)
class BookRunAdmin(admin.ModelAdmin):
list_display = (
"book_state",
"status",
"current_chapter_key",
"started_at",
"finished_at",
"updated_at",
)
list_filter = ("status", "book_state__work")
search_fields = ("book_state__work__title", "current_chapter_key", "failure_reason")
def get_readonly_fields(self, request, obj=None):
return tuple(field.name for field in self.model._meta.fields)
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False

View file

@ -0,0 +1,8 @@
from __future__ import annotations
from django.apps import AppConfig
class AuthoringConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "control_plane.authoring"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,24 @@
from __future__ import annotations
import os
from collections.abc import Iterator
from contextlib import contextmanager
@contextmanager
def open_story_checkpointer() -> Iterator[object]:
database_url = os.environ.get("DATABASE_URL", "")
if database_url.startswith(("postgres://", "postgresql://")):
try:
from langgraph.checkpoint.postgres import PostgresSaver
except ImportError as exc:
raise RuntimeError(
"Spark story workflows require langgraph-checkpoint-postgres; install project dependencies"
) from exc
with PostgresSaver.from_conn_string(database_url) as saver:
saver.setup()
yield saver
return
from langgraph.checkpoint.memory import MemorySaver
yield MemorySaver()

View file

@ -0,0 +1,88 @@
from __future__ import annotations
import html
import re
import zipfile
from pathlib import Path
def write_epub(*, title: str, series: str, chapters: list[dict[str, str]], destination: Path) -> Path:
destination.parent.mkdir(parents=True, exist_ok=True)
manifest = [
'<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>',
'<item id="css" href="style.css" media-type="text/css"/>',
'<item id="title" href="title.xhtml" media-type="application/xhtml+xml"/>',
]
spine = ['<itemref idref="title"/>']
navigation = []
ncx = []
with zipfile.ZipFile(destination, "w") as archive:
archive.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED)
archive.writestr(
"META-INF/container.xml",
'<?xml version="1.0"?><container version="1.0" '
'xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles>'
'<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>'
"</rootfiles></container>",
)
archive.writestr(
"OEBPS/style.css",
"body{font-family:serif;line-height:1.45;margin:5%}h1{text-align:center}"
"p{text-indent:1.2em;margin:0 0 .35em}.first{text-indent:0}.title{text-align:center;margin-top:30%}",
)
archive.writestr(
"OEBPS/title.xhtml",
_xhtml(title, f'<div class="title"><h1>{html.escape(title)}</h1><p>{html.escape(series)}</p></div>'),
)
for index, chapter in enumerate(chapters, start=1):
filename = f"chapter-{index}.xhtml"
item_id = f"chapter-{index}"
chapter_title = chapter.get("title") or f"Chapter {index}"
manifest.append(
f'<item id="{item_id}" href="{filename}" media-type="application/xhtml+xml"/>'
)
spine.append(f'<itemref idref="{item_id}"/>')
navigation.append(
f'<li><a href="{filename}">{html.escape(chapter_title)}</a></li>'
)
ncx.append(
f'<navPoint id="n{index}" playOrder="{index}"><navLabel><text>{html.escape(chapter_title)}</text></navLabel>'
f'<content src="{filename}"/></navPoint>'
)
paragraphs = []
for paragraph_index, paragraph in enumerate(
part.strip() for part in re.split(r"\n\s*\n", chapter.get("content", "")) if part.strip()
):
class_name = ' class="first"' if paragraph_index == 0 else ""
paragraphs.append(f"<p{class_name}>{html.escape(paragraph)}</p>")
archive.writestr(
f"OEBPS/{filename}",
_xhtml(chapter_title, f"<h1>{html.escape(chapter_title)}</h1>{''.join(paragraphs)}"),
)
archive.writestr(
"OEBPS/nav.xhtml",
_xhtml(title, f'<nav xmlns:epub="http://www.idpf.org/2007/ops" epub:type="toc"><ol>{"".join(navigation)}</ol></nav>'),
)
archive.writestr(
"OEBPS/toc.ncx",
f'<?xml version="1.0"?><ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">'
f"<docTitle><text>{html.escape(title)}</text></docTitle><navMap>{''.join(ncx)}</navMap></ncx>",
)
archive.writestr(
"OEBPS/content.opf",
f'<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid" version="3.0">'
f'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="bookid">artifex-{html.escape(title)}</dc:identifier>'
f"<dc:title>{html.escape(title)}</dc:title><dc:language>en</dc:language></metadata>"
f'<manifest>{"".join(manifest)}</manifest><spine toc="ncx">{"".join(spine)}</spine></package>',
)
return destination
def _xhtml(title: str, body: str) -> str:
return (
'<?xml version="1.0" encoding="UTF-8"?>'
'<html xmlns="http://www.w3.org/1999/xhtml"><head>'
f"<title>{html.escape(title)}</title><link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"/>"
f"</head><body>{body}</body></html>"
)

View file

@ -0,0 +1,144 @@
from __future__ import annotations
import json
import os
import time
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Max
from django.utils import timezone
from control_plane.authoring.models import ChapterRevision, RevisionStatus
from control_plane.authoring.services import DjangoStoryWorkflowServices
from control_plane.resources.models import ModelRequest
from model_router.providers import providers_from_resources
from model_router.router import ModelRouter
class Command(BaseCommand):
help = "Generate an isolated prose candidate from an existing chapter revision."
def add_arguments(self, parser) -> None:
parser.add_argument("--source-revision")
parser.add_argument("--review-revision")
parser.add_argument("--model", default="qwen")
parser.add_argument("--review", action="store_true")
def handle(self, *args, **options) -> None:
if options["review_revision"]:
self._review_existing(options["review_revision"])
return
if not options["source_revision"]:
raise CommandError("provide --source-revision or --review-revision")
source = ChapterRevision.objects.select_related("chapter__story__project").get(
id=options["source_revision"]
)
model = str(options["model"]).strip().lower()
os.environ["ARTIFEX_STORY_PROSE_MODEL"] = model
bible = source.chapter.story.bible_versions.filter(
approved_at__isnull=False
).latest("version")
outline = source.chapter.story.outline_versions.filter(
approved_at__isnull=False
).latest("version")
next_number = (
source.chapter.revisions.aggregate(value=Max("revision"))["value"] or 0
) + 1
candidate = ChapterRevision.objects.create(
chapter=source.chapter,
revision=next_number,
status=RevisionStatus.DRAFT,
parent=source,
source_revision=source.source_revision,
story_bible=bible,
outline=outline,
scene_plan=source.scene_plan,
graph_thread_id=f"benchmark-{model}-{source.id}",
generation_metadata={
"benchmark": True,
"benchmark_model": model,
"benchmark_source_revision": str(source.id),
},
)
providers = providers_from_resources()
provider = providers.get(model)
if provider is not None and provider.provider_name == "local_inference":
config = dict(provider.resource.config)
config["temperature"] = 0.7
config["extra_body"] = {
**dict(config.get("extra_body") or {}),
"top_p": 0.8,
"chat_template_kwargs": {"enable_thinking": False},
}
provider.resource.config = config
router = ModelRouter(providers, persist_requests=True)
services = DjangoStoryWorkflowServices(router)
state = {"revision_id": str(candidate.id)}
services.build_context(state)
request_started = timezone.now()
started = time.monotonic()
services.draft_chapter(state)
prose_seconds = time.monotonic() - started
review_ids: list[str] = []
if options["review"]:
services.extract_continuity(state)
for review_kind in ["continuity", "character", "pacing"]:
review_ids.extend(services.review_chapter(state, review_kind))
candidate.refresh_from_db()
prose_request = (
ModelRequest.objects.filter(
project=source.chapter.story.project,
logical_role="STORY_PROSE",
model_resource__provider=provider.provider_name,
created_at__gte=request_started,
)
.order_by("-created_at")
.first()
)
result = {
"candidate_revision_id": str(candidate.id),
"candidate_revision": candidate.revision,
"source_revision_id": str(source.id),
"model": prose_request.model if prose_request else model,
"artifact_uri": candidate.artifact_uri,
"word_count": candidate.word_count,
"prose_seconds": round(prose_seconds, 2),
"prompt_tokens": prose_request.prompt_tokens if prose_request else None,
"completion_tokens": prose_request.completion_tokens if prose_request else None,
"finding_ids": review_ids,
}
self.stdout.write(json.dumps(result, indent=2))
def _review_existing(self, revision_id: str) -> None:
candidate = ChapterRevision.objects.select_related("chapter__story__project").get(
id=revision_id
)
services = DjangoStoryWorkflowServices(
ModelRouter(providers_from_resources(), persist_requests=True)
)
state = {"revision_id": str(candidate.id)}
started = time.monotonic()
services.extract_continuity(state)
finding_ids: list[str] = []
for review_kind in ["continuity", "character", "pacing"]:
finding_ids.extend(services.review_chapter(state, review_kind))
findings = list(
candidate.findings.filter(id__in=finding_ids).values(
"review_kind",
"severity",
"category",
"location",
"description",
"suggested_revision",
)
)
self.stdout.write(
json.dumps(
{
"candidate_revision_id": str(candidate.id),
"review_seconds": round(time.monotonic() - started, 2),
"findings": findings,
},
indent=2,
)
)

View file

@ -0,0 +1,217 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand, CommandError
from control_plane.authoring.book_state import BookStateService
from control_plane.authoring.models import BookRun, BookStateVersion, Work
from model_router.providers import providers_from_resources
from model_router.router import ModelRouter
class Command(BaseCommand):
help = "Create, review, approve, and run versioned fiction book state."
def add_arguments(self, parser) -> None:
parser.add_argument(
"action",
choices=[
"create",
"show",
"validate",
"review",
"approve",
"reject",
"revise",
"impact",
"start-run",
"sync-run",
"review-run",
],
)
parser.add_argument("--id")
parser.add_argument("--run-id")
parser.add_argument("--series-slug")
parser.add_argument("--work-slug")
parser.add_argument("--input", type=Path)
parser.add_argument("--level")
parser.add_argument("--model")
parser.add_argument("--actor", default="management_command")
parser.add_argument("--notes", default="")
parser.add_argument("--force", action="store_true")
parser.add_argument("--policy", type=Path)
def handle(self, *args, **options) -> None:
try:
service = BookStateService(
ModelRouter(providers_from_resources(), persist_requests=True)
)
action = options["action"]
if action == "create":
state = service.create(
work=self._work(options),
content=self._read_content(options, action),
actor=options["actor"],
)
self._write(self._state_payload(state))
return
if action in {"sync-run", "review-run"}:
run = self._run(options)
if action == "sync-run":
service.sync_run(run)
else:
service.review_run(run, model_hint=options.get("model"))
run.refresh_from_db()
self._write(self._run_payload(run))
return
state = self._state(options)
if action == "validate":
service.validate(state)
elif action == "review":
level = str(options.get("level") or "").strip()
if not level:
raise CommandError("review requires --level")
service.review(state, level=level, model_hint=options.get("model"))
elif action == "approve":
service.approve(
state,
actor=options["actor"],
force=options["force"],
notes=options["notes"],
)
elif action == "reject":
service.reject(state, actor=options["actor"], notes=options["notes"])
elif action == "revise":
revised = service.revise(
state,
content=self._read_content(options, action),
actor=options["actor"],
)
self._write(self._state_payload(revised))
return
elif action == "impact":
self._write({"book_state_id": str(state.id), "impact": service.impact(state)})
return
elif action == "start-run":
run = service.start_run(state, policy=self._read_policy(options))
self._write(self._run_payload(run))
return
elif action != "show":
raise CommandError(f"unsupported action: {action}")
state.refresh_from_db()
self._write(self._state_payload(state))
except CommandError:
raise
except (OSError, RuntimeError, TypeError, ValueError, ValidationError) as exc:
raise CommandError(str(exc)) from exc
@staticmethod
def _read_content(options: dict, action: str) -> dict[str, Any]:
path: Path | None = options.get("input")
if path is None:
raise CommandError(f"{action} requires --input")
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise CommandError("input must contain a JSON object")
return value
@staticmethod
def _work(options: dict) -> Work:
if not options.get("series_slug") or not options.get("work_slug"):
raise CommandError("create requires --series-slug and --work-slug")
work = Work.objects.filter(
series__slug=options["series_slug"], slug=options["work_slug"]
).first()
if work is None:
raise CommandError("work not found; register sources first")
return work
@staticmethod
def _read_policy(options: dict) -> dict[str, Any] | None:
path: Path | None = options.get("policy")
if path is None:
return None
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise CommandError("policy must contain a JSON object")
return value
@staticmethod
def _state(options: dict) -> BookStateVersion:
if not options.get("id"):
raise CommandError(f"{options['action']} requires --id")
state = (
BookStateVersion.objects.select_related("work__series", "parent")
.filter(id=options["id"])
.first()
)
if state is None:
raise CommandError("book state not found")
return state
@staticmethod
def _run(options: dict) -> BookRun:
if not options.get("run_id"):
raise CommandError("sync-run requires --run-id")
run = BookRun.objects.filter(id=options["run_id"]).first()
if run is None:
raise CommandError("book run not found")
return run
def _write(self, payload: dict[str, Any]) -> None:
self.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2))
@staticmethod
def _state_payload(state: BookStateVersion) -> dict[str, Any]:
return {
"id": str(state.id),
"series": state.work.series.slug,
"work": state.work.slug,
"parent_id": str(state.parent_id) if state.parent_id else None,
"version": state.version,
"status": state.status,
"content": state.content,
"sha256": state.sha256,
"validation": state.validation,
"reviews": state.reviews,
"change_summary": state.change_summary,
"context_pack": getattr(state, "context_pack", {}),
"context_pack_sha256": getattr(state, "context_pack_sha256", ""),
"artifact_uri": getattr(state, "artifact_uri", ""),
"json_artifact_uri": getattr(state, "json_artifact_uri", ""),
"markdown_artifact_uri": getattr(state, "markdown_artifact_uri", ""),
"approved_at": state.approved_at.isoformat() if state.approved_at else None,
"approved_by": state.approved_by,
"approval_notes": getattr(state, "approval_notes", ""),
"generation_metadata": getattr(state, "generation_metadata", {}),
"created_by": state.created_by,
"approval_forced": state.approval_forced,
"rejected_at": state.rejected_at.isoformat() if state.rejected_at else None,
"rejected_by": state.rejected_by,
"rejection_notes": state.rejection_notes,
"created_at": state.created_at.isoformat(),
"updated_at": state.updated_at.isoformat(),
}
@staticmethod
def _run_payload(run: BookRun) -> dict[str, Any]:
state_id = getattr(run, "state_id", None) or getattr(run, "book_state_id", None)
return {
"id": str(run.id),
"book_state_id": str(state_id) if state_id else None,
"status": run.status,
"policy": getattr(run, "policy", {}),
"reviews": run.reviews,
"current_chapter_key": getattr(run, "current_chapter_key", ""),
"progress": getattr(run, "progress", {}),
"failure_reason": getattr(run, "failure_reason", ""),
"started_at": run.started_at.isoformat() if run.started_at else None,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"created_at": run.created_at.isoformat(),
"updated_at": run.updated_at.isoformat(),
}

View file

@ -0,0 +1,160 @@
from __future__ import annotations
import json
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from control_plane.authoring.models import BookStateVersion, DocumentAuthority, SceneIdeation, Work
from control_plane.authoring.prompts import SCENE_IDEA_TYPES
from control_plane.authoring.standalone_scenes import (
SceneIdeationService,
export_scene_ideation_markdown,
)
from model_router.providers import providers_from_resources
from model_router.router import ModelRouter
class Command(BaseCommand):
help = "Propose cited scene ideas and select one into the standalone scene workflow."
def add_arguments(self, parser) -> None:
parser.add_argument("action", choices=["propose", "show", "export", "select"])
parser.add_argument("--id")
parser.add_argument("--series-slug")
parser.add_argument("--work-slug")
parser.add_argument("--target-book")
parser.add_argument("--book-state")
parser.add_argument("--chapter-key")
parser.add_argument("--focus", default="")
parser.add_argument("--candidate-count", type=int, default=10)
parser.add_argument(
"--scene-type",
action="append",
choices=SCENE_IDEA_TYPES,
)
parser.add_argument(
"--include-authority",
action="append",
choices=DocumentAuthority.values,
)
parser.add_argument("--pin-document", action="append", default=[])
parser.add_argument("--governing-document", action="append", default=[])
parser.add_argument("--candidate-id")
parser.add_argument("--target-words", type=int)
parser.add_argument("--model")
parser.add_argument("--output", type=Path)
parser.add_argument("--compact", action="store_true")
def handle(self, *args, **options) -> None:
action = options["action"]
try:
if action == "propose":
service = self._service()
work = self._work(options)
idea = service.propose(
work=work,
target_book=str(options.get("target_book") or ""),
focus=options["focus"],
candidate_count=options["candidate_count"],
scene_types=options["scene_type"],
authorities=options["include_authority"],
pinned_document_keys=options["pin_document"],
governing_document_keys=options["governing_document"],
detail_level="compact" if options["compact"] else "full",
model_hint=options["model"],
book_state=self._book_state(options),
)
self._write_idea(idea)
return
idea = self._idea(options)
if action == "export":
output = options.get("output")
if output is None:
raise CommandError("export requires --output")
export_scene_ideation_markdown(idea, output, compact=options["compact"])
self.stdout.write(str(output))
return
if action == "select":
service = self._service()
candidate_id = str(options.get("candidate_id") or "").strip()
if not candidate_id:
raise CommandError("select requires --candidate-id")
scene, created = service.select_candidate(
idea,
candidate_id=candidate_id,
target_words=options["target_words"],
book_chapter_key=options.get("chapter_key"),
)
idea.refresh_from_db()
self.stdout.write(
json.dumps(
{
"created": created,
"scene_id": str(scene.id),
"scene_status": scene.status,
"scene_title": scene.title,
"idea": self._payload(idea),
},
ensure_ascii=False,
indent=2,
)
)
return
self._write_idea(idea)
except (OSError, RuntimeError, TypeError, ValueError) as exc:
raise CommandError(str(exc)) from exc
@staticmethod
def _service() -> SceneIdeationService:
return SceneIdeationService(ModelRouter(providers_from_resources(), persist_requests=True))
def _work(self, options: dict) -> Work:
if not options.get("series_slug") or not options.get("work_slug"):
raise CommandError("propose requires --series-slug and --work-slug")
work = Work.objects.filter(
series__slug=options["series_slug"], slug=options["work_slug"]
).first()
if work is None:
raise CommandError("work not found; register sources first")
return work
def _idea(self, options: dict) -> SceneIdeation:
if not options.get("id"):
raise CommandError(f"{options['action']} requires --id")
idea = SceneIdeation.objects.select_related("work__series").filter(id=options["id"]).first()
if idea is None:
raise CommandError("scene ideation not found")
return idea
@staticmethod
def _book_state(options: dict) -> BookStateVersion | None:
state_id = options.get("book_state")
if not state_id:
return None
state = BookStateVersion.objects.filter(id=state_id).first()
if state is None:
raise CommandError("book state not found")
return state
def _write_idea(self, idea: SceneIdeation) -> None:
self.stdout.write(json.dumps(self._payload(idea), ensure_ascii=False, indent=2))
@staticmethod
def _payload(idea: SceneIdeation) -> dict:
return {
"id": str(idea.id),
"series": idea.work.series.slug,
"work": idea.work.slug,
"book_state_id": str(idea.book_state_id) if idea.book_state_id else None,
"target_book": idea.target_book,
"requested_scene_types": idea.requested_scene_types,
"focus": idea.focus,
"authorities": idea.authorities,
"context_pack_sha256": idea.context_pack_sha256,
"governing_document_keys": (idea.context_pack or {}).get("governing_document_keys")
or [],
"citations": (idea.context_pack or {}).get("citations") or [],
"candidates": idea.candidates,
"generation_metadata": idea.generation_metadata,
}

View file

@ -0,0 +1,203 @@
from __future__ import annotations
import json
from pathlib import Path
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand, CommandError
from control_plane.authoring.models import (
BookStateVersion,
DocumentAuthority,
StandaloneScene,
Work,
)
from control_plane.authoring.standalone_scenes import StandaloneSceneService
from model_router.providers import providers_from_resources
from model_router.router import ModelRouter
class Command(BaseCommand):
help = "Plan, write, review, and approve resumable standalone fiction scenes."
def add_arguments(self, parser) -> None:
parser.add_argument(
"action",
choices=[
"create",
"context",
"plan",
"approve-plan",
"write",
"review",
"approve",
"reject",
"run",
"show",
],
)
parser.add_argument("--id")
parser.add_argument("--series-slug")
parser.add_argument("--work-slug")
parser.add_argument("--title")
parser.add_argument("--brief", type=Path)
parser.add_argument("--target-words", type=int, default=1800)
parser.add_argument("--constraint", action="append", default=[])
parser.add_argument("--forbid", action="append", default=[])
parser.add_argument("--boundary", action="append", default=[])
parser.add_argument("--book-state")
parser.add_argument("--chapter-key")
parser.add_argument(
"--include-authority",
action="append",
choices=DocumentAuthority.values,
)
parser.add_argument("--pin-document", action="append", default=[])
parser.add_argument("--model")
parser.add_argument("--max-attempts", type=int, default=2)
parser.add_argument("--auto-approve-plan", action="store_true")
parser.add_argument("--actor", default="management_command")
parser.add_argument("--force", action="store_true")
def handle(self, *args, **options) -> None:
service = StandaloneSceneService(
ModelRouter(providers_from_resources(), persist_requests=True)
)
action = options["action"]
if action in {"create", "run"}:
scene = self._create(service, options)
if action == "create":
self._write_scene_summary(scene)
return
scene = service.plan(
scene,
authorities=options["include_authority"],
pinned_document_keys=options["pin_document"],
model_hint=options["model"],
)
if not options["auto_approve_plan"]:
self.stdout.write(
self.style.WARNING(
f"Scene {scene.id} is awaiting plan review. Run fiction_scene approve-plan."
)
)
self._write_scene_summary(scene)
return
service.approve_plan(scene)
service.write(
scene,
model_hint=options["model"],
max_attempts=options["max_attempts"],
)
service.review(scene, model_hint=options["model"])
self._write_scene_summary(scene)
return
scene = self._scene(options)
try:
if action == "plan":
service.plan(
scene,
authorities=options["include_authority"],
pinned_document_keys=options["pin_document"],
model_hint=options["model"],
)
elif action == "context":
service.prepare_context(
scene,
authorities=options["include_authority"],
pinned_document_keys=options["pin_document"],
)
elif action == "approve-plan":
service.approve_plan(scene)
elif action == "write":
service.write(
scene,
model_hint=options["model"],
max_attempts=options["max_attempts"],
)
elif action == "review":
service.review(scene, model_hint=options["model"])
elif action == "approve":
service.approve(scene, actor=options["actor"], force=options["force"])
elif action == "reject":
service.reject(scene, actor=options["actor"])
elif action != "show":
raise CommandError(f"unsupported action: {action}")
except (RuntimeError, ValueError) as exc:
raise CommandError(str(exc)) from exc
scene.refresh_from_db()
self._write_scene_summary(scene)
def _create(self, service: StandaloneSceneService, options: dict) -> StandaloneScene:
required = ["series_slug", "work_slug", "title", "brief"]
missing = [name for name in required if not options.get(name)]
if missing:
raise CommandError(
f"{options['action']} requires "
+ ", ".join(f"--{name.replace('_', '-')}" for name in missing)
)
work = Work.objects.filter(
series__slug=options["series_slug"], slug=options["work_slug"]
).first()
if work is None:
raise CommandError("work not found; register sources or import the story first")
brief_path: Path = options["brief"]
if not brief_path.exists():
raise CommandError(f"brief does not exist: {brief_path}")
book_state = None
if options.get("book_state"):
try:
book_state = BookStateVersion.objects.filter(
id=options["book_state"]
).first()
except ValidationError as exc:
raise CommandError(str(exc)) from exc
if book_state is None:
raise CommandError("book state not found")
try:
return service.create(
work=work,
title=options["title"],
brief=brief_path.read_text(encoding="utf-8"),
target_words=options["target_words"],
constraints=options["constraint"],
forbidden_events=options["forbid"],
boundary_constraints=options["boundary"],
book_state=book_state,
book_chapter_key=options.get("chapter_key"),
)
except (OSError, RuntimeError, TypeError, ValueError) as exc:
raise CommandError(str(exc)) from exc
def _scene(self, options: dict) -> StandaloneScene:
if not options.get("id"):
raise CommandError(f"{options['action']} requires --id")
scene = StandaloneScene.objects.select_related(
"work__series", "story__project", "book_state"
).filter(id=options["id"]).first()
if scene is None:
raise CommandError("scene not found")
return scene
def _write_scene_summary(self, scene: StandaloneScene) -> None:
payload = {
"id": str(scene.id),
"title": scene.title,
"scene_key": scene.scene_key,
"revision": scene.revision,
"status": scene.status,
"target_words": scene.target_words,
"word_count": scene.word_count,
"context_citations": scene.context_citations.count(),
"citations": (scene.context_pack or {}).get("citations") or [],
"context_pack_sha256": scene.context_pack_sha256,
"plan": scene.plan,
"review": scene.review,
"artifact_uri": scene.artifact_uri,
"review_artifact_uri": scene.review_artifact_uri,
"book_state_id": str(scene.book_state_id) if scene.book_state_id else None,
"chapter_key": scene.book_chapter_key,
"failure_reason": scene.failure_reason,
}
self.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2))

View file

@ -0,0 +1,126 @@
from __future__ import annotations
import re
from difflib import SequenceMatcher
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError
from control_plane.authoring.models import ChapterRevision, StoryProject
from graph.models import GraphRun, GraphRunStatus
class Command(BaseCommand):
help = "Run planned story chapters sequentially until completion or a blocking finding."
def add_arguments(self, parser) -> None:
parser.add_argument("--slug", required=True)
parser.add_argument("--from-chapter", type=int, required=True)
parser.add_argument("--through-chapter", type=int, required=True)
def handle(self, *args, **options) -> None:
story = StoryProject.objects.get(slug=options["slug"])
for number in range(options["from_chapter"], options["through_chapter"] + 1):
chapter = story.chapters.get(number=number)
if chapter.current_revision_id and chapter.status == "APPROVED":
self.stdout.write(f"chapter={number} already approved")
continue
self.stdout.write(f"chapter={number} starting", ending="\n")
call_command(
"story_workflow",
"start",
slug=story.slug,
chapter=number,
fresh=True,
supersede_active=True,
)
graph_run = (
GraphRun.objects.filter(project=story.project)
.order_by("-started_at", "-id")
.first()
)
if graph_run is None or graph_run.current_node != "approve_plan":
raise CommandError(f"chapter {number} did not reach plan approval")
call_command(
"story_workflow",
"resume",
graph_run=graph_run.id,
decision="approve",
)
graph_run.refresh_from_db()
if graph_run.status != GraphRunStatus.PAUSED or graph_run.current_node != "approve_chapter":
raise CommandError(f"chapter {number} did not reach chapter approval")
revision = ChapterRevision.objects.get(
id=graph_run.metadata["current_revision_id"]
)
document = revision.state_document
blocking = revision.findings.filter(
status="OPEN", severity__in=["HIGH", "CRITICAL"]
).count()
if document.verdict != "PASS" and self._repair_evidence(revision):
call_command(
"story_candidate_step",
"audit",
chapter=number,
revision=revision.revision,
)
revision.refresh_from_db()
document.refresh_from_db()
blocking = revision.findings.filter(
status="OPEN", severity__in=["HIGH", "CRITICAL"]
).count()
if document.status != "VALIDATED" or document.verdict != "PASS" or blocking:
raise CommandError(
f"chapter {number} blocked: revision={revision.revision} "
f"state={document.status}/{document.verdict} findings={blocking} "
f"graph_run={graph_run.id}"
)
call_command(
"story_workflow",
"resume",
graph_run=graph_run.id,
decision="approve",
)
self.stdout.write(
self.style.SUCCESS(
f"chapter={number} committed revision={revision.revision} "
f"graph_run={graph_run.id}"
)
)
def _repair_evidence(self, revision: ChapterRevision) -> bool:
findings = revision.findings.filter(
status="OPEN", severity__in=["HIGH", "CRITICAL"], review_kind="state_contract"
)
sequences = []
for finding in findings:
match = re.search(r"State change (\d+)", finding.description)
if match is None or "no exact supporting quotation" not in finding.description:
return False
sequences.append(int(match.group(1)))
if not sequences:
return False
lines = [line.strip() for line in revision.prose.splitlines() if line.strip()]
for sequence in sequences:
change = revision.state_document.changes.get(sequence=sequence)
best = max(
lines,
key=lambda line: SequenceMatcher(None, change.evidence_quote, line).ratio(),
)
score = SequenceMatcher(None, change.evidence_quote, best).ratio()
if score < 0.45:
return False
call_command(
"story_candidate_step",
"correct-evidence",
chapter=revision.chapter.number,
revision=revision.revision,
change_sequence=sequence,
evidence=best,
)
return True

View file

@ -0,0 +1,49 @@
from __future__ import annotations
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError
from control_plane.authoring.models import StoryProject
class Command(BaseCommand):
help = "Supervise a sequential book run and retry blocked chapters with fresh generations."
def add_arguments(self, parser) -> None:
parser.add_argument("--slug", required=True)
parser.add_argument("--through-chapter", type=int, required=True)
parser.add_argument("--attempts-per-chapter", type=int, default=3)
def handle(self, *args, **options) -> None:
story = StoryProject.objects.get(slug=options["slug"])
failures: dict[int, int] = {}
through = options["through_chapter"]
while True:
chapter = (
story.chapters.filter(number__lte=through)
.exclude(status="APPROVED")
.order_by("number")
.first()
)
if chapter is None:
self.stdout.write(self.style.SUCCESS("all planned chapters approved"))
return
try:
call_command(
"story_book_run",
slug=story.slug,
from_chapter=chapter.number,
through_chapter=through,
)
except Exception as exc:
failures[chapter.number] = failures.get(chapter.number, 0) + 1
attempt = failures[chapter.number]
self.stderr.write(
f"chapter={chapter.number} attempt={attempt} blocked: {exc}"
)
if attempt >= options["attempts_per_chapter"]:
raise CommandError(
f"chapter {chapter.number} remained blocked after {attempt} attempts"
) from exc

View file

@ -0,0 +1,319 @@
from __future__ import annotations
import json
import time
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Max
from django.utils import timezone
from control_plane.authoring.models import (
ChapterRevision,
FindingStatus,
RevisionStatus,
StateChangeStatus,
StateDocumentStatus,
)
from control_plane.authoring.services import DjangoStoryWorkflowServices
from control_plane.resources.models import ModelRequest
from graph.models import GraphApproval, GraphApprovalStatus, GraphRun
from model_router.providers import providers_from_resources
from model_router.router import ModelRouter
class Command(BaseCommand):
help = "Run one visible step for a pre-generated story candidate."
def add_arguments(self, parser) -> None:
parser.add_argument(
"action",
choices=[
"import",
"inspect",
"extract",
"audit",
"patch",
"verify",
"approve",
"correct-evidence",
"correct-plan-beat",
"correct-state-value",
"rebase-context",
"quality",
"final-extract",
],
)
parser.add_argument("--revision", type=int, required=True)
parser.add_argument("--chapter", type=int, default=2)
parser.add_argument("--graph-run", type=int)
parser.add_argument("--summary-only", action="store_true")
parser.add_argument("--change-sequence", type=int)
parser.add_argument("--evidence")
parser.add_argument("--scene-number", type=int)
parser.add_argument("--beat-number", type=int)
parser.add_argument("--beat-text")
parser.add_argument("--previous-value")
parser.add_argument("--artifact", type=Path)
def handle(self, *args, **options) -> None:
action = options["action"]
revision = ChapterRevision.objects.filter(
revision=options["revision"], chapter__number=options["chapter"]
).first()
if revision is None:
raise CommandError("revision not found")
if action == "inspect":
document = getattr(revision, "state_document", None)
graph_run = GraphRun.objects.filter(id=options["graph_run"]).first()
requests = []
if graph_run and graph_run.started_at:
requests = list(
ModelRequest.objects.filter(
project=revision.chapter.story.project,
created_at__gte=graph_run.started_at,
)
.order_by("created_at")
.values("logical_role", "model", "status", "latency_ms", "created_at")
)
self.stdout.write(
json.dumps(
{
"id": str(revision.id),
"chapter": revision.chapter.number,
"revision": revision.revision,
"status": revision.status,
"word_count": len(revision.prose.split()),
"scene_plan": None if options["summary_only"] else revision.scene_plan,
"state_status": document.status if document else None,
"state_verdict": document.verdict if document else None,
"state_document": (
None
if options["summary_only"] or document is None
else {
"start_state": document.start_state,
"observed_state": document.observed_state,
"proposed_delta": document.proposed_delta,
"coverage": document.coverage,
}
),
"artifact_uri": revision.artifact_uri,
"generation_metadata": revision.generation_metadata,
"findings": list(
revision.chapter.revisions.filter(
id__in=[revision.id, revision.parent_id]
)
.order_by("findings__created_at")
.values(
"findings__id",
"findings__review_kind",
"findings__severity",
"findings__category",
"findings__description",
"findings__suggested_revision",
"findings__status",
)
),
"model_requests": requests,
},
ensure_ascii=False,
indent=2,
default=str,
)
)
return
if action == "correct-evidence":
evidence = str(options["evidence"] or "").strip()
sequence = options["change_sequence"]
if sequence is None or revision.prose.count(evidence) != 1:
raise CommandError("evidence correction must be one unique exact prose substring")
document = revision.state_document
change = document.changes.get(sequence=sequence)
change.evidence_quote = evidence
change.status = StateChangeStatus.PROPOSED
change.metadata = {**change.metadata, "evidence_corrected_by": "human"}
change.save(update_fields=["evidence_quote", "status", "metadata"])
for item in document.proposed_delta:
if isinstance(item, dict) and item.get("sequence") == sequence:
item["evidence_quote"] = evidence
document.status = StateDocumentStatus.EXTRACTED
document.verdict = ""
document.validated_at = None
document.save(
update_fields=["proposed_delta", "status", "verdict", "validated_at", "updated_at"]
)
revision.findings.filter(
review_kind="state_contract",
category="state_change",
status=FindingStatus.OPEN,
).update(status=FindingStatus.RESOLVED)
self.stdout.write(
self.style.SUCCESS(
f"corrected revision={revision.revision} change_sequence={sequence}"
)
)
return
if action == "correct-plan-beat":
scene_number = options["scene_number"]
beat_number = options["beat_number"]
beat_text = str(options["beat_text"] or "").strip()
if not scene_number or not beat_number or not beat_text:
raise CommandError("scene number, beat number, and beat text are required")
plan = revision.scene_plan
scene = next(
(item for item in plan.get("scenes") or [] if item.get("number") == scene_number),
None,
)
if scene is None or beat_number > len(scene.get("beats") or []):
raise CommandError("scene or beat not found")
scene["beats"][beat_number - 1]["text"] = beat_text
revision.scene_plan = plan
revision.generation_metadata = {
**revision.generation_metadata,
"plan_correction": {
"scene": scene_number,
"beat": beat_number,
"source": "human",
},
}
revision.save(update_fields=["scene_plan", "generation_metadata", "updated_at"])
services = DjangoStoryWorkflowServices(ModelRouter(providers_from_resources()))
services._ensure_contract(revision)
self.stdout.write(
self.style.SUCCESS(
f"corrected plan revision={revision.revision} scene={scene_number} beat={beat_number}"
)
)
return
if action == "correct-state-value":
sequence = options["change_sequence"]
if sequence is None:
raise CommandError("change sequence is required")
previous_value = options["previous_value"]
try:
previous_value = json.loads(previous_value)
except (json.JSONDecodeError, TypeError):
pass
document = revision.state_document
change = document.changes.get(sequence=sequence)
change.previous_value = previous_value
change.status = StateChangeStatus.PROPOSED
change.metadata = {**change.metadata, "previous_value_corrected_by": "human"}
change.save(update_fields=["previous_value", "status", "metadata"])
for item in document.proposed_delta:
if isinstance(item, dict) and item.get("sequence") == sequence:
item["previous_value"] = previous_value
document.status = StateDocumentStatus.EXTRACTED
document.verdict = ""
document.validated_at = None
document.save(
update_fields=["proposed_delta", "status", "verdict", "validated_at", "updated_at"]
)
self.stdout.write(
self.style.SUCCESS(
f"corrected previous value revision={revision.revision} change_sequence={sequence}"
)
)
return
if action == "import":
artifact = options["artifact"]
if artifact is None or not artifact.exists():
raise CommandError("candidate artifact not found")
next_number = (
revision.chapter.revisions.aggregate(value=Max("revision"))["value"] or 0
) + 1
imported = ChapterRevision.objects.create(
chapter=revision.chapter,
revision=next_number,
status=RevisionStatus.REVIEW,
parent=revision,
source_revision=revision.source_revision or revision,
story_bible=revision.story_bible,
outline=revision.outline,
context_snapshot=revision.context_snapshot,
scene_plan=revision.scene_plan,
prose=artifact.read_text(encoding="utf-8"),
artifact_uri=str(artifact),
generation_metadata={
"draft_mode": "full_chapter_terra",
"source_artifact": str(artifact),
},
)
self.stdout.write(self.style.SUCCESS(f"imported revision={imported.revision} id={imported.id}"))
return
services = DjangoStoryWorkflowServices(ModelRouter(providers_from_resources()))
state = {
"revision_id": str(revision.id),
"context_snapshot_id": str(revision.context_snapshot_id),
"story_id": str(revision.chapter.story_id),
}
started = time.monotonic()
if action == "rebase-context":
result = services.build_context(state)
elif action == "quality":
result = services.quality_review(state)
elif action == "final-extract":
result = services.extract_final_state(state)
elif action == "approve":
document = revision.state_document
if document.status not in [
StateDocumentStatus.VALIDATED,
StateDocumentStatus.COMMITTED,
] or document.verdict != "PASS":
raise CommandError("candidate has not passed state validation")
if revision.findings.filter(
status="OPEN", severity__in=["HIGH", "CRITICAL"]
).exists():
raise CommandError("candidate has unresolved blocking findings")
graph_run = GraphRun.objects.filter(project=revision.chapter.story.project).order_by("-id").first()
if graph_run is None:
raise CommandError("no graph run is available for the approval audit record")
approval, _ = GraphApproval.objects.get_or_create(
graph_run=graph_run,
reason=f"STORY_CHAPTER_APPROVAL:{revision.id}",
defaults={
"status": GraphApprovalStatus.APPROVED,
"payload": {"revision_id": str(revision.id), "action": "approve"},
"requested_by": "story_candidate_step",
"decided_by": "human",
"decided_at": timezone.now(),
},
)
commit_result = (
{"canon_snapshot_id": "already_committed"}
if revision.chapter.current_revision_id == revision.id
else services.commit_chapter(state)
)
result = {
**commit_result,
"approval_id": approval.id,
"export_uri": services.publish_story(state),
}
elif action == "extract":
result = services.extract_continuity(state)
elif action == "audit":
result = services.finalize_combined_audit(state)
elif action == "patch":
decision = services.decide_patch(state)
result = decision
if decision["patch_decision"] == "patch":
result = {**decision, **services.apply_automatic_patch({**state, **decision})}
else:
metadata = revision.generation_metadata or {}
source_id = metadata.get("base_revision_id")
if not source_id:
raise CommandError("revision is not a bounded patch candidate")
source = ChapterRevision.objects.get(id=source_id)
services.extract_continuity(state)
result = services.verify_patch(
{
**state,
"patch_source_revision_id": str(source.id),
"patch_finding_ids": metadata.get("finding_ids") or [],
"changed_passages": metadata.get("changed_passages") or [],
}
)
self.stdout.write(
f"completed action={action} elapsed_seconds={time.monotonic() - started:.1f}"
)
self.stdout.write(json.dumps(result, ensure_ascii=False, indent=2, default=str))

View file

@ -0,0 +1,107 @@
from __future__ import annotations
import json
import time
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from control_plane.authoring.models import Chapter, ChapterRevision
from control_plane.authoring.prompts import (
DEFAULT_DRAFT_SYSTEM,
DEFAULT_FULL_CHAPTER_DRAFT_TEMPLATE,
)
from control_plane.authoring.services import DjangoStoryWorkflowServices, compact_chapter_plan
from control_plane.authoring.streaming import atomic_write_text, word_count
from model_router.providers import providers_from_resources
from model_router.router import ModelCapability, ModelRequestContract, ModelRouter
class Command(BaseCommand):
help = "Render or generate one isolated full chapter with Terra and no retries."
def add_arguments(self, parser) -> None:
parser.add_argument("--revision", type=int, required=True)
parser.add_argument("--label", default="terra-full-chapter")
parser.add_argument("--generate", action="store_true")
def handle(self, *args, **options) -> None:
revision = (
ChapterRevision.objects.select_related("chapter__story__project", "context_snapshot")
.filter(revision=options["revision"], chapter__number=2)
.first()
)
if revision is None or revision.context_snapshot is None:
raise CommandError("revision or generation context not found")
previous = (
Chapter.objects.select_related("current_revision")
.filter(story=revision.chapter.story, number=revision.chapter.number - 1)
.first()
)
if previous is None or previous.current_revision is None or not previous.current_revision.prose:
raise CommandError("approved previous chapter is unavailable")
plan = compact_chapter_plan(revision.scene_plan)
services = DjangoStoryWorkflowServices(ModelRouter({}))
prompt = services._render_prompt(
"STORY_FULL_CHAPTER_PROSE",
DEFAULT_DRAFT_SYSTEM,
DEFAULT_FULL_CHAPTER_DRAFT_TEMPLATE,
chapter_number=revision.chapter.number,
chapter_title=revision.chapter.title,
source_chapter=previous.current_revision.prose,
structured_canon=json.dumps(
revision.context_snapshot.content.get("structured_canon") or {},
ensure_ascii=False,
indent=2,
),
scene_plan=json.dumps(plan, ensure_ascii=False, indent=2),
)
source_beats = sum(
1
for scene in revision.scene_plan.get("scenes") or []
for beat in scene.get("beats") or []
if not isinstance(beat, dict) or beat.get("required", True)
)
consolidated_beats = sum(len(scene.get("beats") or []) for scene in plan["scenes"])
self.stdout.write(
f"scenes={len(plan['scenes'])} source_beats={source_beats} "
f"consolidated_beats={consolidated_beats} prompt_chars={len(prompt)} "
f"estimated_tokens={len(prompt) // 4}"
)
if not options["generate"]:
return
provider = providers_from_resources().get("terra")
if provider is None:
raise CommandError("Terra provider is unavailable")
provider.resource.config["timeout_seconds"] = 240
output = Path(revision.chapter.story.artifact_root) / "probes" / (
f"chapter-{revision.chapter.number:02d}-r{revision.revision}-{options['label']}.partial.md"
)
if output.exists():
raise CommandError(f"probe artifact already exists: {output}")
started = time.monotonic()
response = ModelRouter({"terra": provider}).complete(
ModelRequestContract(
purpose=ModelCapability.STORY_PROSE,
prompt=prompt,
model_hint="terra",
token_budget=12000,
project=revision.chapter.story.project,
)
)
prose, marker, _ = response.content.partition("[[END_OF_CHAPTER]]")
prose = prose.strip()
words = word_count(prose)
if not marker:
raise CommandError("Terra omitted [[END_OF_CHAPTER]]")
if words < 4000:
raise CommandError(f"chapter is too short: {words} words")
if words > 8000:
raise CommandError(f"chapter is too long: {words} words")
atomic_write_text(output, prose)
self.stdout.write(
self.style.SUCCESS(
f"completed elapsed_seconds={time.monotonic() - started:.1f} "
f"words={words} artifact={output}"
)
)

View file

@ -0,0 +1,147 @@
from __future__ import annotations
import json
import time
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from control_plane.authoring.models import Chapter, ChapterRevision
from control_plane.authoring.prompts import DEFAULT_SCENE_DRAFT_SYSTEM, DEFAULT_SCENE_DRAFT_TEMPLATE
from control_plane.authoring.services import DjangoStoryWorkflowServices, scene_draft_packet
from control_plane.authoring.streaming import atomic_write_text, word_count
from model_router.providers import providers_from_resources
from model_router.router import ModelCapability, ModelRequestContract, ModelRouter
class Command(BaseCommand):
help = "Render or generate exactly one isolated story scene with no retries."
def add_arguments(self, parser) -> None:
parser.add_argument("--revision", type=int, required=True)
parser.add_argument("--scene", type=int, required=True)
parser.add_argument("--style-revision", type=int)
parser.add_argument(
"--model",
choices=["qwen", "luna", "sol", "terra", "gpt54", "gpt55"],
default="qwen",
)
parser.add_argument("--thinking-budget", type=int, default=0)
parser.add_argument("--label", default="probe")
parser.add_argument("--generate", action="store_true")
def handle(self, *args, **options) -> None:
revision = (
ChapterRevision.objects.select_related("chapter__story__project", "context_snapshot")
.filter(revision=options["revision"], chapter__number=2)
.first()
)
if revision is None:
raise CommandError("revision not found")
style = None
if options["style_revision"]:
style = ChapterRevision.objects.filter(
chapter=revision.chapter, revision=options["style_revision"]
).first()
if style is None or not style.prose:
raise CommandError("style revision not found or empty")
previous = (
Chapter.objects.select_related("current_revision")
.filter(story=revision.chapter.story, number=revision.chapter.number - 1)
.first()
)
if previous is None or previous.current_revision is None or not previous.current_revision.prose:
raise CommandError("approved previous chapter is unavailable")
scene = next(
(item for item in revision.scene_plan.get("scenes") or [] if int(item.get("number") or 0) == options["scene"]),
None,
)
if scene is None:
raise CommandError("scene not found")
packet = scene_draft_packet(revision.scene_plan, scene)
context = revision.context_snapshot.content
draft_context = {
"chapter": context["chapter"],
"structured_canon": context["structured_canon"],
"previous_chapter_tail": " ".join(context["previous_chapter_tail"].split()[-350:]),
}
services = DjangoStoryWorkflowServices(ModelRouter({}))
prompt = services._render_prompt(
"STORY_SCENE_PROSE",
DEFAULT_SCENE_DRAFT_SYSTEM,
DEFAULT_SCENE_DRAFT_TEMPLATE,
chapter_number=revision.chapter.number,
chapter_title=revision.chapter.title,
scene_number=options["scene"],
context=json.dumps(draft_context, ensure_ascii=False, indent=2),
scene_plan=json.dumps(packet["chapter_scope"], ensure_ascii=False, indent=2),
scene=json.dumps(packet["scene"], ensure_ascii=False, indent=2),
source_chapter=previous.current_revision.prose,
style_excerpt=(" ".join(style.prose.split()[:350]) if style else "[none]"),
previous_tail="[chapter opening]",
target_words=packet["target_words"],
boundary_constraints=(
"Do not decide to sell the waystone. Do not introduce a buyer, bid, price, deduction, sale term, "
"or payment. Those belong to later scenes. End with the sealed transfer beginning."
),
)
self.stdout.write(
f"scene={options['scene']} required_beats={len(packet['scene']['beats'])} "
f"target_words={packet['target_words']} prompt_chars={len(prompt)} "
f"estimated_tokens={len(prompt) // 4}"
)
self.stdout.write(json.dumps(packet["scene"], ensure_ascii=False, indent=2))
if not options["generate"]:
return
providers = providers_from_resources()
model = options["model"]
provider = providers.get("luna" if model in {"gpt54", "gpt55"} else model)
if provider is None:
raise CommandError(f"{model} provider is unavailable")
if model in {"gpt54", "gpt55"}:
config = dict(provider.resource.config)
model_name = "gpt-5.4" if model == "gpt54" else "gpt-5.5"
config["command"] = f"/home/daniel/.opencode/bin/opencode run --model openai/{model_name}"
provider.resource.config = config
provider.resource.config["timeout_seconds"] = 180
if model == "qwen":
provider.resource.config["retry_attempts"] = 1
if options["thinking_budget"]:
extra_body = dict(provider.resource.config.get("extra_body") or {})
chat_kwargs = dict(extra_body.get("chat_template_kwargs") or {})
chat_kwargs["enable_thinking"] = True
extra_body["chat_template_kwargs"] = chat_kwargs
provider.resource.config["extra_body"] = extra_body
router = ModelRouter({model: provider})
output = Path(revision.chapter.story.artifact_root) / "probes" / (
f"chapter-{revision.chapter.number:02d}-r{revision.revision}-scene-{options['scene']:02d}-"
f"{options['label']}.partial.md"
)
if output.exists():
raise CommandError(f"probe artifact already exists: {output}")
started = time.monotonic()
response = router.complete(
ModelRequestContract(
purpose=ModelCapability.STORY_PROSE,
prompt=prompt,
model_hint=model,
token_budget=4000 + max(0, options["thinking_budget"]),
project=revision.chapter.story.project,
)
)
prose, marker, _ = response.content.partition("[[END_OF_SCENE]]")
prose = prose.strip()
words = word_count(prose)
if not marker:
raise CommandError("model response omitted [[END_OF_SCENE]]")
if words < max(500, int(packet["target_words"] * 0.6)):
raise CommandError(f"scene is too short: {words} words")
if words > max(2500, int(packet["target_words"] * 1.8)):
raise CommandError(f"scene is too long: {words} words")
atomic_write_text(output, prose)
self.stdout.write(
self.style.SUCCESS(
f"completed model={model} elapsed_seconds={time.monotonic() - started:.1f} "
f"words={words} artifact={output}"
)
)

View file

@ -0,0 +1,90 @@
from __future__ import annotations
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from control_plane.authoring.models import (
DocumentAuthority,
DocumentType,
Series,
Work,
WorkType,
)
from control_plane.authoring.sources import discover_source_paths, inspect_source, register_source
class Command(BaseCommand):
help = "Register immutable, authority-labelled story source documents and passages."
def add_arguments(self, parser) -> None:
parser.add_argument("action", choices=["register"])
parser.add_argument("--root", type=Path, required=True)
parser.add_argument("--series-slug", required=True)
parser.add_argument("--series-title", required=True)
parser.add_argument("--work-slug", required=True)
parser.add_argument("--work-title", required=True)
parser.add_argument("--work-type", choices=WorkType.values, default=WorkType.BOOK)
parser.add_argument("--authority", choices=DocumentAuthority.values, required=True)
parser.add_argument(
"--document-type", choices=DocumentType.values, default=DocumentType.OTHER
)
parser.add_argument("--include-glob", action="append", default=[])
parser.add_argument("--dry-run", action="store_true")
def handle(self, *args, **options) -> None:
root: Path = options["root"]
if not root.exists():
raise CommandError(f"source root does not exist: {root}")
paths = discover_source_paths(root, options["include_glob"])
if not paths:
raise CommandError(f"no supported UTF-8 source files found under {root}")
if options["dry_run"]:
for path in paths:
result = inspect_source(path, root)
self.stdout.write(
f"DRY-RUN {result.logical_key} sha256={result.source_sha256} "
f"passages={result.passage_count} authority={options['authority']}"
)
self.stdout.write(
self.style.SUCCESS(f"Discovered {len(paths)} source files; no changes made.")
)
return
series, _ = Series.objects.get_or_create(
slug=options["series_slug"], defaults={"title": options["series_title"]}
)
work, _ = Work.objects.get_or_create(
series=series,
slug=options["work_slug"],
defaults={
"title": options["work_title"],
"work_type": options["work_type"],
},
)
counts = {"created": 0, "versioned": 0, "unchanged": 0}
for path in paths:
try:
result = register_source(
work=work,
path=path,
root=root,
authority=options["authority"],
document_type=options["document_type"],
)
except UnicodeDecodeError as exc:
raise CommandError(f"source is not valid UTF-8: {path}") from exc
except ValueError as exc:
raise CommandError(str(exc)) from exc
counts[result.status] += 1
self.stdout.write(
f"{result.status.upper()} {result.logical_key} v{result.version} "
f"passages={result.passage_count}"
)
self.stdout.write(
self.style.SUCCESS(
f"Registered {len(paths)} files: {counts['created']} created, "
f"{counts['versioned']} versioned, {counts['unchanged']} unchanged."
)
)

View file

@ -0,0 +1,109 @@
from __future__ import annotations
import json
from django.core.management.base import BaseCommand, CommandError
from control_plane.authoring.models import ChapterRevision, ChapterStateDocument, StateChange
from control_plane.authoring.services import DjangoStoryWorkflowServices
from graph.models import GraphApproval, GraphApprovalStatus
from model_router.providers import providers_from_resources
from model_router.router import ModelRouter
class Command(BaseCommand):
help = "Build, inspect, or query the immutable story state ledger."
def add_arguments(self, parser) -> None:
parser.add_argument("action", choices=["build", "show", "history"])
parser.add_argument("--revision")
parser.add_argument("--slug")
parser.add_argument("--entity")
parser.add_argument("--reuse-extraction", action="store_true")
def handle(self, *args, **options) -> None:
if options["action"] == "history":
self._history(options)
return
if not options["revision"]:
raise CommandError("build and show require --revision")
revision = ChapterRevision.objects.select_related("chapter__story").get(
id=options["revision"]
)
if options["action"] == "show":
self._show(revision)
return
services = DjangoStoryWorkflowServices(
ModelRouter(providers_from_resources(), persist_requests=True)
)
state = {
"revision_id": str(revision.id),
"story_id": str(revision.chapter.story_id),
"context_snapshot_id": str(revision.context_snapshot_id or ""),
}
if not options["reuse_extraction"]:
services.extract_continuity(state)
elif not ChapterStateDocument.objects.filter(revision=revision).exists():
raise CommandError("--reuse-extraction requested but no state document exists")
result = services.judge_state_contract(state)
payload = services.state_approval_payload(state)
approval = GraphApproval.objects.filter(
reason=f"STORY_CHAPTER_APPROVAL:{revision.id}",
status=GraphApprovalStatus.PENDING,
).first()
if approval is not None:
approval.payload = {**approval.payload, **payload}
approval.save(update_fields=["payload", "updated_at"])
self.stdout.write(json.dumps({**result, **payload}, ensure_ascii=False, indent=2))
def _show(self, revision: ChapterRevision) -> None:
document = ChapterStateDocument.objects.get(revision=revision)
self.stdout.write(
json.dumps(
{
"id": str(document.id),
"status": document.status,
"verdict": document.verdict,
"coverage": document.coverage,
"observed_state": document.observed_state,
"proposed_delta": document.proposed_delta,
"json_artifact_uri": document.json_artifact_uri,
"markdown_artifact_uri": document.markdown_artifact_uri,
},
ensure_ascii=False,
indent=2,
)
)
def _history(self, options: dict) -> None:
if not options.get("slug") or not options.get("entity"):
raise CommandError("history requires --slug and --entity")
changes = StateChange.objects.filter(
story__slug=options["slug"],
entity__entity_key=options["entity"],
status="COMMITTED",
).select_related("revision__chapter", "related_entity")
self.stdout.write(
json.dumps(
[
{
"chapter": change.effective_chapter,
"revision_id": str(change.revision_id),
"sequence": change.sequence,
"change_type": change.change_type,
"predicate": change.predicate,
"operation": change.operation,
"previous_value": change.previous_value,
"new_value": change.new_value,
"related_entity": (
change.related_entity.entity_key if change.related_entity else None
),
"evidence_quote": change.evidence_quote,
"evidence_location": change.evidence_location,
}
for change in changes.order_by("effective_chapter", "sequence")
],
ensure_ascii=False,
indent=2,
)
)

View file

@ -0,0 +1,115 @@
from __future__ import annotations
import json
import time
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from control_plane.authoring.models import ChapterRevision
from control_plane.authoring.services import (
apply_exact_edits,
deterministic_temporal_findings,
)
from control_plane.authoring.streaming import atomic_write_text
from model_router.providers import extract_json_object, providers_from_resources
from model_router.router import ModelCapability, ModelRequestContract, ModelRouter
class Command(BaseCommand):
help = "Run one Luna temporal-knowledge check without modifying authoring state."
def add_arguments(self, parser) -> None:
parser.add_argument("--revision", type=int, required=True)
parser.add_argument("--artifact", type=Path, required=True)
parser.add_argument("--patch-output", type=Path)
parser.add_argument("--deterministic-only", action="store_true")
def handle(self, *args, **options) -> None:
revision = ChapterRevision.objects.filter(
revision=options["revision"], chapter__number=2
).first()
if revision is None:
raise CommandError("revision not found")
artifact = options["artifact"]
if not artifact.exists():
raise CommandError(f"artifact not found: {artifact}")
prose = artifact.read_text(encoding="utf-8")
deterministic_findings = deterministic_temporal_findings(prose, revision.scene_plan)
for finding in deterministic_findings:
finding["replacement"] = finding.pop("suggested_revision")
finding["reason"] = finding.pop("description")
prompt = f"""You are a narrow temporal-continuity checker. Return strict JSON only.
Check the chapter for statements made before the winning bid and settlement that incorrectly treat Corin's
future wealth, exact payment, or exact sale proceeds as already known or received. Do not report ordinary
hopes, estimates, conditional language, or facts established after settlement. Return at most four findings.
Every evidence_quote must copy the complete sentence or paragraph containing the problem and must occur
exactly once in the prose; never return an isolated word or short phrase. Every replacement must be a minimal
local correction that preserves voice and does not introduce a precise result before it is known.
Return:
{{"findings":[{{"category":"premature_knowledge","evidence_quote":"", "replacement":"", "reason":""}}]}}
Approved plan:
{json.dumps(revision.scene_plan, ensure_ascii=False, indent=2)}
Chapter prose:
{prose}
"""
started = time.monotonic()
findings = []
if not options["deterministic_only"]:
provider = providers_from_resources().get("luna")
if provider is None:
raise CommandError("Luna provider is unavailable")
provider.resource.config["timeout_seconds"] = 180
response = ModelRouter({"luna": provider}).complete(
ModelRequestContract(
purpose=ModelCapability.STORY_CONTINUITY,
prompt=prompt,
model_hint="luna",
token_budget=2500,
project=revision.chapter.story.project,
)
)
result = extract_json_object(response.content)
findings = result.get("findings") or []
for finding in findings:
evidence = str(finding.get("evidence_quote") or "")
replacement = str(finding.get("replacement") or "")
if not evidence or prose.count(evidence) != 1:
raise CommandError(
"Luna returned missing or non-unique evidence: "
+ json.dumps(result, ensure_ascii=False)
)
if not replacement:
raise CommandError("Luna returned an empty replacement")
finding["source"] = "luna"
combined = list(deterministic_findings)
occupied = [
(prose.index(item["evidence_quote"]), prose.index(item["evidence_quote"]) + len(item["evidence_quote"]))
for item in combined
]
for finding in findings:
start = prose.index(finding["evidence_quote"])
end = start + len(finding["evidence_quote"])
if any(start < occupied_end and occupied_start < end for occupied_start, occupied_end in occupied):
continue
combined.append(finding)
occupied.append((start, end))
self.stdout.write(
f"completed elapsed_seconds={time.monotonic() - started:.1f} findings={len(combined)}"
)
self.stdout.write(json.dumps({"findings": combined}, ensure_ascii=False, indent=2))
if options["patch_output"]:
patched = apply_exact_edits(
prose,
[
{"old_text": item["evidence_quote"], "new_text": item["replacement"]}
for item in combined
],
max_change_ratio=0.01,
)
atomic_write_text(options["patch_output"], patched)
self.stdout.write(self.style.SUCCESS(f"patched artifact={options['patch_output']}"))

View file

@ -0,0 +1,313 @@
from __future__ import annotations
import json
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Max
from django.utils import timezone
from django.utils.text import slugify
from control_plane.authoring.checkpoints import open_story_checkpointer
from control_plane.authoring.models import (
CanonSnapshot,
Chapter,
ChapterContract,
ChapterRevision,
ChapterStateDocument,
ChapterStatus,
OutlineVersion,
RevisionStatus,
Series,
StateDocumentStatus,
StoryBibleVersion,
StoryProject,
StoryStatus,
Work,
text_sha256,
)
from control_plane.authoring.runner import StoryWorkflowRunner
from control_plane.authoring.services import DjangoStoryWorkflowServices
from control_plane.authoring.workflow import build_story_workflow
from control_plane.projects.models import Project
from graph.models import GraphApprovalStatus, GraphRun, GraphRunStatus
from model_router.providers import providers_from_resources
from model_router.router import ModelRouter
class Command(BaseCommand):
help = "Import, start, or resume a checkpointed story-authoring workflow."
def add_arguments(self, parser) -> None:
parser.add_argument("action", choices=["import", "start", "resume"])
parser.add_argument("--slug")
parser.add_argument("--title")
parser.add_argument("--series", default="")
parser.add_argument("--brief", type=Path)
parser.add_argument("--plan", type=Path)
parser.add_argument("--source-dir", type=Path)
parser.add_argument("--source", type=Path)
parser.add_argument("--artifact-root", type=Path)
parser.add_argument("--locked-through", type=int, default=1)
parser.add_argument("--chapter", type=int)
parser.add_argument("--graph-run", type=int)
parser.add_argument(
"--decision", choices=["approve", "request_revision", "reject", "retry"]
)
parser.add_argument("--notes", default="")
parser.add_argument("--fresh", action="store_true")
parser.add_argument("--supersede-active", action="store_true")
def handle(self, *args, **options) -> None:
action = options["action"]
if action == "import":
self._import(options)
elif action == "start":
self._start(options)
else:
self._resume(options)
def _import(self, options: dict) -> None:
required = ["slug", "title", "brief", "plan"]
missing = [name for name in required if not options.get(name)]
if missing:
raise CommandError("import requires " + ", ".join(f"--{name}" for name in missing))
brief_path: Path = options["brief"]
plan_path: Path = options["plan"]
if not brief_path.exists() or not plan_path.exists():
raise CommandError("brief or plan path does not exist")
brief = brief_path.read_text(encoding="utf-8")
plan = json.loads(plan_path.read_text(encoding="utf-8"))
project, _ = Project.objects.get_or_create(
name=options["title"],
defaults={
"project_type": "STORY",
"goal": f"Write and revise {options['title']}",
"status": "READY",
},
)
series_title = options["series"] or options["title"]
series_slug = slugify(series_title)
series, _ = Series.objects.get_or_create(
slug=series_slug, defaults={"title": series_title}
)
work, _ = Work.objects.get_or_create(
series=series,
slug=options["slug"],
defaults={"title": options["title"]},
)
story, _ = StoryProject.objects.update_or_create(
slug=options["slug"],
defaults={
"project": project,
"work": work,
"title": options["title"],
"series": options["series"],
"status": StoryStatus.REVISING,
"artifact_root": str(options.get("artifact_root") or ""),
},
)
bible_version = (story.bible_versions.aggregate(value=Max("version"))["value"] or 0) + 1
bible = StoryBibleVersion.objects.create(
story=story, version=bible_version, content=brief, approved_at=timezone.now()
)
outline_version = (story.outline_versions.aggregate(value=Max("version"))["value"] or 0) + 1
outline = OutlineVersion.objects.create(
story=story, version=outline_version, content=plan, approved_at=timezone.now()
)
for item in plan.get("chapters") or []:
Chapter.objects.update_or_create(
story=story,
number=int(item["number"]),
defaults={"title": item["title"]},
)
if options.get("source_dir"):
self._import_locked_chapters(
story,
bible,
outline,
options["source_dir"],
int(options["locked_through"]),
)
self.stdout.write(
self.style.SUCCESS(
f"Imported {story.title}: bible v{bible.version}, outline v{outline.version}, "
f"{story.chapters.count()} chapters"
)
)
def _import_locked_chapters(
self,
story: StoryProject,
bible: StoryBibleVersion,
outline: OutlineVersion,
source_dir: Path,
locked_through: int,
) -> None:
for number in range(1, locked_through + 1):
matches = sorted(source_dir.glob(f"*-chapter-{number:02d}-*.md"))
matches = [path for path in matches if ".partial." not in path.name]
if not matches:
raise CommandError(f"no canonical source found for Chapter {number} in {source_dir}")
chapter = story.chapters.get(number=number)
prose = matches[0].read_text(encoding="utf-8")
state_path = next(iter(sorted(source_dir.glob(f"*-chapter-{number:02d}.state.json"))), None)
continuity = (
json.loads(state_path.read_text(encoding="utf-8")) if state_path else {}
)
revision_number = (
chapter.revisions.aggregate(value=Max("revision"))["value"] or 0
) + 1
revision = ChapterRevision.objects.create(
chapter=chapter,
revision=revision_number,
status=RevisionStatus.APPROVED,
story_bible=bible,
outline=outline,
prose=prose,
continuity_state=continuity,
artifact_uri=str(matches[0]),
approved_at=timezone.now(),
)
chapter.current_revision = revision
chapter.status = ChapterStatus.APPROVED
chapter.save(update_fields=["current_revision", "status", "updated_at"])
canonical = json.dumps(
continuity, ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
CanonSnapshot.objects.create(
story=story,
through_chapter=number,
version=(story.canon_snapshots.aggregate(value=Max("version"))["value"] or 0)
+ 1,
state=continuity,
source_revision=revision,
sha256=text_sha256(canonical),
)
contract = ChapterContract.objects.create(
revision=revision,
requirements=[],
scene_plan_sha256=text_sha256("{}"),
approved_at=revision.approved_at,
)
ChapterStateDocument.objects.create(
revision=revision,
contract=contract,
status=StateDocumentStatus.COMMITTED,
start_state={},
observed_state=continuity,
proposed_delta=[],
coverage={"requirements": [], "counts": {}},
verdict="PASS",
sha256=text_sha256(canonical),
model_metadata={"imported_baseline": True},
validated_at=revision.approved_at,
committed_at=revision.approved_at,
)
def _start(self, options: dict) -> None:
if not options.get("slug") or not options.get("chapter"):
raise CommandError("start requires --slug and --chapter")
story = StoryProject.objects.get(slug=options["slug"])
chapter = story.chapters.get(number=options["chapter"])
if options.get("fresh") and options.get("source"):
raise CommandError("--fresh cannot be combined with --source")
if options.get("supersede_active"):
self._supersede_active_runs(story, chapter)
bible = story.bible_versions.filter(approved_at__isnull=False).order_by("-version").first()
outline = story.outline_versions.filter(approved_at__isnull=False).order_by("-version").first()
if bible is None or outline is None:
raise CommandError("story needs approved bible and outline versions")
source_revision = None
if options.get("source"):
source_path: Path = options["source"]
source_revision = ChapterRevision.objects.create(
chapter=chapter,
revision=(chapter.revisions.aggregate(value=Max("revision"))["value"] or 0) + 1,
status=RevisionStatus.SOURCE,
story_bible=bible,
outline=outline,
prose=source_path.read_text(encoding="utf-8"),
artifact_uri=str(source_path),
)
revision = ChapterRevision.objects.create(
chapter=chapter,
revision=(chapter.revisions.aggregate(value=Max("revision"))["value"] or 0) + 1,
status=RevisionStatus.DRAFT,
source_revision=source_revision,
story_bible=bible,
outline=outline,
generation_metadata={
"fresh_run": bool(options.get("fresh")),
"pinned_bible_version": bible.version,
"pinned_outline_version": outline.version,
"pinned_prior_canon_id": str(
(
CanonSnapshot.objects.filter(
story=story, through_chapter__lt=chapter.number
)
.order_by("-through_chapter", "-version")
.values_list("id", flat=True)
.first()
)
or ""
),
},
)
with open_story_checkpointer() as saver:
services = DjangoStoryWorkflowServices(
ModelRouter(providers_from_resources(), persist_requests=True)
)
runner = StoryWorkflowRunner(build_story_workflow(services, saver))
graph_run = runner.start(revision)
self.stdout.write(
f"Graph run {graph_run.id}: {graph_run.status} at {graph_run.current_node}"
)
def _supersede_active_runs(self, story: StoryProject, chapter: Chapter) -> None:
active = GraphRun.objects.filter(
project=story.project,
execution_graph_version__graph__name="story_authoring",
status__in=[GraphRunStatus.RUNNING, GraphRunStatus.PAUSED, GraphRunStatus.FAILED],
)
for graph_run in active:
revision_id = graph_run.metadata.get("current_revision_id") or graph_run.metadata.get(
"revision_id"
)
revision = ChapterRevision.objects.filter(id=revision_id).first()
if revision is None or revision.chapter_id != chapter.id:
continue
graph_run.status = GraphRunStatus.CANCELLED
graph_run.current_node = "superseded"
graph_run.failure_reason = "SUPERSEDED_BY_FRESH_STORY_RUN"
graph_run.completed_at = timezone.now()
graph_run.save(
update_fields=[
"status", "current_node", "failure_reason", "completed_at", "updated_at"
]
)
graph_run.approvals.filter(status=GraphApprovalStatus.PENDING).update(
status=GraphApprovalStatus.REJECTED,
decided_by="supersede_active",
decided_at=timezone.now(),
)
def _resume(self, options: dict) -> None:
if not options.get("graph_run") or not options.get("decision"):
raise CommandError("resume requires --graph-run and --decision")
with open_story_checkpointer() as saver:
services = DjangoStoryWorkflowServices(
ModelRouter(providers_from_resources(), persist_requests=True)
)
runner = StoryWorkflowRunner(build_story_workflow(services, saver))
graph_run = runner.resume(
options["graph_run"],
{
"action": options["decision"],
"notes": options["notes"],
"actor": "management_command",
},
)
self.stdout.write(
f"Graph run {graph_run.id}: {graph_run.status} at {graph_run.current_node}"
)

View file

@ -0,0 +1,234 @@
# Generated by Django 5.2.16 on 2026-08-21 06:10
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('projects', '0006_roadmap_scenario_lab_v1'),
]
operations = [
migrations.CreateModel(
name='Chapter',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('number', models.PositiveIntegerField()),
('title', models.CharField(max_length=255)),
('status', models.CharField(choices=[('PLANNED', 'Planned'), ('DRAFTING', 'Drafting'), ('REVIEW', 'Review'), ('APPROVED', 'Approved')], default='PLANNED', max_length=32)),
],
options={
'ordering': ['number'],
},
),
migrations.CreateModel(
name='OutlineVersion',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('version', models.PositiveIntegerField()),
('content', models.JSONField(default=dict)),
('sha256', models.CharField(blank=True, max_length=64)),
('approved_at', models.DateTimeField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='StoryBibleVersion',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('version', models.PositiveIntegerField()),
('content', models.TextField()),
('structured_canon', models.JSONField(blank=True, default=dict)),
('sha256', models.CharField(blank=True, max_length=64)),
('approved_at', models.DateTimeField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='ChapterRevision',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('revision', models.PositiveIntegerField()),
('status', models.CharField(choices=[('SOURCE', 'Source'), ('DRAFT', 'Draft'), ('REVIEW', 'Review'), ('APPROVED', 'Approved'), ('REJECTED', 'Rejected')], default='DRAFT', max_length=32)),
('scene_plan', models.JSONField(blank=True, default=dict)),
('prose', models.TextField(blank=True)),
('continuity_state', models.JSONField(blank=True, default=dict)),
('artifact_uri', models.TextField(blank=True)),
('word_count', models.PositiveIntegerField(default=0)),
('sha256', models.CharField(blank=True, max_length=64)),
('graph_thread_id', models.CharField(blank=True, db_index=True, max_length=255)),
('generation_metadata', models.JSONField(blank=True, default=dict)),
('approved_at', models.DateTimeField(blank=True, null=True)),
('chapter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='authoring.chapter')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='authoring.chapterrevision')),
('source_revision', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_children', to='authoring.chapterrevision')),
],
),
migrations.AddField(
model_name='chapter',
name='current_revision',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='current_for_chapters', to='authoring.chapterrevision'),
),
migrations.CreateModel(
name='CanonSnapshot',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('through_chapter', models.PositiveIntegerField()),
('version', models.PositiveIntegerField()),
('state', models.JSONField(default=dict)),
('sha256', models.CharField(max_length=64)),
('source_revision', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='committed_canon', to='authoring.chapterrevision')),
],
options={
'ordering': ['version'],
},
),
migrations.CreateModel(
name='EditorialFinding',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('review_kind', models.CharField(max_length=80)),
('severity', models.CharField(choices=[('INFO', 'Info'), ('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High'), ('CRITICAL', 'Critical')], default='INFO', max_length=16)),
('category', models.CharField(max_length=80)),
('location', models.CharField(blank=True, max_length=255)),
('description', models.TextField()),
('suggested_revision', models.TextField(blank=True)),
('evidence', models.JSONField(blank=True, default=dict)),
('status', models.CharField(choices=[('OPEN', 'Open'), ('RESOLVED', 'Resolved'), ('ACCEPTED', 'Accepted')], default='OPEN', max_length=16)),
('model_metadata', models.JSONField(blank=True, default=dict)),
('revision', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='findings', to='authoring.chapterrevision')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='GenerationContextSnapshot',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('content', models.JSONField(default=dict)),
('sha256', models.CharField(max_length=64)),
('chapter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='context_snapshots', to='authoring.chapter')),
('prior_canon', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='derived_contexts', to='authoring.canonsnapshot')),
('outline', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='authoring.outlineversion')),
('story_bible', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='authoring.storybibleversion')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='chapterrevision',
name='context_snapshot',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='revisions', to='authoring.generationcontextsnapshot'),
),
migrations.AddField(
model_name='chapterrevision',
name='outline',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='authoring.outlineversion'),
),
migrations.CreateModel(
name='PromptVersion',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=160)),
('purpose', models.CharField(max_length=80)),
('version', models.PositiveIntegerField()),
('system_text', models.TextField(blank=True)),
('user_template', models.TextField()),
('config', models.JSONField(blank=True, default=dict)),
('is_active', models.BooleanField(default=False)),
],
options={
'constraints': [models.UniqueConstraint(fields=('name', 'version'), name='unique_authoring_prompt_version'), models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('purpose',), name='unique_active_authoring_prompt_purpose')],
},
),
migrations.AddField(
model_name='chapterrevision',
name='story_bible',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='authoring.storybibleversion'),
),
migrations.CreateModel(
name='StoryProject',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('title', models.CharField(max_length=255)),
('series', models.CharField(blank=True, max_length=255)),
('slug', models.SlugField(max_length=160, unique=True)),
('status', models.CharField(choices=[('PLANNING', 'Planning'), ('REVISING', 'Revising'), ('DRAFTING', 'Drafting'), ('COMPLETE', 'Complete')], default='PLANNING', max_length=32)),
('artifact_root', models.TextField(blank=True)),
('config', models.JSONField(blank=True, default=dict)),
('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='story_project', to='projects.project')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='storybibleversion',
name='story',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bible_versions', to='authoring.storyproject'),
),
migrations.AddField(
model_name='outlineversion',
name='story',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='outline_versions', to='authoring.storyproject'),
),
migrations.AddField(
model_name='generationcontextsnapshot',
name='story',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='context_snapshots', to='authoring.storyproject'),
),
migrations.AddField(
model_name='chapter',
name='story',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='authoring.storyproject'),
),
migrations.AddField(
model_name='canonsnapshot',
name='story',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='canon_snapshots', to='authoring.storyproject'),
),
migrations.AddConstraint(
model_name='chapterrevision',
constraint=models.UniqueConstraint(fields=('chapter', 'revision'), name='unique_chapter_revision_number'),
),
migrations.AddConstraint(
model_name='storybibleversion',
constraint=models.UniqueConstraint(fields=('story', 'version'), name='unique_story_bible_version'),
),
migrations.AddConstraint(
model_name='outlineversion',
constraint=models.UniqueConstraint(fields=('story', 'version'), name='unique_story_outline_version'),
),
migrations.AddConstraint(
model_name='chapter',
constraint=models.UniqueConstraint(fields=('story', 'number'), name='unique_story_chapter_number'),
),
migrations.AddConstraint(
model_name='canonsnapshot',
constraint=models.UniqueConstraint(fields=('story', 'version'), name='unique_story_canon_version'),
),
]

View file

@ -0,0 +1,173 @@
# Generated by Django 5.2.17 on 2026-08-21 09:08
import hashlib
import json
import uuid
import django.db.models.deletion
from django.db import migrations, models
def backfill_approved_state_documents(apps, schema_editor):
ChapterContract = apps.get_model('authoring', 'ChapterContract')
ChapterStateDocument = apps.get_model('authoring', 'ChapterStateDocument')
ChapterRevision = apps.get_model('authoring', 'ChapterRevision')
for revision in ChapterRevision.objects.filter(approved_at__isnull=False).iterator():
plan_json = json.dumps(
revision.scene_plan, ensure_ascii=False, sort_keys=True, separators=(',', ':')
)
contract, _ = ChapterContract.objects.get_or_create(
revision=revision,
defaults={
'requirements': [],
'scene_plan_sha256': hashlib.sha256(plan_json.encode('utf-8')).hexdigest(),
'approved_at': revision.approved_at,
},
)
state_json = json.dumps(
revision.continuity_state,
ensure_ascii=False,
sort_keys=True,
separators=(',', ':'),
)
ChapterStateDocument.objects.get_or_create(
revision=revision,
defaults={
'contract': contract,
'status': 'COMMITTED',
'start_state': {},
'observed_state': revision.continuity_state,
'proposed_delta': [],
'coverage': {'requirements': [], 'counts': {}},
'verdict': 'PASS',
'sha256': hashlib.sha256(state_json.encode('utf-8')).hexdigest(),
'validated_at': revision.approved_at,
'committed_at': revision.approved_at,
'model_metadata': {'backfilled': True},
},
)
class Migration(migrations.Migration):
dependencies = [
('authoring', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ChapterContract',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('requirements', models.JSONField(default=list)),
('scene_plan_sha256', models.CharField(max_length=64)),
('approved_at', models.DateTimeField(blank=True, null=True)),
('entry_canon', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='chapter_contracts', to='authoring.canonsnapshot')),
('revision', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='contract', to='authoring.chapterrevision')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='ChapterStateDocument',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('status', models.CharField(choices=[('EXTRACTED', 'Extracted'), ('NEEDS_REVISION', 'Needs Revision'), ('VALIDATED', 'Validated'), ('COMMITTED', 'Committed')], default='EXTRACTED', max_length=32)),
('start_state', models.JSONField(default=dict)),
('observed_state', models.JSONField(default=dict)),
('proposed_delta', models.JSONField(default=list)),
('coverage', models.JSONField(default=dict)),
('verdict', models.CharField(blank=True, max_length=32)),
('json_artifact_uri', models.TextField(blank=True)),
('markdown_artifact_uri', models.TextField(blank=True)),
('sha256', models.CharField(max_length=64)),
('model_metadata', models.JSONField(blank=True, default=dict)),
('validated_at', models.DateTimeField(blank=True, null=True)),
('committed_at', models.DateTimeField(blank=True, null=True)),
('contract', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='state_documents', to='authoring.chaptercontract')),
('revision', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='state_document', to='authoring.chapterrevision')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='StoryEntity',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('entity_key', models.CharField(max_length=200)),
('kind', models.CharField(max_length=64)),
('canonical_name', models.CharField(max_length=255)),
('aliases', models.JSONField(blank=True, default=list)),
('metadata', models.JSONField(blank=True, default=dict)),
('first_revision', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='introduced_state_entities', to='authoring.chapterrevision')),
('story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state_entities', to='authoring.storyproject')),
],
),
migrations.CreateModel(
name='StateChange',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('sequence', models.PositiveIntegerField()),
('change_type', models.CharField(max_length=80)),
('predicate', models.CharField(max_length=200)),
('operation', models.CharField(choices=[('SET', 'Set'), ('ADD', 'Add'), ('REMOVE', 'Remove'), ('TRANSFER', 'Transfer'), ('OPEN', 'Open'), ('CLOSE', 'Close')], max_length=16)),
('previous_value', models.JSONField(blank=True, null=True)),
('new_value', models.JSONField(blank=True, null=True)),
('effective_chapter', models.PositiveIntegerField()),
('evidence_quote', models.TextField(blank=True)),
('evidence_location', models.CharField(blank=True, max_length=255)),
('status', models.CharField(choices=[('PROPOSED', 'Proposed'), ('VALIDATED', 'Validated'), ('COMMITTED', 'Committed'), ('REJECTED', 'Rejected')], default='PROPOSED', max_length=16)),
('metadata', models.JSONField(blank=True, default=dict)),
('sha256', models.CharField(max_length=64)),
('revision', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state_changes', to='authoring.chapterrevision')),
('state_document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changes', to='authoring.chapterstatedocument')),
('story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state_changes', to='authoring.storyproject')),
('supersedes', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='superseded_by', to='authoring.statechange')),
('entity', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='changes', to='authoring.storyentity')),
('related_entity', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='related_changes', to='authoring.storyentity')),
],
options={
'ordering': ['effective_chapter', 'sequence'],
},
),
migrations.CreateModel(
name='RequirementCheck',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('requirement_id', models.CharField(max_length=80)),
('requirement_type', models.CharField(max_length=32)),
('requirement_text', models.TextField()),
('status', models.CharField(choices=[('HIT', 'Hit'), ('PARTIAL', 'Partial'), ('MISSED', 'Missed'), ('CONTRADICTED', 'Contradicted'), ('UNVERIFIABLE', 'Unverifiable')], max_length=24)),
('severity', models.CharField(choices=[('INFO', 'Info'), ('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High'), ('CRITICAL', 'Critical')], max_length=16)),
('evidence_quote', models.TextField(blank=True)),
('evidence_location', models.CharField(blank=True, max_length=255)),
('details', models.TextField(blank=True)),
('model_metadata', models.JSONField(blank=True, default=dict)),
('state_document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requirement_checks', to='authoring.chapterstatedocument')),
],
options={
'constraints': [models.UniqueConstraint(fields=('state_document', 'requirement_id'), name='unique_state_document_requirement')],
},
),
migrations.AddConstraint(
model_name='storyentity',
constraint=models.UniqueConstraint(fields=('story', 'entity_key'), name='unique_story_state_entity_key'),
),
migrations.AddConstraint(
model_name='statechange',
constraint=models.UniqueConstraint(fields=('state_document', 'sequence'), name='unique_state_change_sequence'),
),
migrations.RunPython(backfill_approved_state_documents, migrations.RunPython.noop),
]

View file

@ -0,0 +1,138 @@
# Generated by Django 5.2.16 on 2026-08-27 12:16
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authoring', '0002_chaptercontract_chapterstatedocument_storyentity_and_more'),
]
operations = [
migrations.CreateModel(
name='Series',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('title', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=160, unique=True)),
('description', models.TextField(blank=True)),
('metadata', models.JSONField(blank=True, default=dict)),
],
options={
'verbose_name_plural': 'series',
},
),
migrations.CreateModel(
name='SourceDocument',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('logical_key', models.CharField(max_length=500)),
('title', models.CharField(max_length=500)),
('document_type', models.CharField(choices=[('manuscript', 'Manuscript'), ('scene', 'Scene'), ('outline', 'Outline'), ('planning', 'Planning'), ('canon', 'Canon'), ('state', 'State'), ('reference', 'Reference'), ('other', 'Other')], default='other', max_length=32)),
('metadata', models.JSONField(blank=True, default=dict)),
],
options={
'ordering': ['logical_key'],
},
),
migrations.CreateModel(
name='SourceDocumentVersion',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('version', models.PositiveIntegerField()),
('authority', models.CharField(choices=[('canon', 'Canon'), ('provisional', 'Provisional'), ('planning', 'Planning'), ('superseded', 'Superseded'), ('rejected', 'Rejected'), ('noncanon_experiment', 'Noncanon Experiment')], db_index=True, default='provisional', max_length=32)),
('source_path', models.TextField()),
('content', models.TextField()),
('source_sha256', models.CharField(db_index=True, max_length=64)),
('byte_size', models.PositiveBigIntegerField()),
('encoding', models.CharField(default='utf-8', max_length=40)),
('metadata', models.JSONField(blank=True, default=dict)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='authoring.sourcedocument')),
('supersedes', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='superseded_by', to='authoring.sourcedocumentversion')),
],
options={
'ordering': ['document', 'version'],
},
),
migrations.CreateModel(
name='SourcePassage',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('ordinal', models.PositiveIntegerField()),
('start_line', models.PositiveIntegerField()),
('end_line', models.PositiveIntegerField()),
('start_char', models.PositiveBigIntegerField()),
('end_char', models.PositiveBigIntegerField()),
('content', models.TextField()),
('sha256', models.CharField(db_index=True, max_length=64)),
('metadata', models.JSONField(blank=True, default=dict)),
('document_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='passages', to='authoring.sourcedocumentversion')),
],
options={
'ordering': ['document_version', 'ordinal'],
},
),
migrations.CreateModel(
name='Work',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('title', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=160)),
('work_type', models.CharField(choices=[('book', 'Book'), ('series_reference', 'Series Reference'), ('other', 'Other')], default='book', max_length=32)),
('sequence', models.PositiveIntegerField(blank=True, null=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='works', to='authoring.series')),
],
options={
'ordering': ['sequence', 'title'],
},
),
migrations.AddField(
model_name='sourcedocument',
name='work',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='source_documents', to='authoring.work'),
),
migrations.AddField(
model_name='storyproject',
name='work',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='story_project', to='authoring.work'),
),
migrations.AddConstraint(
model_name='sourcedocumentversion',
constraint=models.UniqueConstraint(fields=('document', 'version'), name='unique_source_document_version'),
),
migrations.AddConstraint(
model_name='sourcepassage',
constraint=models.UniqueConstraint(fields=('document_version', 'ordinal'), name='unique_source_document_passage_ordinal'),
),
migrations.AddConstraint(
model_name='sourcepassage',
constraint=models.CheckConstraint(condition=models.Q(('end_line__gte', models.F('start_line'))), name='source_passage_line_range_valid'),
),
migrations.AddConstraint(
model_name='sourcepassage',
constraint=models.CheckConstraint(condition=models.Q(('end_char__gte', models.F('start_char'))), name='source_passage_char_range_valid'),
),
migrations.AddConstraint(
model_name='work',
constraint=models.UniqueConstraint(fields=('series', 'slug'), name='unique_series_work_slug'),
),
migrations.AddConstraint(
model_name='sourcedocument',
constraint=models.UniqueConstraint(fields=('work', 'logical_key'), name='unique_work_source_document_key'),
),
]

View file

@ -0,0 +1,86 @@
# Generated by Django 5.2.16 on 2026-08-27 12:33
import uuid
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authoring', '0003_series_sourcedocument_sourcedocumentversion_and_more'),
]
operations = [
migrations.CreateModel(
name='StandaloneScene',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('scene_key', models.SlugField(max_length=200)),
('revision', models.PositiveIntegerField(default=1)),
('title', models.CharField(max_length=500)),
('status', models.CharField(choices=[('planning', 'Planning'), ('plan_review', 'Plan Review'), ('ready', 'Ready'), ('drafting', 'Drafting'), ('draft_review', 'Draft Review'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('failed', 'Failed')], default='planning', max_length=32)),
('brief', models.TextField()),
('target_words', models.PositiveIntegerField(default=1800, validators=[django.core.validators.MinValueValidator(300), django.core.validators.MaxValueValidator(10000)])),
('constraints', models.JSONField(blank=True, default=list)),
('forbidden_events', models.JSONField(blank=True, default=list)),
('boundary_constraints', models.JSONField(blank=True, default=list)),
('context_query', models.TextField(blank=True)),
('context_pack', models.JSONField(blank=True, default=dict)),
('context_pack_sha256', models.CharField(blank=True, max_length=64)),
('plan', models.JSONField(blank=True, default=dict)),
('contract_requirements', models.JSONField(blank=True, default=list)),
('prose', models.TextField(blank=True)),
('word_count', models.PositiveIntegerField(default=0)),
('sha256', models.CharField(blank=True, max_length=64)),
('partial_artifact_uri', models.TextField(blank=True)),
('artifact_uri', models.TextField(blank=True)),
('review_artifact_uri', models.TextField(blank=True)),
('review', models.JSONField(blank=True, default=dict)),
('generation_metadata', models.JSONField(blank=True, default=dict)),
('plan_approved_at', models.DateTimeField(blank=True, null=True)),
('approved_at', models.DateTimeField(blank=True, null=True)),
('approved_by', models.CharField(blank=True, max_length=160)),
('failure_reason', models.TextField(blank=True)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='revisions', to='authoring.standalonescene')),
('source_version', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='generated_scenes', to='authoring.sourcedocumentversion')),
('story', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='standalone_scenes', to='authoring.storyproject')),
('work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='standalone_scenes', to='authoring.work')),
],
options={
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='SceneContextCitation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('rank', models.PositiveIntegerField()),
('score', models.FloatField(default=0)),
('reason', models.CharField(blank=True, max_length=255)),
('passage', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='scene_citations', to='authoring.sourcepassage')),
('scene', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='context_citations', to='authoring.standalonescene')),
],
options={
'ordering': ['scene', 'rank'],
},
),
migrations.AddConstraint(
model_name='standalonescene',
constraint=models.UniqueConstraint(fields=('work', 'scene_key', 'revision'), name='unique_work_standalone_scene_revision'),
),
migrations.AddConstraint(
model_name='scenecontextcitation',
constraint=models.UniqueConstraint(fields=('scene', 'passage'), name='unique_scene_context_passage'),
),
migrations.AddConstraint(
model_name='scenecontextcitation',
constraint=models.UniqueConstraint(fields=('scene', 'rank'), name='unique_scene_context_rank'),
),
]

View file

@ -0,0 +1,37 @@
# Generated by Django 5.2.16 on 2026-08-27 13:38
import uuid
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authoring', '0004_standalonescene_scenecontextcitation_and_more'),
]
operations = [
migrations.CreateModel(
name='SceneIdeation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('focus', models.TextField(blank=True)),
('candidate_count', models.PositiveSmallIntegerField(default=5, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(8)])),
('authorities', models.JSONField(default=list)),
('pinned_document_keys', models.JSONField(blank=True, default=list)),
('context_pack', models.JSONField(default=dict)),
('context_pack_sha256', models.CharField(max_length=64)),
('candidates', models.JSONField(default=list)),
('generation_metadata', models.JSONField(default=dict)),
('work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scene_ideations', to='authoring.work')),
],
options={
'ordering': ['-created_at'],
},
),
]

View file

@ -0,0 +1,19 @@
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authoring", "0005_sceneideation"),
]
operations = [
migrations.AlterField(
model_name="sceneideation",
name="candidate_count",
field=models.PositiveSmallIntegerField(
default=10,
validators=[MinValueValidator(1), MaxValueValidator(12)],
),
),
]

View file

@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authoring", "0006_alter_sceneideation_candidate_count"),
]
operations = [
migrations.AddField(
model_name="sceneideation",
name="target_book",
field=models.CharField(blank=True, max_length=160),
),
]

View file

@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authoring", "0007_sceneideation_target_book"),
]
operations = [
migrations.AddField(
model_name="sceneideation",
name="requested_scene_types",
field=models.JSONField(blank=True, default=list),
),
]

View file

@ -0,0 +1,217 @@
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authoring", "0008_sceneideation_requested_scene_types"),
]
operations = [
migrations.CreateModel(
name="BookStateVersion",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("version", models.PositiveIntegerField()),
(
"status",
models.CharField(
choices=[
("draft", "Draft"),
("review", "Review"),
("approved", "Approved"),
("rejected", "Rejected"),
],
default="draft",
max_length=16,
),
),
("content", models.JSONField()),
("sha256", models.CharField(db_index=True, max_length=64)),
("context_pack", models.JSONField(blank=True, default=dict)),
("context_pack_sha256", models.CharField(blank=True, max_length=64)),
("validation", models.JSONField(blank=True, default=dict)),
("reviews", models.JSONField(blank=True, default=dict)),
("change_summary", models.JSONField(blank=True, default=dict)),
("generation_metadata", models.JSONField(blank=True, default=dict)),
("created_by", models.CharField(blank=True, max_length=160)),
("json_artifact_uri", models.TextField(blank=True)),
("markdown_artifact_uri", models.TextField(blank=True)),
("approved_at", models.DateTimeField(blank=True, null=True)),
("approved_by", models.CharField(blank=True, max_length=160)),
("approval_notes", models.TextField(blank=True)),
("approval_forced", models.BooleanField(default=False)),
("rejected_at", models.DateTimeField(blank=True, null=True)),
("rejected_by", models.CharField(blank=True, max_length=160)),
("rejection_notes", models.TextField(blank=True)),
(
"parent",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="children",
to="authoring.bookstateversion",
),
),
(
"work",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="book_state_versions",
to="authoring.work",
),
),
],
options={
"ordering": ["work", "version"],
},
),
migrations.AddConstraint(
model_name="bookstateversion",
constraint=models.UniqueConstraint(
fields=("work", "version"), name="unique_work_book_state_version"
),
),
migrations.AddField(
model_name="work",
name="current_book_state",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="current_for_works",
to="authoring.bookstateversion",
),
),
migrations.AddField(
model_name="standalonescene",
name="book_chapter_key",
field=models.CharField(blank=True, max_length=80),
),
migrations.AddField(
model_name="standalonescene",
name="book_state",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="standalone_scenes",
to="authoring.bookstateversion",
),
),
migrations.RemoveConstraint(
model_name="standalonescene",
name="unique_work_standalone_scene_revision",
),
migrations.AddConstraint(
model_name="standalonescene",
constraint=models.UniqueConstraint(
condition=models.Q(("book_state__isnull", True)),
fields=("work", "scene_key", "revision"),
name="unique_unbound_scene_revision",
),
),
migrations.AddConstraint(
model_name="standalonescene",
constraint=models.UniqueConstraint(
condition=models.Q(("book_state__isnull", False)),
fields=(
"work",
"book_state",
"book_chapter_key",
"scene_key",
"revision",
),
name="unique_bound_scene_revision",
),
),
migrations.AddConstraint(
model_name="standalonescene",
constraint=models.CheckConstraint(
condition=models.Q(
models.Q(("book_state__isnull", True), ("book_chapter_key", "")),
models.Q(
("book_state__isnull", False),
models.Q(("book_chapter_key", ""), _negated=True),
),
_connector="OR",
),
name="scene_book_state_chapter_key_paired",
),
),
migrations.AddField(
model_name="sceneideation",
name="book_state",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="scene_ideations",
to="authoring.bookstateversion",
),
),
migrations.CreateModel(
name="BookRun",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("running", "Running"),
("paused", "Paused"),
("review", "Review"),
("complete", "Complete"),
("failed", "Failed"),
("cancelled", "Cancelled"),
],
db_index=True,
default="pending",
max_length=16,
),
),
("current_chapter_key", models.CharField(blank=True, max_length=80)),
("progress", models.JSONField(default=dict)),
("policy", models.JSONField(default=dict)),
("reviews", models.JSONField(blank=True, default=dict)),
("failure_reason", models.TextField(blank=True)),
("started_at", models.DateTimeField(blank=True, null=True)),
("finished_at", models.DateTimeField(blank=True, null=True)),
(
"book_state",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="runs",
to="authoring.bookstateversion",
),
),
],
options={
"ordering": ["-created_at"],
},
),
]

View file

@ -0,0 +1,959 @@
from __future__ import annotations
import hashlib
import json
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Q
from control_plane.common import TimestampedModel
def text_sha256(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
class StoryStatus(models.TextChoices):
PLANNING = "PLANNING"
REVISING = "REVISING"
DRAFTING = "DRAFTING"
COMPLETE = "COMPLETE"
class ChapterStatus(models.TextChoices):
PLANNED = "PLANNED"
DRAFTING = "DRAFTING"
REVIEW = "REVIEW"
APPROVED = "APPROVED"
class RevisionStatus(models.TextChoices):
SOURCE = "SOURCE"
DRAFT = "DRAFT"
REVIEW = "REVIEW"
APPROVED = "APPROVED"
REJECTED = "REJECTED"
class FindingSeverity(models.TextChoices):
INFO = "INFO"
LOW = "LOW"
MEDIUM = "MEDIUM"
HIGH = "HIGH"
CRITICAL = "CRITICAL"
class FindingStatus(models.TextChoices):
OPEN = "OPEN"
RESOLVED = "RESOLVED"
ACCEPTED = "ACCEPTED"
class StateDocumentStatus(models.TextChoices):
EXTRACTED = "EXTRACTED"
NEEDS_REVISION = "NEEDS_REVISION"
VALIDATED = "VALIDATED"
COMMITTED = "COMMITTED"
class RequirementStatus(models.TextChoices):
HIT = "HIT"
PARTIAL = "PARTIAL"
MISSED = "MISSED"
CONTRADICTED = "CONTRADICTED"
UNVERIFIABLE = "UNVERIFIABLE"
class StateChangeStatus(models.TextChoices):
PROPOSED = "PROPOSED"
VALIDATED = "VALIDATED"
COMMITTED = "COMMITTED"
REJECTED = "REJECTED"
class StateOperation(models.TextChoices):
SET = "SET"
ADD = "ADD"
REMOVE = "REMOVE"
TRANSFER = "TRANSFER"
OPEN = "OPEN"
CLOSE = "CLOSE"
class WorkType(models.TextChoices):
BOOK = "book"
SERIES_REFERENCE = "series_reference"
OTHER = "other"
class DocumentType(models.TextChoices):
MANUSCRIPT = "manuscript"
SCENE = "scene"
OUTLINE = "outline"
PLANNING = "planning"
CANON = "canon"
STATE = "state"
REFERENCE = "reference"
OTHER = "other"
class DocumentAuthority(models.TextChoices):
CANON = "canon"
PROVISIONAL = "provisional"
PLANNING = "planning"
SUPERSEDED = "superseded"
REJECTED = "rejected"
NONCANON_EXPERIMENT = "noncanon_experiment"
class SceneDraftStatus(models.TextChoices):
PLANNING = "planning"
PLAN_REVIEW = "plan_review"
READY = "ready"
DRAFTING = "drafting"
DRAFT_REVIEW = "draft_review"
APPROVED = "approved"
REJECTED = "rejected"
FAILED = "failed"
class BookStateStatus(models.TextChoices):
DRAFT = "draft"
REVIEW = "review"
APPROVED = "approved"
REJECTED = "rejected"
class BookRunStatus(models.TextChoices):
PENDING = "pending"
RUNNING = "running"
PAUSED = "paused"
REVIEW = "review"
COMPLETE = "complete"
FAILED = "failed"
CANCELLED = "cancelled"
class Series(TimestampedModel):
title = models.CharField(max_length=255)
slug = models.SlugField(max_length=160, unique=True)
description = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
verbose_name_plural = "series"
def __str__(self) -> str:
return self.title
class Work(TimestampedModel):
series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name="works")
title = models.CharField(max_length=255)
slug = models.SlugField(max_length=160)
work_type = models.CharField(
max_length=32, choices=WorkType.choices, default=WorkType.BOOK
)
sequence = models.PositiveIntegerField(null=True, blank=True)
metadata = models.JSONField(default=dict, blank=True)
current_book_state = models.ForeignKey(
"BookStateVersion",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="current_for_works",
)
class Meta:
ordering = ["sequence", "title"]
constraints = [
models.UniqueConstraint(fields=["series", "slug"], name="unique_series_work_slug")
]
def __str__(self) -> str:
return self.title
class StoryProject(TimestampedModel):
project = models.OneToOneField(
"projects.Project", on_delete=models.CASCADE, related_name="story_project"
)
work = models.OneToOneField(
Work,
on_delete=models.PROTECT,
related_name="story_project",
null=True,
blank=True,
)
title = models.CharField(max_length=255)
series = models.CharField(max_length=255, blank=True)
slug = models.SlugField(max_length=160, unique=True)
status = models.CharField(
max_length=32, choices=StoryStatus.choices, default=StoryStatus.PLANNING
)
artifact_root = models.TextField(blank=True)
config = models.JSONField(default=dict, blank=True)
def __str__(self) -> str:
return self.title
class SourceDocument(TimestampedModel):
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="source_documents")
logical_key = models.CharField(max_length=500)
title = models.CharField(max_length=500)
document_type = models.CharField(
max_length=32, choices=DocumentType.choices, default=DocumentType.OTHER
)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["logical_key"]
constraints = [
models.UniqueConstraint(
fields=["work", "logical_key"], name="unique_work_source_document_key"
)
]
def __str__(self) -> str:
return self.title
class SourceDocumentVersion(TimestampedModel):
document = models.ForeignKey(SourceDocument, on_delete=models.CASCADE, related_name="versions")
version = models.PositiveIntegerField()
authority = models.CharField(
max_length=32,
choices=DocumentAuthority.choices,
default=DocumentAuthority.PROVISIONAL,
db_index=True,
)
source_path = models.TextField()
content = models.TextField()
source_sha256 = models.CharField(max_length=64, db_index=True)
byte_size = models.PositiveBigIntegerField()
encoding = models.CharField(max_length=40, default="utf-8")
supersedes = models.ForeignKey(
"self",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="superseded_by",
)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["document", "version"]
constraints = [
models.UniqueConstraint(
fields=["document", "version"], name="unique_source_document_version"
),
]
def __str__(self) -> str:
return f"{self.document.title} v{self.version}"
def save(self, *args: object, **kwargs: object) -> None:
if not self._state.adding:
original = SourceDocumentVersion.objects.get(pk=self.pk)
immutable_fields = (
"document_id",
"version",
"authority",
"source_path",
"content",
"source_sha256",
"byte_size",
"encoding",
"supersedes_id",
"metadata",
)
changed = any(
getattr(self, field) != getattr(original, field) for field in immutable_fields
)
if changed:
raise ValueError(
"source document versions are immutable; create a superseding version"
)
super().save(*args, **kwargs)
class SourcePassage(TimestampedModel):
document_version = models.ForeignKey(
SourceDocumentVersion, on_delete=models.CASCADE, related_name="passages"
)
ordinal = models.PositiveIntegerField()
start_line = models.PositiveIntegerField()
end_line = models.PositiveIntegerField()
start_char = models.PositiveBigIntegerField()
end_char = models.PositiveBigIntegerField()
content = models.TextField()
sha256 = models.CharField(max_length=64, db_index=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["document_version", "ordinal"]
constraints = [
models.UniqueConstraint(
fields=["document_version", "ordinal"],
name="unique_source_document_passage_ordinal",
),
models.CheckConstraint(
condition=Q(end_line__gte=models.F("start_line")),
name="source_passage_line_range_valid",
),
models.CheckConstraint(
condition=Q(end_char__gte=models.F("start_char")),
name="source_passage_char_range_valid",
),
]
def save(self, *args: object, **kwargs: object) -> None:
if not self._state.adding:
original = SourcePassage.objects.get(pk=self.pk)
immutable_fields = (
"document_version_id",
"ordinal",
"start_line",
"end_line",
"start_char",
"end_char",
"content",
"sha256",
"metadata",
)
changed = any(
getattr(self, field) != getattr(original, field) for field in immutable_fields
)
if changed:
raise ValueError("source passages are immutable with their document version")
super().save(*args, **kwargs)
class BookStateVersion(TimestampedModel):
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="book_state_versions")
parent = models.ForeignKey(
"self",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="children",
)
version = models.PositiveIntegerField()
status = models.CharField(
max_length=16, choices=BookStateStatus.choices, default=BookStateStatus.DRAFT
)
content = models.JSONField()
sha256 = models.CharField(max_length=64, db_index=True)
context_pack = models.JSONField(default=dict, blank=True)
context_pack_sha256 = models.CharField(max_length=64, blank=True)
validation = models.JSONField(default=dict, blank=True)
reviews = models.JSONField(default=dict, blank=True)
change_summary = models.JSONField(default=dict, blank=True)
generation_metadata = models.JSONField(default=dict, blank=True)
created_by = models.CharField(max_length=160, blank=True)
json_artifact_uri = models.TextField(blank=True)
markdown_artifact_uri = models.TextField(blank=True)
approved_at = models.DateTimeField(null=True, blank=True)
approved_by = models.CharField(max_length=160, blank=True)
approval_notes = models.TextField(blank=True)
approval_forced = models.BooleanField(default=False)
rejected_at = models.DateTimeField(null=True, blank=True)
rejected_by = models.CharField(max_length=160, blank=True)
rejection_notes = models.TextField(blank=True)
class Meta:
ordering = ["work", "version"]
constraints = [
models.UniqueConstraint(
fields=["work", "version"], name="unique_work_book_state_version"
)
]
def save(self, *args: object, **kwargs: object) -> None:
if self._state.adding:
canonical = json.dumps(
self.content, ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
self.sha256 = text_sha256(canonical)
if self.context_pack:
canonical_context = json.dumps(
self.context_pack,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
self.context_pack_sha256 = text_sha256(canonical_context)
else:
original = BookStateVersion.objects.get(pk=self.pk)
immutable_fields = (
"work_id",
"parent_id",
"version",
"content",
"sha256",
"context_pack",
"context_pack_sha256",
"generation_metadata",
"created_by",
)
if original.status == BookStateStatus.APPROVED:
immutable_fields += (
"status",
"reviews",
"validation",
"change_summary",
"approved_at",
"approved_by",
"approval_notes",
"approval_forced",
"rejected_at",
"rejected_by",
"rejection_notes",
"json_artifact_uri",
"markdown_artifact_uri",
)
if any(
getattr(self, field) != getattr(original, field) for field in immutable_fields
):
raise ValueError(
"book state versions are immutable; create a child version"
)
super().save(*args, **kwargs)
def __str__(self) -> str:
return f"{self.work} v{self.version}"
class StandaloneScene(TimestampedModel):
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="standalone_scenes")
story = models.ForeignKey(
StoryProject,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="standalone_scenes",
)
parent = models.ForeignKey(
"self",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="revisions",
)
source_version = models.ForeignKey(
SourceDocumentVersion,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="generated_scenes",
)
book_state = models.ForeignKey(
BookStateVersion,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="standalone_scenes",
)
book_chapter_key = models.CharField(max_length=80, blank=True)
scene_key = models.SlugField(max_length=200)
revision = models.PositiveIntegerField(default=1)
title = models.CharField(max_length=500)
status = models.CharField(
max_length=32, choices=SceneDraftStatus.choices, default=SceneDraftStatus.PLANNING
)
brief = models.TextField()
target_words = models.PositiveIntegerField(
default=1800,
validators=[MinValueValidator(300), MaxValueValidator(10000)],
)
constraints = models.JSONField(default=list, blank=True)
forbidden_events = models.JSONField(default=list, blank=True)
boundary_constraints = models.JSONField(default=list, blank=True)
context_query = models.TextField(blank=True)
context_pack = models.JSONField(default=dict, blank=True)
context_pack_sha256 = models.CharField(max_length=64, blank=True)
plan = models.JSONField(default=dict, blank=True)
contract_requirements = models.JSONField(default=list, blank=True)
prose = models.TextField(blank=True)
word_count = models.PositiveIntegerField(default=0)
sha256 = models.CharField(max_length=64, blank=True)
partial_artifact_uri = models.TextField(blank=True)
artifact_uri = models.TextField(blank=True)
review_artifact_uri = models.TextField(blank=True)
review = models.JSONField(default=dict, blank=True)
generation_metadata = models.JSONField(default=dict, blank=True)
plan_approved_at = models.DateTimeField(null=True, blank=True)
approved_at = models.DateTimeField(null=True, blank=True)
approved_by = models.CharField(max_length=160, blank=True)
failure_reason = models.TextField(blank=True)
class Meta:
ordering = ["-updated_at"]
constraints = [
models.UniqueConstraint(
fields=["work", "scene_key", "revision"],
condition=Q(book_state__isnull=True),
name="unique_unbound_scene_revision",
),
models.UniqueConstraint(
fields=[
"work",
"book_state",
"book_chapter_key",
"scene_key",
"revision",
],
condition=Q(book_state__isnull=False),
name="unique_bound_scene_revision",
),
models.CheckConstraint(
condition=(Q(book_state__isnull=True) & Q(book_chapter_key=""))
| (Q(book_state__isnull=False) & ~Q(book_chapter_key="")),
name="scene_book_state_chapter_key_paired",
),
]
def save(self, *args: object, **kwargs: object) -> None:
if not self._state.adding:
original = StandaloneScene.objects.get(pk=self.pk)
if original.status == SceneDraftStatus.APPROVED:
immutable_fields = (
"work_id",
"story_id",
"parent_id",
"source_version_id",
"book_state_id",
"book_chapter_key",
"scene_key",
"revision",
"title",
"status",
"brief",
"target_words",
"constraints",
"forbidden_events",
"boundary_constraints",
"context_query",
"context_pack",
"context_pack_sha256",
"plan",
"contract_requirements",
"prose",
"sha256",
"artifact_uri",
"review",
"review_artifact_uri",
"generation_metadata",
"approved_at",
"approved_by",
)
changed = any(
getattr(self, field) != getattr(original, field)
for field in immutable_fields
)
if changed:
raise ValueError(
"approved standalone scenes are immutable; create a new revision"
)
if self.prose:
import re
self.word_count = len(re.findall(r"\b\S+\b", self.prose))
self.sha256 = text_sha256(self.prose)
super().save(*args, **kwargs)
def __str__(self) -> str:
return f"{self.title} r{self.revision}"
class SceneIdeation(TimestampedModel):
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="scene_ideations")
book_state = models.ForeignKey(
BookStateVersion,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="scene_ideations",
)
target_book = models.CharField(max_length=160, blank=True)
focus = models.TextField(blank=True)
requested_scene_types = models.JSONField(default=list, blank=True)
candidate_count = models.PositiveSmallIntegerField(
default=10,
validators=[MinValueValidator(1), MaxValueValidator(12)],
)
authorities = models.JSONField(default=list)
pinned_document_keys = models.JSONField(default=list, blank=True)
context_pack = models.JSONField(default=dict)
context_pack_sha256 = models.CharField(max_length=64)
candidates = models.JSONField(default=list)
generation_metadata = models.JSONField(default=dict)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.work}: {self.candidate_count} scene ideas"
class BookRun(TimestampedModel):
"""Durable orchestration cursor for a book state, not prose state."""
book_state = models.ForeignKey(
BookStateVersion, on_delete=models.PROTECT, related_name="runs"
)
status = models.CharField(
max_length=16,
choices=BookRunStatus.choices,
default=BookRunStatus.PENDING,
db_index=True,
)
current_chapter_key = models.CharField(max_length=80, blank=True)
progress = models.JSONField(default=dict)
policy = models.JSONField(default=dict)
reviews = models.JSONField(default=dict, blank=True)
failure_reason = models.TextField(blank=True)
started_at = models.DateTimeField(null=True, blank=True)
finished_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
class SceneContextCitation(TimestampedModel):
scene = models.ForeignKey(
StandaloneScene, on_delete=models.CASCADE, related_name="context_citations"
)
passage = models.ForeignKey(
SourcePassage, on_delete=models.PROTECT, related_name="scene_citations"
)
rank = models.PositiveIntegerField()
score = models.FloatField(default=0)
reason = models.CharField(max_length=255, blank=True)
class Meta:
ordering = ["scene", "rank"]
constraints = [
models.UniqueConstraint(
fields=["scene", "passage"], name="unique_scene_context_passage"
),
models.UniqueConstraint(
fields=["scene", "rank"], name="unique_scene_context_rank"
),
]
class StoryBibleVersion(TimestampedModel):
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="bible_versions")
version = models.PositiveIntegerField()
content = models.TextField()
structured_canon = models.JSONField(default=dict, blank=True)
sha256 = models.CharField(max_length=64, blank=True)
approved_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["story", "version"], name="unique_story_bible_version")
]
def save(self, *args: object, **kwargs: object) -> None:
self.sha256 = text_sha256(self.content)
super().save(*args, **kwargs)
class OutlineVersion(TimestampedModel):
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="outline_versions")
version = models.PositiveIntegerField()
content = models.JSONField(default=dict)
sha256 = models.CharField(max_length=64, blank=True)
approved_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["story", "version"], name="unique_story_outline_version")
]
def save(self, *args: object, **kwargs: object) -> None:
import json
canonical = json.dumps(self.content, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
self.sha256 = text_sha256(canonical)
super().save(*args, **kwargs)
class PromptVersion(TimestampedModel):
name = models.CharField(max_length=160)
purpose = models.CharField(max_length=80)
version = models.PositiveIntegerField()
system_text = models.TextField(blank=True)
user_template = models.TextField()
config = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=False)
class Meta:
constraints = [
models.UniqueConstraint(fields=["name", "version"], name="unique_authoring_prompt_version"),
models.UniqueConstraint(
fields=["purpose"],
condition=Q(is_active=True),
name="unique_active_authoring_prompt_purpose",
),
]
class Chapter(TimestampedModel):
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="chapters")
number = models.PositiveIntegerField()
title = models.CharField(max_length=255)
status = models.CharField(
max_length=32, choices=ChapterStatus.choices, default=ChapterStatus.PLANNED
)
current_revision = models.ForeignKey(
"ChapterRevision",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="current_for_chapters",
)
class Meta:
ordering = ["number"]
constraints = [
models.UniqueConstraint(fields=["story", "number"], name="unique_story_chapter_number")
]
def __str__(self) -> str:
return f"{self.story.title} - Chapter {self.number}: {self.title}"
class GenerationContextSnapshot(TimestampedModel):
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="context_snapshots")
chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="context_snapshots")
story_bible = models.ForeignKey(StoryBibleVersion, on_delete=models.PROTECT)
outline = models.ForeignKey(OutlineVersion, on_delete=models.PROTECT)
prior_canon = models.ForeignKey(
"CanonSnapshot", on_delete=models.PROTECT, null=True, blank=True, related_name="derived_contexts"
)
content = models.JSONField(default=dict)
sha256 = models.CharField(max_length=64)
class ChapterRevision(TimestampedModel):
chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="revisions")
revision = models.PositiveIntegerField()
status = models.CharField(
max_length=32, choices=RevisionStatus.choices, default=RevisionStatus.DRAFT
)
parent = models.ForeignKey(
"self", on_delete=models.SET_NULL, null=True, blank=True, related_name="children"
)
source_revision = models.ForeignKey(
"self", on_delete=models.SET_NULL, null=True, blank=True, related_name="source_children"
)
story_bible = models.ForeignKey(StoryBibleVersion, on_delete=models.PROTECT)
outline = models.ForeignKey(OutlineVersion, on_delete=models.PROTECT)
context_snapshot = models.ForeignKey(
GenerationContextSnapshot,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="revisions",
)
scene_plan = models.JSONField(default=dict, blank=True)
prose = models.TextField(blank=True)
continuity_state = models.JSONField(default=dict, blank=True)
artifact_uri = models.TextField(blank=True)
word_count = models.PositiveIntegerField(default=0)
sha256 = models.CharField(max_length=64, blank=True)
graph_thread_id = models.CharField(max_length=255, blank=True, db_index=True)
generation_metadata = models.JSONField(default=dict, blank=True)
approved_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["chapter", "revision"], name="unique_chapter_revision_number"
)
]
def save(self, *args: object, **kwargs: object) -> None:
if self.prose:
import re
self.word_count = len(re.findall(r"\b\S+\b", self.prose))
self.sha256 = text_sha256(self.prose)
super().save(*args, **kwargs)
class ChapterContract(TimestampedModel):
revision = models.OneToOneField(
ChapterRevision, on_delete=models.CASCADE, related_name="contract"
)
entry_canon = models.ForeignKey(
"CanonSnapshot",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="chapter_contracts",
)
requirements = models.JSONField(default=list)
scene_plan_sha256 = models.CharField(max_length=64)
approved_at = models.DateTimeField(null=True, blank=True)
class ChapterStateDocument(TimestampedModel):
revision = models.OneToOneField(
ChapterRevision, on_delete=models.CASCADE, related_name="state_document"
)
contract = models.ForeignKey(
ChapterContract, on_delete=models.PROTECT, related_name="state_documents"
)
status = models.CharField(
max_length=32,
choices=StateDocumentStatus.choices,
default=StateDocumentStatus.EXTRACTED,
)
start_state = models.JSONField(default=dict)
observed_state = models.JSONField(default=dict)
proposed_delta = models.JSONField(default=list)
coverage = models.JSONField(default=dict)
verdict = models.CharField(max_length=32, blank=True)
json_artifact_uri = models.TextField(blank=True)
markdown_artifact_uri = models.TextField(blank=True)
sha256 = models.CharField(max_length=64)
model_metadata = models.JSONField(default=dict, blank=True)
validated_at = models.DateTimeField(null=True, blank=True)
committed_at = models.DateTimeField(null=True, blank=True)
class StoryEntity(TimestampedModel):
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="state_entities")
entity_key = models.CharField(max_length=200)
kind = models.CharField(max_length=64)
canonical_name = models.CharField(max_length=255)
aliases = models.JSONField(default=list, blank=True)
metadata = models.JSONField(default=dict, blank=True)
first_revision = models.ForeignKey(
ChapterRevision,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="introduced_state_entities",
)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["story", "entity_key"], name="unique_story_state_entity_key"
)
]
class StateChange(TimestampedModel):
state_document = models.ForeignKey(
ChapterStateDocument, on_delete=models.CASCADE, related_name="changes"
)
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="state_changes")
revision = models.ForeignKey(
ChapterRevision, on_delete=models.CASCADE, related_name="state_changes"
)
entity = models.ForeignKey(
StoryEntity, on_delete=models.PROTECT, null=True, blank=True, related_name="changes"
)
related_entity = models.ForeignKey(
StoryEntity,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="related_changes",
)
sequence = models.PositiveIntegerField()
change_type = models.CharField(max_length=80)
predicate = models.CharField(max_length=200)
operation = models.CharField(max_length=16, choices=StateOperation.choices)
previous_value = models.JSONField(null=True, blank=True)
new_value = models.JSONField(null=True, blank=True)
effective_chapter = models.PositiveIntegerField()
evidence_quote = models.TextField(blank=True)
evidence_location = models.CharField(max_length=255, blank=True)
status = models.CharField(
max_length=16,
choices=StateChangeStatus.choices,
default=StateChangeStatus.PROPOSED,
)
supersedes = models.ForeignKey(
"self",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="superseded_by",
)
metadata = models.JSONField(default=dict, blank=True)
sha256 = models.CharField(max_length=64)
class Meta:
ordering = ["effective_chapter", "sequence"]
constraints = [
models.UniqueConstraint(
fields=["state_document", "sequence"], name="unique_state_change_sequence"
)
]
class RequirementCheck(TimestampedModel):
state_document = models.ForeignKey(
ChapterStateDocument, on_delete=models.CASCADE, related_name="requirement_checks"
)
requirement_id = models.CharField(max_length=80)
requirement_type = models.CharField(max_length=32)
requirement_text = models.TextField()
status = models.CharField(max_length=24, choices=RequirementStatus.choices)
severity = models.CharField(max_length=16, choices=FindingSeverity.choices)
evidence_quote = models.TextField(blank=True)
evidence_location = models.CharField(max_length=255, blank=True)
details = models.TextField(blank=True)
model_metadata = models.JSONField(default=dict, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["state_document", "requirement_id"],
name="unique_state_document_requirement",
)
]
class CanonSnapshot(TimestampedModel):
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="canon_snapshots")
through_chapter = models.PositiveIntegerField()
version = models.PositiveIntegerField()
state = models.JSONField(default=dict)
source_revision = models.OneToOneField(
ChapterRevision, on_delete=models.PROTECT, related_name="committed_canon"
)
sha256 = models.CharField(max_length=64)
class Meta:
ordering = ["version"]
constraints = [
models.UniqueConstraint(fields=["story", "version"], name="unique_story_canon_version")
]
class EditorialFinding(TimestampedModel):
revision = models.ForeignKey(ChapterRevision, on_delete=models.CASCADE, related_name="findings")
review_kind = models.CharField(max_length=80)
severity = models.CharField(
max_length=16, choices=FindingSeverity.choices, default=FindingSeverity.INFO
)
category = models.CharField(max_length=80)
location = models.CharField(max_length=255, blank=True)
description = models.TextField()
suggested_revision = models.TextField(blank=True)
evidence = models.JSONField(default=dict, blank=True)
status = models.CharField(
max_length=16, choices=FindingStatus.choices, default=FindingStatus.OPEN
)
model_metadata = models.JSONField(default=dict, blank=True)

View file

@ -0,0 +1,528 @@
from __future__ import annotations
DEFAULT_PLAN_SYSTEM = """You are a developmental story architect. Return one valid JSON object only.
Preserve canon, exact chronology, relationship pacing, character agency, and required chapter beats.
Do not move important relationship development into montage."""
DEFAULT_PLAN_TEMPLATE = """Plan Chapter {chapter_number}: {chapter_title} as fully dramatized scenes.
Story bible:
{story_bible}
Chapter outline:
{chapter_outline}
Prior canon:
{prior_canon}
Legacy source draft (reference only; it is not canon and may contradict this outline):
{source_prose}
Human revision notes:
{human_notes}
Return atomic requirements. Mark only indispensable story events as required; staging, clothing, speaker choice,
incidental props, and optional texture must be required:false.
{{"day_start":"", "day_end":"", "target_words":5500, "chapter_constraints":[], "exact_values":[],
"forbidden_events":[], "final_image":"", "scenes":[{{"number":1,"purpose":"","location":"",
"present":[],"word_budget":1300,"beats":[{{"text":"","required":true}}],"ending_state":""}}], "forbidden_shortcuts":[]}}
"""
DEFAULT_DRAFT_SYSTEM = """Write polished adult progression-fantasy prose in close third person past tense.
Return finished chapter prose only. Keep dialogue clean and natural. Dramatize relationship milestones on page.
Do not turn slavery into a metaphor for employment, make constrained characters act automatically free,
or replace lived behavior with repeated moral speeches. Avoid legal and procedural story engines.
End with [[END_OF_CHAPTER]] on its own line."""
DEFAULT_SCENE_DRAFT_SYSTEM = """Write polished adult progression-fantasy prose in close third person past tense.
Return finished scene prose only. Keep dialogue clean and natural. Dramatize relationship milestones on page.
Do not turn slavery into a metaphor for employment, make constrained characters act automatically free,
or replace lived behavior with repeated moral speeches. Avoid legal and procedural story engines.
End with [[END_OF_SCENE]] on its own line."""
STANDALONE_SCENE_PLAN_SYSTEM = """You are a fiction scene architect. Return one valid JSON object only.
Preserve every supplied canon fact and source boundary. Plan a complete dramatized scene, not a synopsis.
Do not invent authority for provisional or planning sources, and do not silently resolve contradictions."""
STANDALONE_SCENE_PLAN_TEMPLATE = """Plan one complete scene titled {title}.
Scene brief:
{brief}
Cited source context:
{context}
Author constraints:
{constraints}
Forbidden events:
{forbidden_events}
Boundary constraints:
{boundary_constraints}
Target words: {target_words}
Return strict JSON in this shape:
{{"purpose":"","pov_character":"","tense":"past","location":"","time_context":"",
"present":[],"target_words":{target_words},"beats":[{{"text":"","required":true}}],
"exact_values":[],"constraints":[],"forbidden_events":[],"ending_state":"","final_image":"",
"boundary_constraints":[],"continuity_questions":[]}}
Use 3-8 concrete beats. Mark only indispensable events required:true. Preserve unresolved continuity questions
instead of guessing. The ending state and final image must define where the scene stops."""
SCENE_IDEA_TYPES = {
"quiet_connection": "A short, low-stakes character moment whose meaning comes from attention or choice.",
"major_turn": "A full dramatic turn that materially changes a goal, relationship, status, or commitment.",
"physical_escalation": (
"A chosen physical threshold materially advances intimacy, danger, combat, exertion, or vulnerability. "
"Routine care, incidental proximity, injury assistance, and helping someone dress or undress do not qualify."
),
"conflict_pressure": "Opposed wants, values, or tactics create direct pressure without requiring rupture.",
"boundary_choice": "A limit, permission, refusal, duty, or autonomy question is tested through action.",
"revelation_discovery": "New information or recognition changes what a character understands or can choose.",
"aftermath_consequence": "Characters absorb, interpret, or act on the concrete cost of an earlier event.",
"competence_task": "Work, craft, training, care, or problem-solving reveals character and changes conditions.",
"external_plot_action": "An outside objective, threat, journey, contest, or obstacle drives the scene.",
"ensemble_social": "A group, household, team, family, or public setting changes interpersonal dynamics.",
}
SCENE_IDEATION_SYSTEM = """You are a continuity-aware fiction development editor.
Return one valid JSON object only. Propose genuinely new standalone scene opportunities grounded in the
cited evidence. Preserve each source's authority label: canon is binding, planning is guidance, and
provisional material is not established fact. Do not draft prose, silently settle open questions, or
repeat an existing scene as a new proposal."""
SCENE_IDEATION_TEMPLATE = """Propose {candidate_count} distinct standalone scenes for {work_title}.
Development focus:
{focus}
Target book:
{target_book}
Cited source context:
{context}
Available scene types (choose exactly one primary type per candidate):
{scene_types}
Return strict JSON in this shape:
{{"candidates":[{{"title":"","brief":"","purpose":"","placement":"","pov_character":"",
"scene_type":"quiet_connection","type_fit":"","scope_fit":"","prerequisites":[],
"target_words":1800,"citations":["SRC-01"],
"opportunity":"","constraints":[],
"future_opportunities":[],"forbidden_events":[],"boundary_constraints":[],
"continuity_questions":[],"risks":[]}}]}}
Return exactly {candidate_count} candidates. Every candidate must cite at least one supplied source ID
and explain the unspent story question or opportunity it uses. For each candidate, list 2-4
future_opportunities that its ending creates, sharpens, or leaves newly available. These must be
consequential later possibilities, not promises, mandatory sequel hooks, or events completed inside
the proposed scene. Keep the brief concrete enough for a later scene planner, but preserve uncertain
chronology and unresolved continuity as questions. Prefer different dramatic functions, character
pairings, pressures, locations, and endings rather than cosmetic variations of one idea. Use distinct
scene types until every available type is represented; only then repeat a type.
The target book is a hard placement boundary. Every event, relationship state, location, role, ability,
object, and household condition required by the scene must exist by or during that book. Later-book canon
may constrain what the scene cannot resolve, but it cannot supply the scene's premise. If a cited passage
describes an event first occurring after the target book, do not use that event as a prerequisite. State
all prerequisites and explain scope_fit using supplied evidence. Do not propose a candidate whose scope fit
is uncertain; use a different candidate grounded inside the selected book.
The scene type must describe the scene's actual dramatic change, not its surface activity. Explain type_fit.
For physical_escalation, require a deliberate choice that crosses or sharply approaches a meaningful
established physical threshold and changes later possibilities. Routine caregiving, medical assistance,
incidental touch, bathing, changing clothes, or helping someone dress or undress is insufficient by itself."""
SCENE_IDEATION_COMPACT_TEMPLATE = """Propose {candidate_count} distinct standalone scenes for
{work_title}.
Development focus:
{focus}
Target book:
{target_book}
Cited source context:
{context}
Available scene types (choose exactly one primary type per candidate):
{scene_types}
Return strict JSON in this compact shape:
{{"candidates":[{{"title":"","brief":"","scene_type":"quiet_connection",
"citations":["SRC-01"],"opportunity":"","future_opportunities":[]}}]}}
Return exactly {candidate_count} candidates. Every candidate must cite supplied source IDs, state
the existing question or opportunity it spends, and list 2-4 consequential possibilities its
ending creates. The target book is a hard premise boundary: later-book evidence may constrain an
idea but cannot supply its prerequisite. Obey the complete governing documents and use distinct
requested scene types until all are represented; then repeat.
The brief must contain the concrete dramatic action and ending change, not planning notes or prose.
The scene type must describe the actual dramatic change rather than surface activity. A
physical_escalation must cross or sharply approach a meaningful established physical threshold,
reveal a person-specific independent choice, and change later possibilities. Routine care,
incidental touch, generic sensory experiments, clothing assistance, or proving competent consent
and stopping does not qualify."""
STANDALONE_SCENE_PROSE_SYSTEM = """Write polished, immersive fiction in the requested point of view and tense.
Return finished scene prose only. Treat cited context as evidence with the authority labels shown. Never promote
planning or provisional material into canon merely because it was retrieved. Obey the approved plan, constraints,
forbidden events, exact values, and ending boundary. End with [[END_OF_SCENE]] on its own line."""
STANDALONE_SCENE_PROSE_TEMPLATE = """Write the complete scene: {title}.
Scene brief:
{brief}
Cited context:
{context}
Approved scene plan:
{plan}
Frozen requirements:
{requirements}
Target {target_words} words. Do not add a scene heading, explain the plan, cite source IDs in prose, summarize
later events, or continue beyond the approved ending state and final image.
Return prose followed by [[END_OF_SCENE]] on its own line."""
STANDALONE_SCENE_REVIEW_SYSTEM = """You are a strict fiction continuity editor. Return one valid JSON object only.
Use only the supplied cited context, approved plan, frozen requirements, and actual prose. Do not invent repairs.
Every reported prose defect must include one exact contiguous quotation from the candidate scene."""
STANDALONE_SCENE_REVIEW_TEMPLATE = """Review this standalone scene.
Cited context:
{context}
Approved plan:
{plan}
Frozen requirements:
{requirements}
Candidate scene:
{prose}
Return strict JSON:
{{"passed":true,"requirement_results":[{{"requirement_id":"","status":"HIT|PARTIAL|MISSED|CONTRADICTED|UNVERIFIABLE","evidence_quote":"","details":""}}],
"findings":[{{"severity":"LOW|MEDIUM|HIGH|CRITICAL","category":"canon|chronology|spatial|object|financial|relationship|knowledge|boundary|logic|prose","evidence_quote":"exact prose substring","description":"","suggested_revision":""}}],
"observed_state":{{}},"proposed_changes":[]}}
Return exactly one result for every requirement ID. HIGH or CRITICAL contradictions, missing required beats,
forbidden events, unsupported canon claims, and boundary violations make passed false. A scene may validly have
no state changes."""
DEFAULT_DRAFT_TEMPLATE = """Write Chapter {chapter_number}: {chapter_title}.
Canon context:
{context}
Approved scene plan:
{scene_plan}
The approved plan is the exclusive event scope for this chapter. Obey every constraint and exact value.
Do not add later events, purchases, training, travel, relationship milestones, or hooks after its final scene.
Complete every planned scene without skipping important days, then stop at the specified final image.
Write 5,000-6,500 words. Use the scene word budgets to fully dramatize rather than summarize events.
Return prose followed by [[END_OF_CHAPTER]].
"""
DEFAULT_FULL_CHAPTER_DRAFT_TEMPLATE = """Write the complete Chapter {chapter_number}: {chapter_title}.
Previous chapter (read-only canon and voice reference; never contradict its events or physical details):
{source_chapter}
Current structured canon:
{structured_canon}
Approved chapter plan:
{scene_plan}
Write the entire chapter as continuous prose without scene headings. Treat the scene divisions as internal
structure, not separate stories: transitions must be natural, only the final scene may conclude the chapter,
and no scene may repeat an earlier scene's summary or closing thought. Preserve every exact value and stop
at the specified final image. Target 5,000-6,500 words.
Return prose followed by [[END_OF_CHAPTER]] on its own line.
"""
DEFAULT_SCENE_DRAFT_TEMPLATE = """Write scene {scene_number} of Chapter {chapter_number}: {chapter_title}.
Canon context:
{context}
Approved chapter plan:
{scene_plan}
Current scene contract:
{scene}
Previous chapter (read-only canon and voice reference; never contradict its events or physical details):
{source_chapter}
Style reference (match its narrative texture, not its events or wording):
{style_excerpt}
Tail of prose immediately before this scene:
{previous_tail}
Write only the current scene, targeting {target_words} words. Fulfill its required beats while preserving
chapter-level voice and momentum. Begin with a natural transition from the preceding prose, if any. Do not
repeat prior events, add a scene heading, summarize later scenes, or write beyond this scene's ending state.
Hard scene boundary: {boundary_constraints}
Return prose followed by [[END_OF_SCENE]] on its own line.
"""
DEFAULT_CONTINUITY_TEMPLATE = """Extract the complete chapter state and immutable state changes.
Return strict JSON only in this shape:
{{"schema_version":2,"through_chapter":{chapter_number},"state_document":{{"timeline":{{}},
"scene_end":{{}},"characters":{{}},"inventory":[],"money":[],"relationships":[],
"open_threads":[],"promises_and_constraints":[],"reveals":{{}},"chapter_summary":[]}},
"changes":[{{"entity_key":"character.corin.vale","entity_kind":"character",
"canonical_name":"Corin Vale","change_type":"MONEY_CHANGED","predicate":"finances.balance",
"operation":"SET|ADD|REMOVE|TRANSFER|OPEN|CLOSE","previous_value":null,"new_value":null,
"related_entity_key":"","evidence_quote":"exact prose substring","evidence_location":""}}],
"objective_findings":[{{"severity":"MEDIUM|HIGH|CRITICAL","category":"canon|chronology|exact_value|scene_boundary|premature_knowledge",
"location":"","evidence_quote":"one unique exact prose substring","description":"","suggested_revision":"minimal replacement",
"objective":true,"exact_patch_suitable":true}}]}}
Track changes to people, items, locations, organizations, accounts, relationships, promises, injuries,
knowledge, ownership, custody, money, magic, and plot threads. Use stable lowercase entity keys.
Every change needs an exact quotation from the chapter. Do not invent, repair, or infer unsupported facts.
Also report at most eight objective, material defects: contradictions with prior canon, chronology errors,
premature knowledge or state changes, wrong exact values, omitted required beats, violated constraints or forbidden
events, and writing beyond a planned scene/chapter boundary. Check every required beat, exact value, constraint,
forbidden event, forbidden shortcut, and the final image before returning no findings.
Do not report subjective prose preferences. Every finding must quote one unique exact prose substring.
Approved scene plan:
{scene_plan}
Prior canon:
{prior_canon}
Chapter:
{prose}
"""
DEFAULT_FINAL_STATE_TEMPLATE = """Extract only the compact final chapter state and immutable state changes.
Return strict JSON only in this shape:
{{"schema_version":2,"through_chapter":{chapter_number},"state_document":{{"timeline":{{}},
"scene_end":{{}},"chapter_summary":[],"open_threads":[]}},
"changes":[{{"entity_key":"character.corin.vale","entity_kind":"character",
"canonical_name":"Corin Vale","change_type":"STATE_CHANGED","predicate":"state",
"operation":"SET|ADD|REMOVE|TRANSFER|OPEN|CLOSE","previous_value":null,"new_value":null,
"related_entity_key":"","evidence_quote":"exact prose substring","evidence_location":""}}]}}
Return only facts changed by this chapter. Every change requires one exact contiguous prose quotation.
Copy previous_value exactly from prior canon when that predicate already exists; do not summarize or shorten it.
Do not perform editorial review and do not regenerate complete character, inventory, or relationship summaries.
Approved scene plan:
{scene_plan}
Prior canon:
{prior_canon}
Final chapter:
{prose}
"""
DEFAULT_QUALITY_REVIEW_TEMPLATE = """Review this complete chapter before state extraction.
Return strict JSON only:
{{"findings":[{{"severity":"MEDIUM|HIGH|CRITICAL","category":"chronology|continuity|logic|character|pacing|repetition|prose|contract",
"location":"","evidence_quote":"one unique exact chapter substring","description":"",
"suggested_revision":"minimal exact replacement for evidence_quote","objective":true,
"exact_patch_suitable":true}}]}}
Report at most six material defects. Check the exact handoff from the previous chapter, chronology, causal logic,
character agency and consent, physical condition, inventory, money, repeated thematic explanation, awkward
contract-like prose, all required beats and constraints, and the final image. Detect contradictions inside the
approved plan as well as contradictions between plan and prose. Do not report taste preferences. A patch is
suitable only when replacing one unique local passage can fix the issue without inventing unsupported facts.
Previous chapter:
{previous_chapter}
Approved plan:
{scene_plan}
Candidate chapter:
{prose}
"""
DEFAULT_STATE_JUDGE_TEMPLATE = """Judge the chapter against every frozen contract requirement.
Return strict JSON only:
{{"requirements":[{{"requirement_id":"", "status":"HIT|PARTIAL|MISSED|CONTRADICTED|UNVERIFIABLE", "evidence_quote":"exact prose substring", "evidence_location":"", "details":""}}],
"findings":[{{"severity":"LOW|MEDIUM|HIGH|CRITICAL","category":"","location":"","evidence_quote":"exact prose substring","description":"","suggested_revision":"","objective":true,"exact_patch_suitable":true}}],
"missing_state_changes":[{{"entity_key":"","entity_kind":"","canonical_name":"",
"change_type":"","predicate":"","operation":"SET|ADD|REMOVE|TRANSFER|OPEN|CLOSE",
"previous_value":null,"new_value":null,"related_entity_key":"","evidence_quote":"exact prose substring",
"evidence_location":""}}]}}
Return exactly one result for every requirement ID, in the supplied order. HIT means a positive beat occurred
or a prohibition/constraint was obeyed. Every HIT or PARTIAL result needs an exact contiguous quotation from
the prose. Mark unsupported claims UNVERIFIABLE. Any PARTIAL, MISSED, CONTRADICTED, or UNVERIFIABLE item
is a defect to report. Check exact chronology, arithmetic, ownership, injuries, knowledge, promises, scene
boundaries, forbidden events, forbidden montage, and the final image.
Also perform one holistic continuity, character, pacing, and prose audit. Return at most eight concrete findings.
Objective means a demonstrable canon, logic, continuity, chronology, or scene-execution defect, not a taste
preference. Mark exact_patch_suitable only when a small local edit can fix it. LOW style preferences must not
be objective.
Also compare the extracted state and proposed changes with the prose. Return every material person, item,
ownership, custody, money, injury, knowledge, relationship, promise, location, magic, and plot-thread change
missing from the proposed delta. Do not repeat changes already present.
Frozen contract:
{contract}
Prior approved state:
{prior_state}
Extracted chapter state:
{observed_state}
Proposed state changes:
{proposed_delta}
Approved scene plan:
{scene_plan}
Chapter prose:
{prose}
"""
DEFAULT_REVIEW_TEMPLATE = """Review this chapter as the {review_kind} editor.
Return one JSON object: {{"findings":[{{"severity":"LOW|MEDIUM|HIGH|CRITICAL","category":"","location":"","description":"","suggested_revision":""}}]}}.
Report only concrete issues. Check against the supplied canon and scene plan. For character review, verify that
power, freedom, consent, and conditioned behavior are shown consistently without making characters meek.
For pacing review, reject important days or relationship milestones summarized in montage. For continuity,
check chronology, injuries, money, possessions, magic, and prior promises.
Context:
{context}
Scene plan:
{scene_plan}
Chapter:
{prose}
"""
BOOK_STRUCTURE_REVIEW_SYSTEM = """You are a strict developmental fiction editor. Return one valid JSON object
only. Judge the supplied approved planning contracts for structural coherence; do not draft prose or invent
missing canon. HIGH and CRITICAL findings are blocking."""
BOOK_STRUCTURE_REVIEW_TEMPLATE = """Perform a {review_level} review of this book state.
Book state (complete for manuscript review, act slice for act review):
{state}
Return strict JSON:
{{"findings":[{{"severity":"INFO|LOW|MEDIUM|HIGH|CRITICAL",
"category":"structure|continuity|chronology|character|plot|pacing|contract|canon|logic|relationship|other",
"chapter_key":"","description":"","suggested_revision":""}}]}}
Use only chapter keys present in the supplied state. Report concrete contract defects, causal gaps, impossible
dependencies, misplaced reveals, broken arc progression, pacing failures, or contradictory ending states.
Return an empty findings list when there are no material defects."""
BOOK_CONTINUITY_REVIEW_SYSTEM = """You are a strict fiction continuity editor. Return one valid JSON object
only. Compare the ordered approved scene packets with the book continuity ledger. Do not rewrite prose or infer
facts not established by the supplied material. HIGH and CRITICAL findings are blocking."""
BOOK_CONTINUITY_REVIEW_TEMPLATE = """Review continuity across these ordered book-state scene placements.
Continuity ledger and chapter contracts:
{state}
Ordered approved scene packets:
{scene_packets}
Return strict JSON:
{{"findings":[{{"severity":"INFO|LOW|MEDIUM|HIGH|CRITICAL",
"category":"continuity|chronology|canon|character|relationship|location|object|injury|route|promise|money|logic|other",
"chapter_key":"","description":"","suggested_revision":""}}]}}
Use only supplied chapter keys. Check establishment and resolution order, knowledge, injuries, routes, custody,
objects, money, promises, relationships, locations, and scene-to-scene state. Return an empty findings list when
there are no material defects."""
DEFAULT_TARGETED_VERIFICATION_TEMPLATE = """Verify only the supplied findings and contract requirements
against the revised chapter. Do not search for or report new issues. Return strict JSON only:
{{"finding_results":[{{"finding_id":"","status":"RESOLVED|UNRESOLVED|UNVERIFIABLE","evidence_quote":"exact prose substring","details":""}}],
"requirement_results":[{{"requirement_id":"","status":"HIT|PARTIAL|MISSED|CONTRADICTED|UNVERIFIABLE","evidence_quote":"exact prose substring","evidence_location":"","details":""}}]}}
Findings to verify:
{findings}
Contract requirements to verify:
{requirements}
Changed passages:
{changed_passages}
Revised chapter:
{prose}
"""
DEFAULT_REPAIR_PLAN_TEMPLATE = """Create a precise structural repair plan for the chapter.
Return strict JSON only. Preserve unaffected scenes and specify exact corrections, required values,
chronology, scene boundaries, and the intended final image. Do not write prose.
Context:
{context}
Approved scene plan:
{scene_plan}
Findings:
{findings}
Current chapter:
{prose}
"""
DEFAULT_REVISION_TEMPLATE = """Rewrite the chapter according to the approved scene plan and repair plan.
Preserve strong prose, natural dialogue, established scenes, and all unaffected details. Do not mention revision.
Return the full revised chapter followed by [[END_OF_CHAPTER]].
Context:
{context}
Scene plan:
{scene_plan}
Findings:
{findings}
Sol repair plan:
{repair_plan}
Current chapter:
{prose}
"""
DEFAULT_PATCH_REVISION_TEMPLATE = """Patch only the passages required by the concrete findings below.
Return strict JSON only in this shape:
{{"edits":[{{"old_text":"exact unique text copied from the chapter","new_text":"replacement text"}}]}}
Each old_text must occur exactly once in the original chapter. Edits may not overlap. Keep total touched text
under five percent of the chapter. Do not rewrite, summarize, reformat, or return unchanged chapter text.
Address only the supplied findings. Do not perform additional polishing.
Findings:
{findings}
Human notes:
{human_notes}
Current chapter:
{prose}
"""

View file

@ -0,0 +1,96 @@
from __future__ import annotations
from typing import Any
from django.utils import timezone
from control_plane.authoring.models import ChapterRevision
from graph.bootstrap import champion_story_authoring_graph_v2
from graph.models import GraphRun, GraphRunStatus
class StoryWorkflowRunner:
def __init__(self, workflow: object) -> None:
self.workflow = workflow
def start(self, revision: ChapterRevision, *, max_revisions: int = 2) -> GraphRun:
version = champion_story_authoring_graph_v2()
graph_run = GraphRun.objects.create(
execution_graph_version=version,
project=revision.chapter.story.project,
status=GraphRunStatus.RUNNING,
started_at=timezone.now(),
current_node="build_context",
metadata={
"revision_id": str(revision.id),
"initial_revision_id": str(revision.id),
"current_revision_id": str(revision.id),
},
)
thread_id = f"story:{revision.chapter.story_id}:chapter:{revision.chapter.number}:revision:{revision.id}"
revision.graph_thread_id = thread_id
revision.save(update_fields=["graph_thread_id", "updated_at"])
initial = {
"story_id": str(revision.chapter.story_id),
"chapter_id": str(revision.chapter_id),
"revision_id": str(revision.id),
"graph_run_id": graph_run.id,
"thread_id": thread_id,
"editorial_finding_ids": [],
"patch_finding_ids": [],
"patch_attempted": False,
"patch_status": "not_needed",
"verification_status": "not_needed",
}
return self._invoke(graph_run, initial)
def resume(self, graph_run_id: int, decision: dict[str, Any]) -> GraphRun:
from langgraph.types import Command
graph_run = GraphRun.objects.get(id=graph_run_id)
if graph_run.status == GraphRunStatus.CANCELLED:
raise RuntimeError("cancelled story runs cannot be resumed")
graph_run.status = GraphRunStatus.RUNNING
graph_run.failure_reason = ""
graph_run.save(update_fields=["status", "failure_reason", "updated_at"])
value = None if decision.get("action") == "retry" else Command(resume=decision)
return self._invoke(graph_run, value)
def _invoke(self, graph_run: GraphRun, value: object) -> GraphRun:
thread_id = ChapterRevision.objects.get(
id=graph_run.metadata["revision_id"]
).graph_thread_id
config = {"configurable": {"thread_id": thread_id}}
try:
self.workflow.invoke(value, config=config)
snapshot = self.workflow.get_state(config)
except Exception as exc:
graph_run.status = GraphRunStatus.FAILED
graph_run.failure_reason = str(exc)[:4000]
graph_run.completed_at = timezone.now()
graph_run.save(
update_fields=["status", "failure_reason", "completed_at", "updated_at"]
)
raise
next_nodes = tuple(snapshot.next or ())
current_revision_id = str(snapshot.values.get("revision_id") or graph_run.metadata["revision_id"])
graph_run.metadata = {
**graph_run.metadata,
"current_revision_id": current_revision_id,
}
if next_nodes:
graph_run.status = GraphRunStatus.PAUSED
graph_run.current_node = str(next_nodes[0])
graph_run.failure_reason = "AWAITING_STORY_APPROVAL"
graph_run.save(
update_fields=["status", "current_node", "failure_reason", "metadata", "updated_at"]
)
else:
graph_run.status = GraphRunStatus.COMPLETE
graph_run.current_node = "complete"
graph_run.completed_at = timezone.now()
graph_run.metadata = {**graph_run.metadata, "final_state": dict(snapshot.values)}
graph_run.save(
update_fields=["status", "current_node", "completed_at", "metadata", "updated_at"]
)
return graph_run

View file

@ -0,0 +1,387 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from django.db.models import Q
from control_plane.authoring.models import (
DocumentAuthority,
SourceDocumentVersion,
SourcePassage,
Work,
WorkType,
)
from control_plane.authoring.state_management import json_sha256
STOP_WORDS = {
"and",
"are",
"about",
"after",
"again",
"also",
"before",
"being",
"between",
"but",
"could",
"for",
"from",
"has",
"her",
"him",
"his",
"have",
"into",
"its",
"must",
"not",
"scene",
"she",
"should",
"that",
"the",
"their",
"them",
"then",
"there",
"they",
"this",
"through",
"what",
"when",
"where",
"which",
"while",
"with",
"would",
"was",
"were",
"write",
}
@dataclass(frozen=True)
class RankedPassage:
passage: SourcePassage
score: float
reason: str
def query_terms(query: str, limit: int = 16) -> list[str]:
counts: dict[str, int] = {}
for token in re.findall(r"[a-zA-Z][a-zA-Z0-9']{2,}", query.lower()):
if token in STOP_WORDS:
continue
counts[token] = counts.get(token, 0) + 1
ordered = sorted(
counts.items(), key=lambda item: (-item[1], -len(item[0]), item[0])
)
return [token for token, _count in ordered[:limit]]
def retrieve_scene_passages(
*,
work: Work,
query: str,
authorities: list[str] | None = None,
pinned_document_keys: list[str] | None = None,
limit: int = 24,
) -> list[RankedPassage]:
authorities = authorities or [DocumentAuthority.CANON]
invalid = sorted(set(authorities) - set(DocumentAuthority.values))
if invalid:
raise ValueError(f"unsupported document authorities: {', '.join(invalid)}")
pinned = {value.strip() for value in (pinned_document_keys or []) if value.strip()}
terms = query_terms(query)
visible_work_ids = list(
Work.objects.filter(series=work.series, work_type=WorkType.SERIES_REFERENCE).values_list(
"id", flat=True
)
)
visible_work_ids.append(work.id)
base = SourcePassage.objects.select_related(
"document_version__document"
).filter(
document_version__document__work_id__in=visible_work_ids,
document_version__authority__in=authorities,
document_version__superseded_by__isnull=True,
)
candidates: dict[object, SourcePassage] = {}
if terms:
term_filter = Q()
for term in terms:
term_filter |= Q(content__icontains=term)
term_filter |= Q(document_version__document__title__icontains=term)
for passage in base.filter(term_filter)[:4000]:
candidates[passage.id] = passage
if pinned:
for passage in base.filter(document_version__document__logical_key__in=pinned)[:2000]:
candidates[passage.id] = passage
if not candidates:
for passage in base[:300]:
candidates[passage.id] = passage
ranked: list[RankedPassage] = []
lowered_query = query.lower()
for passage in candidates.values():
document = passage.document_version.document
haystack = passage.content.lower()
identity = f"{document.logical_key} {document.title}".lower()
score = 0.0
matched = []
for term in terms:
occurrences = haystack.count(term)
if occurrences:
score += 1.0 + min(occurrences, 4) * 0.5
matched.append(term)
if term in identity:
score += 4.0
if document.logical_key in pinned:
score += 100.0
if passage.document_version.authority == DocumentAuthority.CANON:
score += 2.0
if document.title.lower() in lowered_query:
score += 5.0
reason = "pinned" if document.logical_key in pinned else "terms: " + ", ".join(matched[:6])
ranked.append(RankedPassage(passage=passage, score=score, reason=reason.strip()))
ranked.sort(
key=lambda item: (
-item.score,
item.passage.document_version.document.logical_key,
item.passage.ordinal,
)
)
requested_limit = max(1, limit)
if not pinned:
if len(authorities) == 1:
return ranked[:requested_limit]
return _select_across_authorities(ranked, authorities, requested_limit)
pinned_ranked = [
item
for item in ranked
if item.passage.document_version.document.logical_key in pinned
]
other_ranked = [
item
for item in ranked
if item.passage.document_version.document.logical_key not in pinned
]
if not other_ranked:
return ranked[:requested_limit]
pinned_budget = min(len(pinned_ranked), max(1, requested_limit * 2 // 3))
found_pinned_keys = {
item.passage.document_version.document.logical_key for item in pinned_ranked
}
per_document_limit = max(
1,
(pinned_budget + len(found_pinned_keys) - 1) // max(1, len(found_pinned_keys)),
)
selected: list[RankedPassage] = []
pinned_counts: dict[str, int] = {}
for item in pinned_ranked:
document_key = item.passage.document_version.document.logical_key
if len(selected) >= pinned_budget:
break
if pinned_counts.get(document_key, 0) >= per_document_limit:
continue
selected.append(item)
pinned_counts[document_key] = pinned_counts.get(document_key, 0) + 1
selected.extend(
_select_across_authorities(
other_ranked,
authorities,
requested_limit - len(selected),
)
)
selected_ids = {item.passage.id for item in selected}
for item in ranked:
if len(selected) >= requested_limit:
break
if item.passage.id not in selected_ids:
selected.append(item)
selected_ids.add(item.passage.id)
return selected
def _select_across_authorities(
ranked: list[RankedPassage], authorities: list[str], limit: int
) -> list[RankedPassage]:
authority_groups = {
authority: [
item
for item in ranked
if item.passage.document_version.authority == authority
]
for authority in authorities
}
offsets = {authority: 0 for authority in authorities}
selected: list[RankedPassage] = []
while len(selected) < limit:
added = False
for authority in authorities:
offset = offsets[authority]
group = authority_groups[authority]
if offset >= len(group):
continue
selected.append(group[offset])
offsets[authority] += 1
added = True
if len(selected) >= limit:
break
if not added:
break
selected_ids = {item.passage.id for item in selected}
for item in ranked:
if len(selected) >= limit:
break
if item.passage.id not in selected_ids:
selected.append(item)
selected_ids.add(item.passage.id)
return selected
def build_scene_context_pack(
*,
work: Work,
query: str,
authorities: list[str] | None = None,
pinned_document_keys: list[str] | None = None,
governing_document_keys: list[str] | None = None,
limit: int = 24,
max_chars: int = 50000,
) -> tuple[dict, list[RankedPassage]]:
authorities = authorities or [DocumentAuthority.CANON]
governing_keys = list(
dict.fromkeys(
value.strip() for value in (governing_document_keys or []) if value.strip()
)
)
ranked = retrieve_scene_passages(
work=work,
query=query,
authorities=authorities,
pinned_document_keys=pinned_document_keys,
limit=limit,
)
citations = []
rendered = []
used_chars = 0
kept: list[RankedPassage] = []
if governing_keys:
visible_work_ids = list(
Work.objects.filter(
series=work.series,
work_type=WorkType.SERIES_REFERENCE,
).values_list("id", flat=True)
)
visible_work_ids.append(work.id)
versions = list(
SourceDocumentVersion.objects.select_related("document")
.filter(
document__work_id__in=visible_work_ids,
document__logical_key__in=governing_keys,
authority__in=authorities,
superseded_by__isnull=True,
)
.order_by("document__logical_key")
)
versions_by_key: dict[str, list[SourceDocumentVersion]] = {}
for version in versions:
versions_by_key.setdefault(version.document.logical_key, []).append(version)
missing = [key for key in governing_keys if key not in versions_by_key]
ambiguous = [key for key, values in versions_by_key.items() if len(values) > 1]
if missing:
raise ValueError("governing documents not found: " + ", ".join(missing))
if ambiguous:
raise ValueError("governing document keys are ambiguous: " + ", ".join(ambiguous))
for key in governing_keys:
version = versions_by_key[key][0]
document = version.document
label = f"SRC-{len(citations) + 1:02d}"
end_line = version.content.count("\n") + 1
block = (
f"[{label}] authority={version.authority} source={document.logical_key} "
f"version={version.version} lines=1-{end_line} scope=governing-document\n"
f"{version.content}"
)
if used_chars + len(block) > max_chars:
raise ValueError("governing documents exceed the context character budget")
used_chars += len(block)
rendered.append(block)
citations.append(
{
"id": label,
"kind": "governing_document",
"passage_id": None,
"document_version_id": str(version.id),
"document_key": document.logical_key,
"document_title": document.title,
"document_version": version.version,
"authority": version.authority,
"source_path": version.source_path,
"start_line": 1,
"end_line": end_line,
"start_char": 0,
"end_char": len(version.content),
"sha256": version.source_sha256,
"score": None,
"reason": "governing document supplied in full",
}
)
governing_set = set(governing_keys)
for item in ranked:
passage = item.passage
version = passage.document_version
document = version.document
if document.logical_key in governing_set:
continue
excerpt = passage.content[:2500]
label = f"SRC-{len(citations) + 1:02d}"
block = (
f"[{label}] authority={version.authority} source={document.logical_key} "
f"version={version.version} lines={passage.start_line}-{passage.end_line}\n{excerpt}"
)
if rendered and used_chars + len(block) > max_chars:
continue
used_chars += len(block)
kept.append(item)
citations.append(
{
"id": label,
"passage_id": str(passage.id),
"document_key": document.logical_key,
"document_title": document.title,
"document_version": version.version,
"authority": version.authority,
"source_path": version.source_path,
"start_line": passage.start_line,
"end_line": passage.end_line,
"start_char": passage.start_char,
"end_char": passage.end_char,
"sha256": passage.sha256,
"score": item.score,
"reason": item.reason,
}
)
rendered.append(block)
pack = {
"schema_version": 1,
"work_id": str(work.id),
"query": query,
"authorities": authorities,
"governing_document_keys": governing_keys,
"citations": citations,
"rendered_context": (
"\n\n".join(rendered)
if rendered
else "(No matching approved source passages.)"
),
}
pack["sha256"] = json_sha256(pack)
return pack, kept

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,197 @@
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path
from django.db import transaction
from django.db.models import Max
from control_plane.authoring.models import (
DocumentAuthority,
DocumentType,
SourceDocument,
SourceDocumentVersion,
SourcePassage,
Work,
)
SUPPORTED_SOURCE_SUFFIXES = {".json", ".log", ".md", ".txt"}
@dataclass(frozen=True)
class SourceRegistrationResult:
path: Path
logical_key: str
status: str
source_sha256: str
version: int | None = None
passage_count: int = 0
def discover_source_paths(root: Path, include_globs: list[str] | None = None) -> list[Path]:
root = root.resolve()
if root.is_file():
supported = root.suffix.lower() in SUPPORTED_SOURCE_SUFFIXES
included = not include_globs or any(
fnmatch(root.name, pattern) for pattern in include_globs
)
return [root] if supported and included else []
return sorted(
path.resolve()
for path in root.rglob("*")
if path.is_file()
and path.suffix.lower() in SUPPORTED_SOURCE_SUFFIXES
and (
not include_globs
or any(fnmatch(path.relative_to(root).as_posix(), pattern) for pattern in include_globs)
)
)
def source_logical_key(path: Path, root: Path) -> str:
path = path.resolve()
root = root.resolve()
if root.is_file():
return path.name
return path.relative_to(root).as_posix()
def source_title(path: Path, content: str) -> str:
if path.suffix.lower() == ".md":
match = re.search(r"^#{1,6}\s+(.+?)\s*$", content, flags=re.MULTILINE)
if match:
return match.group(1).strip()
return path.stem.replace("-", " ").replace("_", " ").strip().title()
def passage_spans(content: str) -> list[dict[str, int | str]]:
lines = content.splitlines(keepends=True)
if not lines and content:
lines = [content]
passages: list[dict[str, int | str]] = []
block_start_line: int | None = None
block_start_char: int | None = None
block_end_line = 0
block_end_char = 0
cursor = 0
def finish_block() -> None:
nonlocal block_start_line, block_start_char
if block_start_line is None or block_start_char is None:
return
passage_content = content[block_start_char:block_end_char]
passages.append(
{
"ordinal": len(passages) + 1,
"start_line": block_start_line,
"end_line": block_end_line,
"start_char": block_start_char,
"end_char": block_end_char,
"content": passage_content,
"sha256": hashlib.sha256(passage_content.encode("utf-8")).hexdigest(),
}
)
block_start_line = None
block_start_char = None
for line_number, line in enumerate(lines, start=1):
content_end = cursor + len(line.rstrip("\r\n"))
if line.strip():
if block_start_line is None:
block_start_line = line_number
block_start_char = cursor
block_end_line = line_number
block_end_char = content_end
else:
finish_block()
cursor += len(line)
finish_block()
return passages
def inspect_source(path: Path, root: Path) -> SourceRegistrationResult:
raw = path.read_bytes()
content = raw.decode("utf-8")
return SourceRegistrationResult(
path=path.resolve(),
logical_key=source_logical_key(path, root),
status="discovered",
source_sha256=hashlib.sha256(raw).hexdigest(),
passage_count=len(passage_spans(content)),
)
@transaction.atomic
def register_source(
*,
work: Work,
path: Path,
root: Path,
authority: str,
document_type: str = DocumentType.OTHER,
) -> SourceRegistrationResult:
if authority not in DocumentAuthority.values:
raise ValueError(f"unsupported document authority: {authority}")
if document_type not in DocumentType.values:
raise ValueError(f"unsupported document type: {document_type}")
path = path.resolve()
raw = path.read_bytes()
content = raw.decode("utf-8")
digest = hashlib.sha256(raw).hexdigest()
logical_key = source_logical_key(path, root)
document, _ = SourceDocument.objects.get_or_create(
work=work,
logical_key=logical_key,
defaults={
"title": source_title(path, content),
"document_type": document_type,
},
)
if document.document_type != document_type:
raise ValueError(
f"source {logical_key} is already registered as {document.document_type}, "
f"not {document_type}"
)
latest = document.versions.order_by("-version").first()
if (
latest
and latest.source_sha256 == digest
and latest.authority == authority
and latest.source_path == str(path)
):
return SourceRegistrationResult(
path=path,
logical_key=logical_key,
status="unchanged",
source_sha256=digest,
version=latest.version,
passage_count=latest.passages.count(),
)
version_number = (document.versions.aggregate(value=Max("version"))["value"] or 0) + 1
version = SourceDocumentVersion.objects.create(
document=document,
version=version_number,
authority=authority,
source_path=str(path),
content=content,
source_sha256=digest,
byte_size=len(raw),
supersedes=latest,
)
spans = passage_spans(content)
SourcePassage.objects.bulk_create(
[SourcePassage(document_version=version, **span) for span in spans]
)
return SourceRegistrationResult(
path=path,
logical_key=logical_key,
status="created" if latest is None else "versioned",
source_sha256=digest,
version=version_number,
passage_count=len(spans),
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
from __future__ import annotations
from typing import Any, TypedDict
class StoryGraphState(TypedDict, total=False):
story_id: str
chapter_id: str
revision_id: str
graph_run_id: int
thread_id: str
context_snapshot_id: str
state_document_id: str
scene_plan: dict[str, Any]
editorial_finding_ids: list[str]
patch_finding_ids: list[str]
patch_attempted: bool
patch_decision: str
patch_status: str
patch_source_revision_id: str
patch_change_ratio: float
changed_passages: list[dict[str, Any]]
verification_status: str
state_judge_status: str
approval_action: str
human_notes: str
canon_snapshot_id: str
export_uri: str

View file

@ -0,0 +1,279 @@
from __future__ import annotations
import copy
import hashlib
import json
import re
from typing import Any
def canonical_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def json_sha256(value: Any) -> str:
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
def normalize_entity_key(kind: str, name: str, supplied: str = "") -> str:
value = supplied.strip().lower() or f"{kind}.{name}"
value = re.sub(r"[^a-z0-9]+", ".", value).strip(".")
return value[:200] or "book.state"
def build_contract_requirements(
scene_plan: dict[str, Any], *, max_required_per_scene: int | None = 3
) -> list[dict[str, Any]]:
requirements: list[dict[str, Any]] = []
for scene_index, scene in enumerate(scene_plan.get("scenes") or [], start=1):
number = int(scene.get("number") or scene_index)
beats = scene.get("beats") or []
requested_required = [
index
for index, beat in enumerate(beats)
if isinstance(beat, dict) and bool(beat.get("required"))
]
allowed_required = set(requested_required)
if (
max_required_per_scene is not None
and len(requested_required) > max_required_per_scene
):
if max_required_per_scene < 1:
allowed_required = set()
elif max_required_per_scene == 1:
allowed_required = {requested_required[0]}
elif max_required_per_scene == 2:
allowed_required = {requested_required[0], requested_required[-1]}
else:
step = (len(requested_required) - 1) / (max_required_per_scene - 1)
selected = {
requested_required[round(index * step)]
for index in range(max_required_per_scene)
}
allowed_required = selected
for beat_index, beat in enumerate(beats, start=1):
if isinstance(beat, dict):
text = str(beat.get("text") or "")
required = (beat_index - 1) in allowed_required
else:
text = str(beat)
required = False
requirements.append(
{
"id": f"S{number:02d}-B{beat_index:02d}",
"type": "BEAT",
"text": text,
"severity": "HIGH" if required else "MEDIUM",
"blocking": required,
"required": required,
}
)
ending = str(scene.get("ending_state") or "").strip()
if ending:
requirements.append(
{
"id": f"S{number:02d}-END",
"type": "ENDING_STATE",
"text": ending,
"severity": "MEDIUM",
"blocking": False,
"required": False,
}
)
groups = [
("VALUE", "EXACT_VALUE", "exact_values", "CRITICAL", True),
("FORBID", "FORBIDDEN_EVENT", "forbidden_events", "CRITICAL", True),
("BOUNDARY", "SCENE_BOUNDARY", "boundary_constraints", "HIGH", True),
("SHORTCUT", "FORBIDDEN_SHORTCUT", "forbidden_shortcuts", "MEDIUM", False),
("CONSTRAINT", "CHAPTER_CONSTRAINT", "chapter_constraints", "MEDIUM", False),
]
for prefix, kind, field, severity, blocking in groups:
for index, value in enumerate(scene_plan.get(field) or [], start=1):
requirements.append(
{
"id": f"{prefix}-{index:02d}",
"type": kind,
"text": str(value),
"severity": severity,
"blocking": blocking,
"required": blocking,
}
)
for requirement_id, field in [("TIME-START", "day_start"), ("TIME-END", "day_end")]:
value = str(scene_plan.get(field) or "").strip()
if value:
requirements.append(
{
"id": requirement_id,
"type": "CHRONOLOGY",
"text": value,
"severity": "CRITICAL",
"blocking": True,
"required": True,
}
)
final_image = str(scene_plan.get("final_image") or "").strip()
if final_image:
requirements.append(
{
"id": "FINAL-IMAGE",
"type": "FINAL_IMAGE",
"text": final_image,
"severity": "MEDIUM",
"blocking": False,
"required": False,
}
)
return requirements
def requirement_is_blocking(requirement: dict[str, Any]) -> bool:
return bool(requirement.get("blocking"))
def evidence_is_present(prose: str, quote: str) -> bool:
quote = quote.strip()
if not quote:
return False
if quote in prose:
return True
normalized_quote = re.sub(r"[\W_]+", " ", quote.casefold()).strip()
normalized_prose = re.sub(r"[\W_]+", " ", prose.casefold()).strip()
if len(normalized_quote.split()) >= 4 and normalized_quote in normalized_prose:
return True
fragments = [
re.sub(r"[\W_]+", " ", fragment.casefold()).strip()
for fragment in re.split(r"[.!?]+", quote)
]
fragments = [fragment for fragment in fragments if len(fragment.split()) >= 2]
if len(fragments) < 2:
return False
first = normalized_prose.find(fragments[0])
if first < 0:
return False
cursor = first + len(fragments[0])
for fragment in fragments[1:]:
position = normalized_prose.find(fragment, cursor)
if position < 0:
return False
cursor = position + len(fragment)
return cursor - first <= len(normalized_quote) * 2 + 120
def apply_state_changes(
prior_state: dict[str, Any],
changes: list[dict[str, Any]],
*,
through_chapter: int,
chapter_state: dict[str, Any],
) -> dict[str, Any]:
if prior_state.get("schema_version") == 2 and isinstance(prior_state.get("entities"), dict):
state = copy.deepcopy(prior_state)
else:
state = {
"schema_version": 2,
"through_chapter": max(0, through_chapter - 1),
"entities": {},
"book": {"legacy_state": copy.deepcopy(prior_state)} if prior_state else {},
}
entities = state.setdefault("entities", {})
missing = object()
for change in sorted(changes, key=lambda item: int(item.get("sequence") or 0)):
key = str(change.get("entity_key") or "book.state")
entity = entities.setdefault(
key,
{
"kind": str(change.get("entity_kind") or "book"),
"name": str(change.get("canonical_name") or key),
"facts": {},
},
)
facts = entity.setdefault("facts", {})
path = [part for part in str(change.get("predicate") or "state").split(".") if part]
target = facts
for part in path[:-1]:
target = target.setdefault(part, {})
leaf = path[-1] if path else "state"
current = target.get(leaf, missing)
previous = change.get("previous_value")
if current is not missing and previous is not None and current != previous:
raise ValueError(
f"state change {change.get('sequence')} expected {key}.{'.'.join(path)} "
f"to be {previous!r}, found {current!r}"
)
operation = str(change.get("operation") or "SET").upper()
new_value = copy.deepcopy(change.get("new_value"))
related = str(change.get("related_entity_key") or "").strip()
if operation == "ADD":
values = [] if current is missing or current is None else list(current)
additions = new_value if isinstance(new_value, list) else [new_value]
for value in additions:
if value not in values:
values.append(value)
target[leaf] = values
elif operation == "REMOVE":
values = [] if current is missing or current is None else list(current)
removals = new_value if isinstance(new_value, list) else [new_value]
target[leaf] = [value for value in values if value not in removals]
elif operation == "OPEN":
target[leaf] = new_value if new_value is not None else "OPEN"
elif operation == "CLOSE":
target[leaf] = new_value if new_value is not None else "CLOSED"
elif operation == "TRANSFER":
if not related:
raise ValueError(
f"state change {change.get('sequence')} cannot TRANSFER without "
"related_entity_key"
)
if new_value not in (None, "", related):
raise ValueError(
f"state change {change.get('sequence')} TRANSFER destination "
f"{new_value!r} does not match related entity {related!r}"
)
target[leaf] = related
else:
target[leaf] = new_value
if related:
entity.setdefault("relations", {})[str(change.get("predicate") or "related")] = related
state["through_chapter"] = through_chapter
state["chapter_state"] = copy.deepcopy(chapter_state)
return state
def render_state_markdown(document: dict[str, Any]) -> str:
coverage = document.get("coverage") or {}
changes = document.get("proposed_delta") or []
lines = [
f"# Chapter {document.get('through_chapter', '')} State",
"",
f"Verdict: **{document.get('verdict') or 'PENDING'}**",
"",
"## Requirement Coverage",
"",
]
for check in coverage.get("requirements") or []:
lines.append(
f"- `{check.get('requirement_id', '')}` **{check.get('status', '')}**: "
f"{check.get('requirement_text', '')}"
)
if check.get("evidence_quote"):
lines.append(f" Evidence: {check['evidence_quote']}")
lines.extend(["", "## State Changes", ""])
for change in changes:
lines.append(
f"- `{change.get('entity_key', 'book.state')}.{change.get('predicate', 'state')}` "
f"{change.get('operation', 'SET')}: {change.get('previous_value')!r} -> "
f"{change.get('new_value')!r}"
)
lines.extend(
[
"",
"## Observed State",
"",
"```json",
json.dumps(document.get("observed_state") or {}, ensure_ascii=False, indent=2),
"```",
"",
]
)
return "\n".join(lines)

View file

@ -0,0 +1,141 @@
from __future__ import annotations
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
from model_router.router import ModelRequestContract, ModelRouter
def word_count(text: str) -> int:
return len(re.findall(r"\b\S+\b", text))
def atomic_write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
except BaseException:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
raise
def merge_with_overlap(existing: str, continuation: str, max_overlap: int = 4000) -> str:
existing = existing.rstrip()
continuation = continuation.lstrip()
if not existing:
return continuation
limit = min(len(existing), len(continuation), max_overlap)
for size in range(limit, 39, -1):
if existing[-size:] == continuation[:size]:
return existing + continuation[size:]
return existing + ("" if existing.endswith((" ", "\n")) else " ") + continuation
@dataclass(frozen=True)
class DraftResult:
text: str
attempts: int
resumed: bool
word_count: int
class ResumableDraftWriter:
def __init__(self, router: ModelRouter) -> None:
self.router = router
def generate(
self,
*,
request: ModelRequestContract,
partial_path: Path,
minimum_words: int = 3000,
maximum_words: int = 15000,
completion_marker: str = "[[END_OF_CHAPTER]]",
max_attempts: int = 4,
) -> DraftResult:
attempt_path = partial_path.with_name(partial_path.name + ".attempt")
partial = self._reconcile(partial_path, attempt_path)
if completion_marker in partial:
completed = partial.partition(completion_marker)[0].rstrip()
if word_count(completed) < minimum_words:
partial = ""
atomic_write_text(partial_path, partial)
resumed = bool(partial)
last_error = "generation did not complete"
retry_feedback = ""
short_completions = 0
for attempt in range(1, max_attempts + 1):
prompt = request.prompt + retry_feedback
if partial:
prompt += (
"\n\nContinue from the exact cutoff below. Return continuation prose only; do not restart "
"or summarize. Finish with the required completion marker.\n<saved-prose>\n"
+ partial
+ "\n</saved-prose>"
)
continued_request = ModelRequestContract(
purpose=request.purpose,
prompt=prompt,
model_hint=request.model_hint,
token_budget=request.token_budget,
project=request.project,
agent_version=request.agent_version,
)
attempt_path.parent.mkdir(parents=True, exist_ok=True)
try:
with attempt_path.open("w", encoding="utf-8", newline="\n") as handle:
for chunk in self.router.stream(continued_request):
handle.write(chunk.content)
handle.flush()
os.fsync(handle.fileno())
partial = self._reconcile(partial_path, attempt_path)
words = word_count(partial)
if words > maximum_words:
raise RuntimeError(
f"generated prose exceeds maximum: {words} > {maximum_words} words"
)
if completion_marker not in partial:
last_error = "provider completed without the chapter marker"
continue
body = partial.partition(completion_marker)[0].rstrip()
if word_count(body) < minimum_words:
last_error = f"completed chapter is shorter than {minimum_words} words"
short_completions += 1
partial = ""
atomic_write_text(partial_path, partial)
if short_completions >= 2:
break
retry_feedback = (
f"\n\nThe prior complete draft was too short. Write at least {minimum_words} words "
"and fully dramatize every planned scene without padding or repeating the chapter."
)
continue
atomic_write_text(partial_path, body)
return DraftResult(body, attempt, resumed, word_count(body))
except Exception as exc:
last_error = str(exc)
partial = self._reconcile(partial_path, attempt_path)
if word_count(partial) > maximum_words:
raise
raise RuntimeError(
f"chapter remains partial after {max_attempts} attempts at {partial_path}: {last_error}"
)
def _reconcile(self, partial_path: Path, attempt_path: Path) -> str:
partial = partial_path.read_text(encoding="utf-8") if partial_path.exists() else ""
if attempt_path.exists():
partial = merge_with_overlap(partial, attempt_path.read_text(encoding="utf-8"))
atomic_write_text(partial_path, partial)
attempt_path.unlink()
return partial.strip()

View file

@ -0,0 +1,483 @@
from __future__ import annotations
import json
from typing import Any
from django.core.exceptions import ValidationError
from django.http import HttpRequest, JsonResponse
from django.views.decorators.http import require_http_methods
from control_plane.authoring.book_state import BookStateService
from control_plane.authoring.models import (
BookRun,
BookStateVersion,
SceneIdeation,
StandaloneScene,
Work,
)
from control_plane.authoring.standalone_scenes import (
SceneIdeationService,
StandaloneSceneService,
)
from model_router.providers import providers_from_resources
from model_router.router import ModelRouter
def scene_service() -> StandaloneSceneService:
return StandaloneSceneService(
ModelRouter(providers_from_resources(), persist_requests=True)
)
def ideation_service() -> SceneIdeationService:
return SceneIdeationService(ModelRouter(providers_from_resources(), persist_requests=True))
def book_service() -> BookStateService:
return BookStateService(ModelRouter(providers_from_resources(), persist_requests=True))
def _json_body(request: HttpRequest) -> dict[str, Any]:
try:
value = json.loads(request.body or b"{}")
except json.JSONDecodeError as exc:
raise ValueError("request body must be valid JSON") from exc
if not isinstance(value, dict):
raise ValueError("request body must be a JSON object")
return value
def _json_bool(body: dict[str, Any], field: str, *, default: bool = False) -> bool:
value = body.get(field, default)
if not isinstance(value, bool):
raise ValueError(f"{field} must be boolean")
return value
def _scene(scene_id) -> StandaloneScene | None:
return (
StandaloneScene.objects.select_related(
"work__series", "story__project", "source_version", "book_state"
)
.filter(id=scene_id)
.first()
)
def _idea(idea_id) -> SceneIdeation | None:
return SceneIdeation.objects.select_related("work__series").filter(id=idea_id).first()
def _payload(scene: StandaloneScene, *, include_prose: bool = False) -> dict[str, Any]:
payload = {
"id": str(scene.id),
"series": scene.work.series.slug,
"work": scene.work.slug,
"title": scene.title,
"scene_key": scene.scene_key,
"revision": scene.revision,
"status": scene.status,
"brief": scene.brief,
"target_words": scene.target_words,
"word_count": scene.word_count,
"constraints": scene.constraints,
"forbidden_events": scene.forbidden_events,
"boundary_constraints": scene.boundary_constraints,
"context_pack_sha256": scene.context_pack_sha256,
"citations": (scene.context_pack or {}).get("citations") or [],
"plan": scene.plan,
"contract_requirements": scene.contract_requirements,
"review": scene.review,
"sha256": scene.sha256,
"artifact_uri": scene.artifact_uri,
"review_artifact_uri": scene.review_artifact_uri,
"generation_metadata": scene.generation_metadata,
"approved_at": scene.approved_at.isoformat() if scene.approved_at else None,
"approved_by": scene.approved_by,
"source_version_id": str(scene.source_version_id) if scene.source_version_id else None,
"book_state_id": str(scene.book_state_id) if scene.book_state_id else None,
"chapter_key": scene.book_chapter_key,
"failure_reason": scene.failure_reason,
"created_at": scene.created_at.isoformat(),
"updated_at": scene.updated_at.isoformat(),
}
if include_prose:
payload["prose"] = scene.prose
return payload
def _iso(value: Any) -> str | None:
return value.isoformat() if value else None
def _book_state_payload(state: BookStateVersion) -> dict[str, Any]:
return {
"id": str(state.id),
"series": state.work.series.slug,
"work": state.work.slug,
"parent_id": str(state.parent_id) if state.parent_id else None,
"version": state.version,
"status": state.status,
"content": state.content,
"sha256": state.sha256,
"validation": state.validation,
"reviews": state.reviews,
"change_summary": state.change_summary,
"context_pack": getattr(state, "context_pack", {}),
"context_pack_sha256": getattr(state, "context_pack_sha256", ""),
"generation_metadata": getattr(state, "generation_metadata", {}),
"created_by": state.created_by,
"json_artifact_uri": getattr(state, "json_artifact_uri", ""),
"markdown_artifact_uri": getattr(state, "markdown_artifact_uri", ""),
"approved_at": _iso(getattr(state, "approved_at", None)),
"approved_by": getattr(state, "approved_by", ""),
"approval_notes": getattr(state, "approval_notes", ""),
"approval_forced": state.approval_forced,
"rejected_at": _iso(getattr(state, "rejected_at", None)),
"rejected_by": getattr(state, "rejected_by", ""),
"rejection_notes": getattr(state, "rejection_notes", ""),
"created_at": _iso(state.created_at),
"updated_at": _iso(state.updated_at),
}
def _book_run_payload(run: BookRun) -> dict[str, Any]:
state_id = getattr(run, "state_id", None) or getattr(run, "book_state_id", None)
return {
"id": str(run.id),
"book_state_id": str(state_id) if state_id else None,
"status": run.status,
"policy": getattr(run, "policy", {}),
"reviews": run.reviews,
"current_chapter_key": getattr(run, "current_chapter_key", ""),
"progress": getattr(run, "progress", {}),
"failure_reason": getattr(run, "failure_reason", ""),
"started_at": _iso(getattr(run, "started_at", None)),
"finished_at": _iso(getattr(run, "finished_at", None)),
"created_at": _iso(run.created_at),
"updated_at": _iso(run.updated_at),
}
def _idea_payload(idea: SceneIdeation) -> dict[str, Any]:
return {
"id": str(idea.id),
"series": idea.work.series.slug,
"work": idea.work.slug,
"book_state_id": str(idea.book_state_id) if idea.book_state_id else None,
"target_book": idea.target_book,
"requested_scene_types": idea.requested_scene_types,
"focus": idea.focus,
"candidate_count": idea.candidate_count,
"authorities": idea.authorities,
"pinned_document_keys": idea.pinned_document_keys,
"governing_document_keys": (idea.context_pack or {}).get(
"governing_document_keys"
)
or [],
"context_pack_sha256": idea.context_pack_sha256,
"citations": (idea.context_pack or {}).get("citations") or [],
"candidates": idea.candidates,
"generation_metadata": idea.generation_metadata,
"created_at": idea.created_at.isoformat(),
"updated_at": idea.updated_at.isoformat(),
}
def _book_state(state_id) -> BookStateVersion | None:
return (
BookStateVersion.objects.select_related("work__series", "parent")
.filter(id=state_id)
.first()
)
@require_http_methods(["GET", "POST"])
def book_states(request: HttpRequest) -> JsonResponse:
if request.method == "GET":
states = BookStateVersion.objects.select_related("work__series", "parent").order_by(
"-updated_at"
)[:100]
return JsonResponse({"book_states": [_book_state_payload(state) for state in states]})
try:
body = _json_body(request)
required = ["series_slug", "work_slug", "content"]
missing = [field for field in required if body.get(field) in (None, "")]
if missing:
raise ValueError("missing fields: " + ", ".join(missing))
if not isinstance(body["content"], dict):
raise ValueError("content must be a JSON object")
work = Work.objects.filter(
series__slug=body["series_slug"], slug=body["work_slug"]
).first()
if work is None:
return JsonResponse({"error": "work not found"}, status=404)
state = book_service().create(
work=work,
content=body["content"],
actor=str(body.get("actor") or "api"),
context_pack=body.get("context_pack"),
generation_metadata=body.get("generation_metadata"),
)
except (RuntimeError, TypeError, ValueError, ValidationError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
return JsonResponse(_book_state_payload(state), status=201)
@require_http_methods(["GET"])
def book_state_detail(request: HttpRequest, state_id) -> JsonResponse:
state = _book_state(state_id)
if state is None:
return JsonResponse({"error": "book state not found"}, status=404)
return JsonResponse(_book_state_payload(state))
@require_http_methods(["POST"])
def book_state_action(request: HttpRequest, state_id) -> JsonResponse:
state = _book_state(state_id)
if state is None:
return JsonResponse({"error": "book state not found"}, status=404)
try:
body = _json_body(request)
action = str(body.get("action") or "").strip().replace("-", "_")
service = book_service()
if action == "validate":
service.validate(state, for_approval=_json_bool(body, "for_approval"))
elif action == "review":
level = str(body.get("level") or "").strip()
if not level:
raise ValueError("level is required")
service.review(state, level=level, model_hint=body.get("model"))
elif action == "approve":
service.approve(
state,
actor=str(body.get("actor") or "api"),
force=_json_bool(body, "force"),
notes=str(body.get("notes") or ""),
)
elif action == "reject":
service.reject(
state,
actor=str(body.get("actor") or "api"),
notes=str(body.get("notes") or ""),
)
elif action == "revise":
content = body.get("content")
if not isinstance(content, dict):
raise ValueError("content must be a JSON object")
revised = service.revise(
state,
content=content,
actor=str(body.get("actor") or "api"),
context_pack=body.get("context_pack"),
generation_metadata=body.get("generation_metadata"),
)
return JsonResponse(_book_state_payload(revised), status=201)
elif action == "impact":
return JsonResponse({"impact": service.impact(state)})
elif action == "start_run":
run = service.start_run(state, policy=body.get("policy"))
return JsonResponse(_book_run_payload(run), status=201)
elif action == "sync_run":
run_id = str(body.get("run_id") or "").strip()
if not run_id:
raise ValueError("run_id is required")
run = BookRun.objects.filter(id=run_id).first()
if run is None:
return JsonResponse({"error": "book run not found"}, status=404)
run_state_id = getattr(run, "state_id", None) or getattr(
run, "book_state_id", None
)
if run_state_id != state.id:
raise ValueError("book run does not belong to this state")
service.sync_run(run)
run.refresh_from_db()
return JsonResponse(_book_run_payload(run))
elif action == "review_run":
run_id = str(body.get("run_id") or "").strip()
if not run_id:
raise ValueError("run_id is required")
run = BookRun.objects.filter(id=run_id, book_state=state).first()
if run is None:
return JsonResponse({"error": "book run not found"}, status=404)
service.review_run(run, model_hint=body.get("model"))
run.refresh_from_db()
return JsonResponse(_book_run_payload(run))
else:
raise ValueError("unsupported action")
except (RuntimeError, TypeError, ValueError, ValidationError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
state.refresh_from_db()
return JsonResponse(_book_state_payload(state))
@require_http_methods(["GET", "POST"])
def scene_ideas(request: HttpRequest) -> JsonResponse:
if request.method == "GET":
ideas = SceneIdeation.objects.select_related("work__series").order_by("-created_at")[:100]
return JsonResponse({"ideas": [_idea_payload(idea) for idea in ideas]})
try:
body = _json_body(request)
required = ["series_slug", "work_slug", "target_book"]
missing = [field for field in required if not str(body.get(field) or "").strip()]
if missing:
raise ValueError("missing fields: " + ", ".join(missing))
work = Work.objects.filter(
series__slug=body["series_slug"], slug=body["work_slug"]
).first()
if work is None:
return JsonResponse({"error": "work not found"}, status=404)
book_state = None
if body.get("book_state_id"):
book_state = BookStateVersion.objects.filter(id=body["book_state_id"]).first()
if book_state is None:
return JsonResponse({"error": "book state not found"}, status=404)
idea = ideation_service().propose(
work=work,
target_book=str(body["target_book"]),
focus=str(body.get("focus") or ""),
candidate_count=int(body.get("candidate_count") or 10),
scene_types=list(body.get("scene_types") or []) or None,
authorities=list(body.get("authorities") or []) or None,
pinned_document_keys=list(body.get("pinned_document_keys") or []),
governing_document_keys=list(body.get("governing_document_keys") or []),
detail_level=str(body.get("detail_level") or "full"),
model_hint=body.get("model"),
book_state=book_state,
)
except (RuntimeError, TypeError, ValueError, ValidationError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
return JsonResponse(_idea_payload(idea), status=201)
@require_http_methods(["GET"])
def scene_idea_detail(request: HttpRequest, idea_id) -> JsonResponse:
idea = _idea(idea_id)
if idea is None:
return JsonResponse({"error": "scene ideation not found"}, status=404)
return JsonResponse(_idea_payload(idea))
@require_http_methods(["POST"])
def scene_idea_action(request: HttpRequest, idea_id) -> JsonResponse:
idea = _idea(idea_id)
if idea is None:
return JsonResponse({"error": "scene ideation not found"}, status=404)
try:
body = _json_body(request)
action = str(body.get("action") or "").strip().replace("-", "_")
if action != "select":
raise ValueError("unsupported action")
candidate_id = str(body.get("candidate_id") or "").strip()
if not candidate_id:
raise ValueError("candidate_id is required")
scene, created = ideation_service().select_candidate(
idea,
candidate_id=candidate_id,
target_words=(
int(body["target_words"]) if body.get("target_words") is not None else None
),
book_chapter_key=body.get("chapter_key"),
)
idea.refresh_from_db()
except (RuntimeError, TypeError, ValueError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
return JsonResponse(
{"idea": _idea_payload(idea), "scene": _payload(scene), "created": created},
status=201 if created else 200,
)
@require_http_methods(["GET", "POST"])
def standalone_scenes(request: HttpRequest) -> JsonResponse:
if request.method == "GET":
scenes = StandaloneScene.objects.select_related("work__series", "book_state").order_by(
"-updated_at"
)[:100]
return JsonResponse({"scenes": [_payload(scene) for scene in scenes]})
try:
body = _json_body(request)
required = ["series_slug", "work_slug", "title", "brief"]
missing = [field for field in required if not str(body.get(field) or "").strip()]
if missing:
raise ValueError("missing fields: " + ", ".join(missing))
work = Work.objects.filter(
series__slug=body["series_slug"], slug=body["work_slug"]
).first()
if work is None:
return JsonResponse({"error": "work not found"}, status=404)
book_state = None
if body.get("book_state_id"):
book_state = BookStateVersion.objects.filter(id=body["book_state_id"]).first()
if book_state is None:
return JsonResponse({"error": "book state not found"}, status=404)
scene = scene_service().create(
work=work,
title=str(body["title"]),
brief=str(body["brief"]),
target_words=int(body.get("target_words") or 1800),
constraints=list(body.get("constraints") or []),
forbidden_events=list(body.get("forbidden_events") or []),
boundary_constraints=list(body.get("boundary_constraints") or []),
book_state=book_state,
book_chapter_key=body.get("chapter_key"),
)
except (TypeError, ValueError, ValidationError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
return JsonResponse(_payload(scene), status=201)
@require_http_methods(["GET"])
def standalone_scene_detail(request: HttpRequest, scene_id) -> JsonResponse:
scene = _scene(scene_id)
if scene is None:
return JsonResponse({"error": "scene not found"}, status=404)
return JsonResponse(_payload(scene, include_prose=request.GET.get("include_prose") == "1"))
@require_http_methods(["POST"])
def standalone_scene_action(request: HttpRequest, scene_id) -> JsonResponse:
scene = _scene(scene_id)
if scene is None:
return JsonResponse({"error": "scene not found"}, status=404)
try:
body = _json_body(request)
action = str(body.get("action") or "").strip().replace("-", "_")
service = scene_service()
if action == "context":
service.prepare_context(
scene,
authorities=list(body.get("authorities") or []) or None,
pinned_document_keys=list(body.get("pinned_document_keys") or []),
)
elif action == "plan":
service.plan(
scene,
authorities=list(body.get("authorities") or []) or None,
pinned_document_keys=list(body.get("pinned_document_keys") or []),
model_hint=body.get("model"),
)
elif action == "approve_plan":
service.approve_plan(scene)
elif action == "write":
service.write(
scene,
model_hint=body.get("model"),
max_attempts=int(body.get("max_attempts") or 2),
)
elif action == "review":
service.review(scene, model_hint=body.get("model"))
elif action == "approve":
service.approve(
scene,
actor=str(body.get("actor") or "api"),
force=_json_bool(body, "force"),
)
elif action == "reject":
service.reject(scene, actor=str(body.get("actor") or "api"))
else:
raise ValueError("unsupported action")
except (RuntimeError, TypeError, ValueError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
scene.refresh_from_db()
return JsonResponse(_payload(scene))

View file

@ -0,0 +1,148 @@
from __future__ import annotations
from typing import Any
from control_plane.authoring.services import DjangoStoryWorkflowServices
from control_plane.authoring.state import StoryGraphState
def build_story_workflow(services: DjangoStoryWorkflowServices, checkpointer: object):
try:
from langgraph.graph import END, StateGraph
from langgraph.types import interrupt
except ImportError as exc:
raise RuntimeError("Story authoring requires LangGraph") from exc
graph = StateGraph(StoryGraphState)
def build_context(state: StoryGraphState) -> dict[str, Any]:
return services.build_context(dict(state))
def plan_chapter(state: StoryGraphState) -> dict[str, Any]:
return services.plan_chapter(dict(state))
def approve_plan(state: StoryGraphState) -> dict[str, Any]:
approval = services.ensure_approval(
dict(state),
"STORY_PLAN_APPROVAL",
{
"type": "story_plan",
"revision_id": state["revision_id"],
"scene_plan": state.get("scene_plan", {}),
"allowed_actions": ["approve", "request_revision", "reject"],
},
)
decision = interrupt(approval.payload)
services.decide_approval(approval.id, decision)
return {
"approval_action": str(decision.get("action") or "reject").lower(),
"human_notes": str(decision.get("notes") or ""),
}
def draft_chapter(state: StoryGraphState) -> dict[str, Any]:
return services.draft_chapter(dict(state))
def extract_continuity(state: StoryGraphState) -> dict[str, Any]:
method = getattr(services, "extract_final_state", services.extract_continuity)
return method(dict(state))
def quality_review(state: StoryGraphState) -> dict[str, Any]:
method = getattr(services, "quality_review", None)
if method is None:
return {"editorial_finding_ids": []}
return method(dict(state))
def judge_state_contract(state: StoryGraphState) -> dict[str, Any]:
method = getattr(services, "finalize_combined_audit", None)
if method is None:
method = getattr(services, "judge_state_contract", None)
if method is None:
return {"state_judge_status": "pass"}
return method(dict(state))
def decide_patch(state: StoryGraphState) -> dict[str, Any]:
return services.decide_patch(dict(state))
def apply_patch(state: StoryGraphState) -> dict[str, Any]:
return services.apply_automatic_patch(dict(state))
def verify_patch(state: StoryGraphState) -> dict[str, Any]:
return services.verify_patch(dict(state))
def approve_chapter(state: StoryGraphState) -> dict[str, Any]:
state_payload_method = getattr(services, "state_approval_payload", None)
state_payload = state_payload_method(dict(state)) if state_payload_method else {}
approval = services.ensure_approval(
dict(state),
"STORY_CHAPTER_APPROVAL",
{
"type": "story_chapter",
"revision_id": state["revision_id"],
"finding_ids": state.get("editorial_finding_ids", []),
**state_payload,
"allowed_actions": ["approve", "request_revision", "reject"],
},
)
decision = interrupt(approval.payload)
services.decide_approval(approval.id, decision)
return {
"approval_action": str(decision.get("action") or "reject").lower(),
"human_notes": str(decision.get("notes") or ""),
}
def commit_chapter(state: StoryGraphState) -> dict[str, Any]:
return services.commit_chapter(dict(state))
def publish_story(state: StoryGraphState) -> dict[str, Any]:
return {"export_uri": services.publish_story(dict(state))}
graph.add_node("build_context", build_context)
graph.add_node("plan_chapter", plan_chapter)
graph.add_node("approve_plan", approve_plan)
graph.add_node("draft_chapter", draft_chapter)
graph.add_node("quality_review", quality_review)
graph.add_node("extract_continuity", extract_continuity)
graph.add_node("review_draft", judge_state_contract)
graph.add_node("decide_patch", decide_patch)
graph.add_node("apply_patch", apply_patch)
graph.add_node("extract_patched_continuity", extract_continuity)
graph.add_node("verify_patch", verify_patch)
graph.add_node("approve_chapter", approve_chapter)
graph.add_node("commit_chapter", commit_chapter)
graph.add_node("publish_story", publish_story)
graph.add_node("manual_revision", lambda state: {})
graph.add_node("reject", lambda state: {})
graph.set_entry_point("build_context")
graph.add_edge("build_context", "plan_chapter")
graph.add_edge("plan_chapter", "approve_plan")
graph.add_conditional_edges(
"approve_plan",
lambda state: state.get("approval_action", "reject"),
{"approve": "draft_chapter", "request_revision": "plan_chapter", "reject": "reject"},
)
graph.add_edge("draft_chapter", "quality_review")
graph.add_edge("quality_review", "decide_patch")
graph.add_conditional_edges(
"decide_patch",
lambda state: state.get("patch_decision", "human_review"),
{"patch": "apply_patch", "human_review": "extract_continuity"},
)
graph.add_conditional_edges(
"apply_patch",
lambda state: state.get("patch_status", "failed"),
{"applied": "extract_patched_continuity", "failed": "extract_continuity"},
)
graph.add_edge("extract_continuity", "review_draft")
graph.add_edge("extract_patched_continuity", "verify_patch")
graph.add_edge("verify_patch", "review_draft")
graph.add_edge("review_draft", "approve_chapter")
graph.add_conditional_edges(
"approve_chapter",
lambda state: state.get("approval_action", "reject"),
{"approve": "commit_chapter", "request_revision": "manual_revision", "reject": "reject"},
)
graph.add_edge("commit_chapter", "publish_story")
graph.add_edge("publish_story", END)
graph.add_edge("manual_revision", END)
graph.add_edge("reject", END)
return graph.compile(checkpointer=checkpointer)

View file

@ -12,6 +12,10 @@ from agents.control_room import AgentControlRoomService
from agents.lifecycle import ExplorerService
from agents.roadmap import RoadmapService
from agents.scenario_lab import ScenarioLabService
from control_plane.authoring.checkpoints import open_story_checkpointer
from control_plane.authoring.runner import StoryWorkflowRunner
from control_plane.authoring.services import DjangoStoryWorkflowServices
from control_plane.authoring.workflow import build_story_workflow
from control_plane.events.models import Event
from control_plane.projects.models import Decision, ExplorationOpportunity, Project, RoadmapItem, ScenarioFinding, ScenarioSuite, StewardFinding, Task
from control_plane.projects.ui_services import ControlPlaneUIService
@ -19,6 +23,8 @@ from graph.bootstrap import champion_project_exploration_graph_v1
from graph.langgraph_runtime import LangGraphRuntime
from graph.lifecycle import exploration_registry
from graph.models import GraphApproval, GraphApprovalStatus, GraphRun, GraphRunStatus
from model_router.providers import providers_from_resources
from model_router.router import ModelRouter
ui = ControlPlaneUIService()
@ -192,11 +198,29 @@ def approvals(request):
def approval_action(request, approval_id):
approval = get_object_or_404(GraphApproval, id=approval_id)
action = request.POST.get("action")
graph_run = approval.graph_run
if graph_run.execution_graph_version.graph.name == "story_authoring":
decision = {
"action": "approve" if action == "approve" else "request_revision",
"actor": "ui",
"notes": request.POST.get("notes", "").strip(),
}
try:
with open_story_checkpointer() as checkpointer:
services = DjangoStoryWorkflowServices(
ModelRouter(providers_from_resources(), persist_requests=True)
)
workflow = build_story_workflow(services, checkpointer)
StoryWorkflowRunner(workflow).resume(graph_run.id, decision)
except Exception as exc:
graph_run.failure_reason = f"UI approval resume failed: {exc}"
graph_run.save(update_fields=["failure_reason", "updated_at"])
return redirect("graph_run_detail", graph_run_id=graph_run.id)
approval.status = GraphApprovalStatus.APPROVED if action == "approve" else GraphApprovalStatus.REJECTED
approval.decided_by = "ui"
approval.decided_at = timezone.now()
approval.save(update_fields=["status", "decided_by", "decided_at", "updated_at"])
graph_run = approval.graph_run
if approval.status == GraphApprovalStatus.APPROVED:
graph_run.status = GraphRunStatus.RUNNING
graph_run.failure_reason = ""

View file

@ -22,15 +22,30 @@ class Command(BaseCommand):
if executable is None:
default_user_install = Path.home() / ".opencode" / "bin" / "opencode"
executable = str(default_user_install) if default_user_install.exists() else "opencode"
return f"{executable} run"
return f"{executable} run --model openai/gpt-5.6-{model_key}"
def opencode_config(self, model_key: str) -> dict[str, object]:
return {
"model_key": model_key,
"transport": os.environ.get(f"ARTIFEX_{model_key.upper()}_TRANSPORT", os.environ.get("ARTIFEX_OPENCODE_TRANSPORT", "local")),
"command": self.opencode_command(model_key),
"working_directory": os.environ.get(
f"ARTIFEX_{model_key.upper()}_WORKING_DIRECTORY",
os.environ.get(
"ARTIFEX_OPENCODE_WORKING_DIRECTORY",
str(Path.home() / "artifex-story-runtime"),
),
),
"use_pty": os.environ.get(f"ARTIFEX_{model_key.upper()}_USE_PTY", os.environ.get("ARTIFEX_OPENCODE_USE_PTY", "0")) == "1",
"timeout_seconds": int(os.environ.get(f"ARTIFEX_{model_key.upper()}_TIMEOUT_SECONDS", os.environ.get("ARTIFEX_OPENCODE_TIMEOUT_SECONDS", "120"))),
"timeout_seconds": int(
os.environ.get(
f"ARTIFEX_{model_key.upper()}_TIMEOUT_SECONDS",
os.environ.get(
"ARTIFEX_OPENCODE_TIMEOUT_SECONDS",
str({"sol": 600, "terra": 1800, "luna": 300}.get(model_key, 300)),
),
)
),
}
def update_opencode_resource(self, *, model_key: str, name: str, roles: list[str], compute: Resource) -> None:
@ -63,19 +78,19 @@ class Command(BaseCommand):
self.update_opencode_resource(
model_key="sol",
name=os.environ.get("ARTIFEX_SOL_RESOURCE_NAME", "GPT-5.6 Sol"),
roles=["PROJECT_BRAIN", "PLANNING", "ARCHAEOLOGY_INTERPRETATION", "AGENT_DESIGN", "ESCALATION"],
roles=["PROJECT_BRAIN", "PLANNING", "ARCHAEOLOGY_INTERPRETATION", "AGENT_DESIGN", "ESCALATION", "STORY_PLANNING", "STORY_JUDGE"],
compute=spark,
)
self.update_opencode_resource(
model_key="terra",
name=os.environ.get("ARTIFEX_TERRA_RESOURCE_NAME", "GPT-5.6 Terra"),
roles=["REASONING", "REVIEW", "PORTFOLIO_IC", "STRATEGY"],
roles=["REASONING", "REVIEW", "PORTFOLIO_IC", "STRATEGY", "STORY_PROSE", "STORY_PACING_REVIEW"],
compute=spark,
)
self.update_opencode_resource(
model_key="luna",
name=os.environ.get("ARTIFEX_LUNA_RESOURCE_NAME", "GPT-5.6 Luna"),
roles=["REASONING", "MARKET_RESEARCH", "WEB_RESEARCH", "SYNTHESIS"],
roles=["REASONING", "MARKET_RESEARCH", "WEB_RESEARCH", "SYNTHESIS", "STORY_CONTINUITY", "STORY_CHARACTER_REVIEW"],
compute=spark,
)
Resource.objects.update_or_create(
@ -84,7 +99,15 @@ class Command(BaseCommand):
"kind": ResourceKind.MODEL,
"provider": "local_inference",
"compute": spark,
"roles": ["CODING", "REVIEW", "REASONING"],
"roles": [
"CODING",
"REVIEW",
"REASONING",
"STORY_PROSE",
"STORY_CONTINUITY",
"STORY_CHARACTER_REVIEW",
"STORY_REVISION",
],
"config": {
"endpoint_url": os.environ.get("ARTIFEX_QWEN_ENDPOINT_URL", "http://192.168.1.162:8002/v1/chat/completions"),
"health_url": os.environ.get("ARTIFEX_QWEN_HEALTH_URL", "http://192.168.1.162:8002/health"),

View file

@ -0,0 +1,306 @@
# Story Authoring Workflow
Artifex stores approved fiction state in Django/PostgreSQL and unfinished execution state in LangGraph checkpoints. Draft fragments and EPUB files remain ordinary artifacts under the story's configured artifact root.
## Workflow
The `story_authoring` graph runs these stages:
1. Build a chapter context snapshot from the approved bible, outline, canon, previous chapter, and optional source revision.
2. Use Terra to generate a self-contained scene plan with exact values, forbidden events, and an explicit ending, then pause for human approval.
3. Draft the complete chapter in one resumable Terra call from the approved consolidated beat plan and full previous-chapter canon.
4. Use one Luna call to extract the complete chapter state and immutable state changes while checking every required beat, exact value, constraint, forbidden event, final image, and objective continuity issue. Deterministic checks independently enforce known temporal-state and exact-payout rules.
5. Validate every proposed state change against exact prose evidence. Hard canon, chronology, exact values,
forbidden events, and explicitly required beats can block; optional scene texture remains nonblocking.
6. If the combined audit finds objective, locally repairable MEDIUM-or-higher defects, let Luna apply one exact
patch touching at most five percent of the chapter. LOW and subjective findings are never patched
automatically.
7. Run one targeted Luna verifier over only the supplied findings, hard requirements, and changed
passages. It cannot discover new issues or trigger another patch.
8. Pause for final chapter approval with state coverage, patch metadata, and residual findings attached.
9. Atomically commit validated state changes, materialize a cumulative canon snapshot, promote the
revision, and rebuild the EPUB.
The approval inbox at `/approvals/` resumes the persisted checkpoint. A final request for revision ends
the automated run; it never starts another model loop. Start a new explicit run for additional work.
## Production Setup
Story workflows require PostgreSQL for durable cross-process resume. SQLite uses an in-memory checkpoint saver and is suitable only for tests that start and resume in one process.
```bash
pip install -e .
export DATABASE_URL=postgresql://artifex:artifex@localhost:5432/artifex
python manage.py migrate
python manage.py seed_spark_resources
python manage.py runserver
```
Run Artifex on Spark for the simplest deployment. If the Django process runs elsewhere, set `ARTIFEX_OPENCODE_TRANSPORT=ssh` and `ARTIFEX_SPARK_SSH_ALIAS=spark`; if it runs on Spark, leave the transport as `local`. Configure the Terra and Luna OpenCode commands with `ARTIFEX_<MODEL>_OPENCODE_COMMAND` when they differ from `opencode run`.
## Import And Run
Import an approved story bible and outline, creating locked canon through Chapter 1:
```bash
python manage.py story_workflow import \
--slug the-fortune-below \
--title "The Fortune Below" \
--series "Labyrinth Hero" \
--brief /path/to/labyrinth-hero-brief.md \
--plan /path/to/labyrinth-hero-plan.json \
--source-dir /path/to/labyrinth-hero \
--locked-through 1 \
--artifact-root /path/to/artifacts
```
Start Chapter 2 with the old chapter retained as source material:
```bash
python manage.py story_workflow start \
--slug the-fortune-below \
--chapter 2 \
--source /path/to/labyrinth-hero-chapter-02-a-fortune-with-witnesses.md
```
Start a clean run while retaining but disabling older paused runs:
```bash
python manage.py story_workflow start \
--slug the-fortune-below \
--chapter 2 \
--fresh \
--supersede-active
```
Approve or return a paused gate from the shell:
```bash
python manage.py story_workflow resume --graph-run 42 --decision approve
python manage.py story_workflow resume --graph-run 42 --decision request_revision --notes "Slow the transition into the lodging scene."
python manage.py story_workflow resume --graph-run 42 --decision reject
```
Every model request, context snapshot, review finding, approval, revision, canon snapshot, and publication artifact remains queryable in Django.
## State Ledger
`StoryEntity` gives every tracked person, item, location, account, relationship, organization,
and plot thread a stable key. `StateChange` stores immutable, evidence-backed transitions for those
entities. `ChapterStateDocument` stores the readable chapter state and contract coverage, while
`CanonSnapshot` materializes the complete approved book state for fast generation context.
Only validated changes from an approved revision are committed. Rejected revisions retain proposed
changes for audit but never alter canon. Corrections use superseding changes rather than rewriting history.
Build or inspect a state document and query committed entity history:
```bash
python manage.py story_state build --revision <revision-uuid>
python manage.py story_state show --revision <revision-uuid>
python manage.py story_state history --slug the-fortune-below --entity character.corin.vale
```
Each build writes `chapter-NN-rN.state.json` and `chapter-NN-rN.state.md` under the story artifact
root. The database remains authoritative; these files are human-readable projections.
## Book Authoring State
`BookStateVersion` is the versioned planning contract for a whole work. Its JSON content owns act and chapter
structure, chapter dependencies, book constraints and forbidden events, arc and thread progression, and planning
continuity facts. Validation checks that structure and dependency graph; separate structural, continuity, and
editorial reviews remain attached to the immutable version. Revisions point to their parent and carry an impact
report so changed chapters and downstream dependencies can be inspected before approval.
Create and operate on book state with `fiction_book`:
```bash
python manage.py fiction_book create --series-slug labyrinth-hero \
--work-slug the-fortune-below --input /path/to/book-state.json
python manage.py fiction_book validate --id <state-uuid>
python manage.py fiction_book review --id <state-uuid> --level continuity --model luna
python manage.py fiction_book impact --id <state-uuid>
python manage.py fiction_book approve --id <state-uuid> --actor daniel
python manage.py fiction_book start-run --id <state-uuid>
python manage.py fiction_book sync-run --run-id <run-uuid>
python manage.py fiction_book review-run --run-id <run-uuid> --model luna
```
The equivalent API is `GET|POST /api/authoring/book-states/`, `GET
/api/authoring/book-states/<uuid>/`, and `POST /api/authoring/book-states/<uuid>/actions/`. Actions are
`validate`, `review`, `approve`, `reject`, `revise`, `impact`, `start_run`, `sync_run`, and `review_run`. A `BookRun` stores a
durable chapter cursor and completed work, allowing an approved plan to resume without inferring progress from
generated prose. Once every bound scene is approved, the run stops in review until a current whole-run continuity
review passes. Bind a standalone scene to one approved chapter with `--book-state` and `--chapter-key`, or the
scene-create JSON fields `book_state_id` and `chapter_key`.
Completed runs retain the exact reviewed scene manifest. Start a new run before creating later revisions against
the same approved book state.
Book state is planning authority, not publication authority. Approval does not promote any fact into canon,
generate or approve chapter prose, or auto-approve a standalone scene; those remain explicit authoring and human
approval steps.
## Source Registry
Register source files before extracting continuity claims. Registration stores immutable file versions,
SHA-256 hashes, explicit authority labels, and line/character-addressable passages. It does not infer
authority from directory or filename conventions and does not promote extracted facts into canon.
Use `--dry-run` first when inspecting an existing corpus:
```bash
python manage.py story_sources register \
--root /path/to/manuscripts/the-fortune-below \
--series-slug labyrinth-hero \
--series-title "Labyrinth Hero" \
--work-slug the-fortune-below \
--work-title "The Fortune Below" \
--authority provisional \
--document-type other \
--dry-run
```
Authority values are `canon`, `provisional`, `planning`, `superseded`, `rejected`, and
`noncanon_experiment`. Register mixed-authority corpora in separate, explicitly classified batches.
Changing an authority label creates a superseding source version even when the file content is unchanged.
Use repeated `--include-glob` values to register explicit authority batches while retaining paths relative to
the full corpus root. Shared canon belongs to a `series_reference` work so every book in that series can retrieve
it without seeing sibling-book drafts.
## Standalone Scenes
Standalone scenes have their own persistent plan, cited context pack, frozen requirements, resumable prose,
review, hashes, artifacts, and approval state. They do not require fake chapters and do not alter series canon.
Final approval registers the generated scene as `provisional`; canon promotion remains a separate decision.
Ask Sol to propose new evidence-backed scenes before creating any scene record:
```bash
python manage.py fiction_ideas propose \
--series-slug labyrinth-hero \
--work-slug the-fortune-below \
--target-book "Book Two" \
--focus "Unspent Sabine and Corin choices that preserve current physical continuity" \
--candidate-count 10 \
--include-authority canon \
--include-authority planning \
--model sol
```
Each persisted proposal includes its exact context hash, authority-labelled citations, candidate briefs,
the opportunity each scene spends, future opportunities its ending creates, continuity questions, risks, and
prompt/response hashes. The default ten-candidate menu covers ten generic dramatic functions: quiet connection,
major turn, physical escalation, conflict pressure, boundary choice, revelation/discovery,
aftermath/consequence, competence/task, external plot action, and ensemble/social. These types classify dramatic
function without assuming a particular book, cast, genre, or relationship. Proposal is read-only and creates no scene. Inspect a
proposal and explicitly select one candidate with:
```bash
python manage.py fiction_ideas show --id <idea-uuid>
python manage.py fiction_ideas export --id <idea-uuid> --output /path/to/scene-ideas.md
python manage.py fiction_ideas select --id <idea-uuid> --candidate-id idea-02
```
Pass `--book-state <approved-state-uuid>` to freeze the approved book contract into ideation context. Selecting
from a bound proposal also requires `--chapter-key`, and the resulting scene is bound to that exact state/chapter.
Export renders the persisted proposal, frozen citation index, selection state, and generation hashes as
deterministic Markdown. It does not call a model or mutate the proposal. Selection is idempotent and creates one
`planning` scene for the existing plan/write/review workflow. Repeating
the same selection returns the same scene. Ideation consults `canon` and `planning` by default; pass explicit
`--include-authority` values to narrow or deliberately expand that evidence set.
`--target-book` is mandatory for new proposals. The selected book is a hard premise boundary: later-book canon
may constrain consequences, but events, roles, locations, relationships, and abilities first established later
cannot stage the proposed scene. Every candidate exports its prerequisites and explicit book-scope justification.
Each candidate also explains why its primary scene type fits. A `physical_escalation` must cross or sharply
approach a meaningful established physical threshold; routine care, medical assistance, incidental contact,
bathing, or help dressing and undressing is not sufficient.
Use repeated `--scene-type` values to request alternatives within one or more dramatic functions. When one type
is requested, multiple candidates may use that type; when several are requested, the proposal covers every
requested type before repeating one:
```bash
python manage.py fiction_ideas propose \
--series-slug labyrinth-hero \
--work-slug the-fortune-below \
--target-book "Book Two" \
--candidate-count 3 \
--scene-type physical_escalation \
--model sol
```
Use `--governing-document` for an authoritative guide that the model must receive in full. Governing documents
are frozen verbatim into the context pack before supplementary RAG passages are selected. `--pin-document`
continues to prioritize relevant excerpts and should not be used when complete-document context is required.
Pass `--compact` to generate lightweight ideation candidates containing only the title, brief, citations,
opportunity spent, and future opportunities. The same flag on `fiction_ideas export` produces a review Markdown
without planning constraints, risks, citation index, or other full-detail sections.
Create and plan a scene:
```bash
python manage.py fiction_scene run \
--series-slug labyrinth-hero \
--work-slug the-fortune-below \
--title "Fourteen Seconds" \
--brief /path/to/scene-brief.md \
--target-words 1800 \
--constraint "Sabine owns the timing." \
--boundary "Stop when Sabine leaves the office."
```
After `fiction_scene create`, preview the exact source passages before planning with:
```bash
python manage.py fiction_scene context --id <scene-uuid> \
--include-authority canon \
--pin-document path/relative/to/the/registered/corpus.md
```
The command stops at plan review. Inspect the returned plan and continue explicitly:
```bash
python manage.py fiction_scene approve-plan --id <scene-uuid>
python manage.py fiction_scene write --id <scene-uuid>
python manage.py fiction_scene review --id <scene-uuid>
python manage.py fiction_scene approve --id <scene-uuid> --actor daniel
```
For an intentional plan-to-draft run that still stops before final prose approval:
```bash
python manage.py fiction_scene run ... --auto-approve-plan
```
Context retrieval uses only the latest versions with `canon` authority by default. Add an authority only when
the scene should deliberately consult it, for example `--include-authority planning`. Pin a known document with
`--pin-document book-two/planning/fortune-below-sabine-corin-turn-and-household-rules.md`.
The same workflow is available through JSON endpoints:
- `GET|POST /api/authoring/ideas/`
- `GET /api/authoring/ideas/<uuid>/`
- `POST /api/authoring/ideas/<uuid>/actions/` with action `select`
- `GET|POST /api/authoring/scenes/`
- `GET /api/authoring/scenes/<uuid>/`
- `POST /api/authoring/scenes/<uuid>/actions/`
Supported actions are `context`, `plan`, `approve_plan`, `write`, `review`, `approve`, and `reject`. Scene detail
omits prose by default; request `?include_prose=1` only when the caller needs the full draft.
## Model Policy
The default book policy uses only Terra, Luna, and Qwen:
- Terra: chapter planning and complete chapter prose.
- Luna: combined continuity/state extraction and objective audit, one bounded exact patch, and targeted verification.
- Qwen3.8 no-thinking: reserved for local structured fallback work.
- Human: plan approval and final chapter approval.
The combined Luna and deterministic audit must reject chronology drift, incorrect economics, canon conflicts,
omitted required beats, and prose that continues beyond the approved final image. Automation performs at most
one bounded patch; unresolved blockers return to the human gate.

View file

@ -4,11 +4,20 @@ from django.utils import timezone
from graph.agent_control import agent_investigation_graph_v1
from graph.crypto_venture_cohort import crypto_venture_cohort_graph_v1
from graph.lifecycle import project_evolution_graph_v1, project_exploration_graph_v1, project_extension_graph_v1
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus
from graph.lifecycle import (
project_evolution_graph_v1,
project_exploration_graph_v1,
project_extension_graph_v1,
)
from graph.models import (
ExecutionGraphDefinition,
ExecutionGraphVersion,
ExecutionGraphVersionStatus,
)
from graph.roadmap import project_roadmap_review_graph_v1
from graph.scenario_lab import scenario_lab_graph_v1
from graph.steward import steward_run_graph_v1
from graph.story_authoring import story_authoring_graph_v1, story_authoring_graph_v2
from graph.task_execution import task_execution_graph_v1
from graph.venture_cohort import venture_discovery_cohort_graph_v1
from graph.venture_discovery import venture_discovery_graph_v1
@ -113,3 +122,11 @@ def champion_venture_discovery_cohort_graph_v1() -> ExecutionGraphVersion:
def champion_crypto_venture_cohort_graph_v1() -> ExecutionGraphVersion:
return _champion_graph(crypto_venture_cohort_graph_v1())
def champion_story_authoring_graph_v1() -> ExecutionGraphVersion:
return _champion_graph(story_authoring_graph_v1())
def champion_story_authoring_graph_v2() -> ExecutionGraphVersion:
return _champion_graph(story_authoring_graph_v2())

111
graph/story_authoring.py Normal file
View file

@ -0,0 +1,111 @@
from __future__ import annotations
from graph.spec import ExecutionGraphSpec, GraphEdgeSpec, GraphNodeSpec
def story_authoring_graph_v1() -> ExecutionGraphSpec:
node_ids = [
"build_context",
"plan_chapter",
"approve_plan",
"draft_chapter",
"extract_continuity",
"review_chapter",
"judge_chapter",
"revise_chapter",
"approve_chapter",
"commit_chapter",
"publish_story",
"complete",
"reject",
]
nodes = {
node_id: GraphNodeSpec(
node_id,
f"story_{node_id}",
{"checkpointed": True, "human_gate": node_id.startswith("approve_")},
)
for node_id in node_ids
}
edges = [
GraphEdgeSpec("build_context", "plan_chapter", "success"),
GraphEdgeSpec("plan_chapter", "approve_plan", "success"),
GraphEdgeSpec("approve_plan", "draft_chapter", "approve"),
GraphEdgeSpec("approve_plan", "plan_chapter", "request_revision"),
GraphEdgeSpec("approve_plan", "reject", "reject"),
GraphEdgeSpec("draft_chapter", "extract_continuity", "success"),
GraphEdgeSpec("extract_continuity", "review_chapter", "fan_out"),
GraphEdgeSpec("review_chapter", "judge_chapter", "success"),
GraphEdgeSpec("judge_chapter", "revise_chapter", "revise"),
GraphEdgeSpec("judge_chapter", "approve_chapter", "human_review"),
GraphEdgeSpec("revise_chapter", "extract_continuity", "success"),
GraphEdgeSpec("approve_chapter", "commit_chapter", "approve"),
GraphEdgeSpec("approve_chapter", "revise_chapter", "request_revision"),
GraphEdgeSpec("approve_chapter", "reject", "reject"),
GraphEdgeSpec("commit_chapter", "publish_story", "success"),
GraphEdgeSpec("publish_story", "complete", "success"),
]
spec = ExecutionGraphSpec(
name="story_authoring",
version=1,
graph_type="STORY_AUTHORING",
entry="build_context",
nodes=nodes,
edges=edges,
terminal_nodes=["complete", "reject"],
metadata={
"description": "Checkpoint-native story plan, draft, parallel review, approval, and publish workflow."
},
)
spec.validate()
return spec
def story_authoring_graph_v2() -> ExecutionGraphSpec:
node_ids = [
"build_context", "plan_chapter", "approve_plan", "draft_chapter",
"extract_continuity", "review_draft", "decide_patch", "apply_patch",
"extract_patched_continuity", "verify_patch", "approve_chapter",
"commit_chapter", "publish_story", "complete", "manual_revision", "reject",
]
nodes = {
node_id: GraphNodeSpec(
node_id,
f"story_{node_id}",
{"checkpointed": True, "human_gate": node_id.startswith("approve_")},
)
for node_id in node_ids
}
edges = [
GraphEdgeSpec("build_context", "plan_chapter", "success"),
GraphEdgeSpec("plan_chapter", "approve_plan", "success"),
GraphEdgeSpec("approve_plan", "draft_chapter", "approve"),
GraphEdgeSpec("approve_plan", "plan_chapter", "request_revision"),
GraphEdgeSpec("approve_plan", "reject", "reject"),
GraphEdgeSpec("draft_chapter", "extract_continuity", "success"),
GraphEdgeSpec("extract_continuity", "review_draft", "success"),
GraphEdgeSpec("review_draft", "decide_patch", "success"),
GraphEdgeSpec("decide_patch", "apply_patch", "patch"),
GraphEdgeSpec("decide_patch", "approve_chapter", "human_review"),
GraphEdgeSpec("apply_patch", "extract_patched_continuity", "applied"),
GraphEdgeSpec("apply_patch", "approve_chapter", "failed"),
GraphEdgeSpec("extract_patched_continuity", "verify_patch", "success"),
GraphEdgeSpec("verify_patch", "approve_chapter", "success"),
GraphEdgeSpec("approve_chapter", "commit_chapter", "approve"),
GraphEdgeSpec("approve_chapter", "manual_revision", "request_revision"),
GraphEdgeSpec("approve_chapter", "reject", "reject"),
GraphEdgeSpec("commit_chapter", "publish_story", "success"),
GraphEdgeSpec("publish_story", "complete", "success"),
]
spec = ExecutionGraphSpec(
name="story_authoring",
version=2,
graph_type="STORY_AUTHORING",
entry="build_context",
nodes=nodes,
edges=edges,
terminal_nodes=["complete", "manual_revision", "reject"],
metadata={"description": "Bounded one-draft, one-review, one-patch story workflow."},
)
spec.validate()
return spec

View file

@ -2,7 +2,6 @@ from __future__ import annotations
import os
DEFAULT_MODEL_POLICY = {
"planning": "sol",
"project_brain": "sol",
@ -15,6 +14,11 @@ DEFAULT_MODEL_POLICY = {
"venture_ideation": "sol",
"venture_research": "luna",
"venture_portfolio_ic": "terra",
"story_planning": "terra",
"story_prose": "terra",
"story_continuity": "luna",
"story_review": "terra",
"story_revision": "luna",
}
@ -30,6 +34,11 @@ ENV_BY_ROLE = {
"venture_ideation": "ARTIFEX_VENTURE_IDEATION_MODEL",
"venture_research": "ARTIFEX_VENTURE_RESEARCH_MODEL",
"venture_portfolio_ic": "ARTIFEX_VENTURE_PORTFOLIO_IC_MODEL",
"story_planning": "ARTIFEX_STORY_PLANNING_MODEL",
"story_prose": "ARTIFEX_STORY_PROSE_MODEL",
"story_continuity": "ARTIFEX_STORY_CONTINUITY_MODEL",
"story_review": "ARTIFEX_STORY_REVIEW_MODEL",
"story_revision": "ARTIFEX_STORY_REVISION_MODEL",
}
@ -42,6 +51,11 @@ PURPOSE_TO_ROLE = {
"CODING": "coding",
"REVIEW": "review",
"REASONING": "reasoning",
"STORY_PLANNING": "story_planning",
"STORY_PROSE": "story_prose",
"STORY_CONTINUITY": "story_continuity",
"STORY_REVIEW": "story_review",
"STORY_REVISION": "story_revision",
}

View file

@ -8,11 +8,12 @@ import subprocess
import time
import urllib.error
import urllib.request
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
from control_plane.resources.models import Resource
from model_router.router import ModelRequestContract, ModelResponseContract
from model_router.router import ModelChunk, ModelRequestContract, ModelResponseContract
class ProviderError(RuntimeError):
@ -63,21 +64,33 @@ class SolProvider:
command = str(config.get("command", "opencode run"))
transport = str(config.get("transport", "ssh"))
use_pty = bool(config.get("use_pty", False))
working_directory = str(config.get("working_directory") or "").strip() or None
if transport == "local":
argv = [*shlex.split(command, posix=os.name != "nt"), request.prompt]
argv = shlex.split(command, posix=os.name != "nt")
if use_pty:
shell_command = " ".join(shlex.quote(part) for part in argv)
argv = ["script", "-q", "-e", "-c", shell_command, "/dev/null"]
completed = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, check=False)
else:
ssh_alias = (compute.config if compute else {}).get("ssh_alias", config.get("ssh_alias", "spark"))
remote_command = " ".join(shlex.quote(part) for part in [*shlex.split(command), request.prompt])
completed = subprocess.run(
["ssh", "-tt", str(ssh_alias), remote_command],
argv,
capture_output=True,
text=True,
timeout=timeout,
check=False,
cwd=working_directory,
input=request.prompt,
)
else:
ssh_alias = (compute.config if compute else {}).get("ssh_alias", config.get("ssh_alias", "spark"))
remote_command = " ".join(shlex.quote(part) for part in shlex.split(command))
if working_directory:
remote_command = f"cd {shlex.quote(working_directory)} && {remote_command}"
completed = subprocess.run(
["ssh", "-T", str(ssh_alias), remote_command],
capture_output=True,
text=True,
timeout=timeout,
check=False,
input=request.prompt,
)
if completed.returncode != 0:
raise ProviderError(completed.stderr.strip() or "Sol provider failed")
@ -90,6 +103,49 @@ class SolProvider:
metadata={"provider": self.provider_name, "usage": {}},
)
def stream(self, request: ModelRequestContract) -> Iterator[ModelChunk]:
config = self.resource.config
command = str(config.get("command", "opencode run"))
transport = str(config.get("transport", "ssh"))
working_directory = str(config.get("working_directory") or "").strip() or None
if transport == "local":
argv = shlex.split(command, posix=os.name != "nt")
else:
compute = self.resource.compute
ssh_alias = (compute.config if compute else {}).get(
"ssh_alias", config.get("ssh_alias", "spark")
)
remote_command = " ".join(shlex.quote(part) for part in shlex.split(command))
if working_directory:
remote_command = f"cd {shlex.quote(working_directory)} && {remote_command}"
argv = ["ssh", "-T", str(ssh_alias), remote_command]
process = subprocess.Popen(
argv,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
cwd=working_directory if transport == "local" else None,
stdin=subprocess.PIPE,
)
assert process.stdin is not None
process.stdin.write(request.prompt)
process.stdin.close()
emitted = False
assert process.stdout is not None
for line in process.stdout:
cleaned = _clean_opencode_output(line)
if not cleaned:
continue
emitted = True
yield ModelChunk(cleaned + "\n", {"provider": self.provider_name})
stderr = process.stderr.read().strip() if process.stderr is not None else ""
return_code = process.wait()
if return_code != 0:
raise ProviderError(stderr or "Sol streaming provider failed")
if not emitted:
raise ProviderError("Sol streaming provider response missing content")
def health(self) -> str:
config = self.resource.config
try:
@ -160,6 +216,57 @@ class QwenProvider:
},
)
def stream(self, request: ModelRequestContract) -> Iterator[ModelChunk]:
config = self.resource.config
url = str(config.get("endpoint_url", "http://localhost:8000/v1/chat/completions"))
body = {
"model": config.get("model", self.resource.name),
"messages": [{"role": "user", "content": request.prompt}],
"max_tokens": request.token_budget,
"temperature": config.get("temperature", 0),
"stream": True,
"stream_options": {"include_usage": True},
}
if config.get("extra_body"):
body.update(config["extra_body"])
http_request = urllib.request.Request(
url,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
emitted = False
usage: dict[str, object] = {}
with urllib.request.urlopen(
http_request, timeout=int(config.get("timeout_seconds", 240))
) as response:
for raw_line in response:
line = raw_line.decode("utf-8").strip()
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload == "[DONE]":
break
try:
event = json.loads(payload)
except json.JSONDecodeError:
continue
if event.get("usage"):
usage = event["usage"]
yield ModelChunk("", {"provider": self.provider_name, "usage": usage})
choices = event.get("choices") or []
if not choices:
continue
content = (choices[0].get("delta") or {}).get("content")
if content:
emitted = True
yield ModelChunk(
content,
{"provider": self.provider_name, "usage": usage},
)
if not emitted:
raise ProviderError("Qwen streaming provider response missing content")
def health(self) -> str:
base_url = str(self.resource.config.get("health_url", self.resource.config.get("endpoint_url", ""))).replace(
"/v1/chat/completions", "/health"

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import time
import uuid
from collections.abc import Iterator
from dataclasses import dataclass
from enum import StrEnum
from typing import Protocol
@ -21,6 +22,11 @@ class ModelCapability(StrEnum):
CODING = "CODING"
REVIEW = "REVIEW"
REASONING = "REASONING"
STORY_PLANNING = "STORY_PLANNING"
STORY_PROSE = "STORY_PROSE"
STORY_CONTINUITY = "STORY_CONTINUITY"
STORY_REVIEW = "STORY_REVIEW"
STORY_REVISION = "STORY_REVISION"
@dataclass(frozen=True)
@ -40,11 +46,19 @@ class ModelResponseContract:
metadata: dict[str, object]
@dataclass(frozen=True)
class ModelChunk:
content: str
metadata: dict[str, object] | None = None
class ModelProvider(Protocol):
provider_name: str
def complete(self, request: ModelRequestContract) -> ModelResponseContract: ...
def stream(self, request: ModelRequestContract) -> Iterator[ModelChunk]: ...
def health(self) -> str: ...
@ -74,6 +88,43 @@ class ModelRouter:
self._finish_record(record, "COMPLETE", response, started)
return response
def stream(self, request: ModelRequestContract) -> Iterator[ModelChunk]:
provider_key = request.model_hint or self.route(request.purpose)
provider = self.providers.get(provider_key)
if provider is None:
raise RuntimeError(f"No model provider configured for {provider_key}")
model_resource = self._resource_for(provider_key, request.purpose) if self.persist_requests else None
record = self._start_record(request, provider, model_resource)
started = time.monotonic()
chunks: list[str] = []
metadata: dict[str, object] = {}
try:
stream_method = getattr(provider, "stream", None)
if stream_method is None:
response = provider.complete(request)
chunks.append(response.content)
metadata = response.metadata
yield ModelChunk(response.content, response.metadata)
else:
for chunk in stream_method(request):
if chunk.metadata:
metadata.update(chunk.metadata)
if not chunk.content:
continue
chunks.append(chunk.content)
yield chunk
except Exception as exc:
if record is not None:
self._finish_record(record, "FAILED", None, started, failure_reason=str(exc))
raise
if record is not None:
response = ModelResponseContract(
model=model_resource.name if model_resource else provider_key,
content="".join(chunks),
metadata=metadata,
)
self._finish_record(record, "COMPLETE", response, started)
def health(self) -> dict[str, str]:
statuses: dict[str, str] = {}
for key, provider in self.providers.items():

View file

@ -7,6 +7,7 @@ dependencies = [
"django>=5.1,<6.0",
"psycopg[binary]>=3.2,<4.0",
"langgraph>=0.2,<0.3",
"langgraph-checkpoint-postgres>=2.0,<3.0",
"structlog>=24.4,<25.0",
"numpy>=2.0,<3.0",
"numba>=0.60,<1.0",

View file

@ -1,2 +1,2 @@
{% extends "control_plane/base.html" %}
{% block content %}<header class="page"><div><h2>Approvals</h2><p class="muted">Durable approval inbox for paused graph decisions.</p></div></header><table><thead><tr><th>Source</th><th>Project</th><th>Reason</th><th>Evidence</th><th>Requested</th><th>Actions</th></tr></thead><tbody>{% for approval in approvals %}<tr><td><a href="{% url 'graph_run_detail' approval.graph_run.id %}">{{ approval.graph_run.execution_graph_version.graph.name }} v{{ approval.graph_run.execution_graph_version.version }}</a></td><td>{{ approval.graph_run.project.name|default:"-" }}</td><td>{{ approval.reason }}</td><td><pre>{{ approval.payload }}</pre></td><td>{{ approval.created_at }}</td><td><form class="actions" method="post" action="{% url 'approval_action' approval.id %}">{% csrf_token %}<button name="action" value="approve">Approve</button><button class="secondary" name="action" value="reject">Reject</button></form></td></tr>{% empty %}<tr><td colspan="6">No pending approvals.</td></tr>{% endfor %}</tbody></table>{% endblock %}
{% block content %}<header class="page"><div><h2>Approvals</h2><p class="muted">Durable approval inbox for paused graph decisions.</p></div></header><table><thead><tr><th>Source</th><th>Project</th><th>Reason</th><th>Evidence</th><th>Requested</th><th>Actions</th></tr></thead><tbody>{% for approval in approvals %}<tr><td><a href="{% url 'graph_run_detail' approval.graph_run.id %}">{{ approval.graph_run.execution_graph_version.graph.name }} v{{ approval.graph_run.execution_graph_version.version }}</a></td><td>{{ approval.graph_run.project.name|default:"-" }}</td><td>{{ approval.reason }}</td><td><pre>{{ approval.payload }}</pre></td><td>{{ approval.created_at }}</td><td><form class="actions" method="post" action="{% url 'approval_action' approval.id %}">{% csrf_token %}{% if approval.graph_run.execution_graph_version.graph.name == "story_authoring" %}<textarea name="notes" rows="3" placeholder="Optional revision notes"></textarea>{% endif %}<button name="action" value="approve">Approve</button><button class="secondary" name="action" value="reject">{% if approval.graph_run.execution_graph_version.graph.name == "story_authoring" %}Request changes{% else %}Reject{% endif %}</button></form></td></tr>{% empty %}<tr><td colspan="6">No pending approvals.</td></tr>{% endfor %}</tbody></table>{% endblock %}

View file

@ -11,10 +11,13 @@ django.setup()
@pytest.fixture(scope="session", autouse=True)
def migrated_database():
call_command("migrate", verbosity=0, interactive=False)
def migrated_database(django_db_blocker):
with django_db_blocker.unblock():
call_command("migrate", verbosity=0, interactive=False)
@pytest.fixture(autouse=True)
def clean_database(migrated_database):
call_command("flush", verbosity=0, interactive=False)
def clean_database(migrated_database, django_db_blocker):
with django_db_blocker.unblock():
call_command("flush", verbosity=0, interactive=False)
yield

View file

@ -0,0 +1,534 @@
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
import pytest
from django.test import Client
from django.urls import reverse
from control_plane.authoring.book_state import BookStateService, validate_book_state_content
from control_plane.authoring.models import (
BookRunStatus,
BookStateStatus,
SceneDraftStatus,
Series,
StandaloneScene,
StoryProject,
Work,
)
from control_plane.authoring.standalone_scenes import StandaloneSceneService
from control_plane.projects.models import Project
from model_router.router import ModelResponseContract
pytestmark = pytest.mark.django_db(transaction=True)
class FakeReviewRouter:
def __init__(self) -> None:
self.requests = []
def complete(self, request):
self.requests.append(request)
return ModelResponseContract(
model="test-reviewer",
content=json.dumps({"findings": []}),
metadata={},
)
@pytest.fixture
def work(tmp_path: Path) -> Work:
series = Series.objects.create(title="Test Series", slug="test-series")
work = Work.objects.create(series=series, title="Test Book", slug="test-book")
project = Project.objects.create(name="Test Book", project_type="STORY", goal="Write")
StoryProject.objects.create(
project=project,
work=work,
title=work.title,
series=series.title,
slug=work.slug,
artifact_root=str(tmp_path),
)
return work
def book_content(chapter_count: int = 3) -> dict:
chapters = []
for number in range(1, chapter_count + 1):
key = f"chapter-{number}"
chapters.append(
{
"chapter_key": key,
"number": number,
"order": number,
"title": f"Chapter {number}",
"purpose": f"Advance turn {number}.",
"target_words": 1800,
"depends_on": [] if number == 1 else [f"chapter-{number - 1}"],
"act_id": "act-1",
"arc_ids": ["main-arc"],
"thread_ids": ["main-thread"],
"beats": [
{
"beat_id": f"beat-{number}",
"text": f"The chapter performs turn {number}.",
"required": True,
}
],
"ending_state": f"Turn {number} is complete.",
"scene_refs": [],
}
)
return {
"schema_version": 1,
"title": "Test Book",
"premise": "A protagonist makes progressively harder choices.",
"target_words": chapter_count * 1800,
"constraints": ["Preserve agency."],
"forbidden_events": [],
"acts": [
{
"act_id": "act-1",
"number": 1,
"start_chapter": 1,
"end_chapter": chapter_count,
}
],
"arcs": [
{
"arc_id": "main-arc",
"start_chapter": "chapter-1",
"end_chapter": f"chapter-{chapter_count}",
}
],
"threads": [
{
"thread_id": "main-thread",
"start_chapter": "chapter-1",
"end_chapter": f"chapter-{chapter_count}",
}
],
"continuity": [
{
"fact_id": "fact-choice",
"entity_key": "character.protagonist",
"category": "character",
"description": "The protagonist has made the first choice.",
"established_in": "chapter-1",
"resolved_in": f"chapter-{chapter_count}",
}
],
"chapters": chapters,
"ending": "The central choice has lasting consequences.",
"open_questions": [],
}
def passing_reviews(state) -> dict:
state_hash = state.sha256
return {
"act:act-1": {"passed": True, "state_sha256": state_hash},
"manuscript": {"passed": True, "state_sha256": state_hash},
}
def make_review_ready(state) -> None:
state.status = BookStateStatus.REVIEW
state.reviews = passing_reviews(state)
state.save(update_fields=["status", "reviews", "updated_at"])
def test_validation_rejects_forward_dependency_and_sorts_messages(work: Work) -> None:
content = book_content()
content["chapters"][0]["depends_on"] = ["chapter-2"]
result = validate_book_state_content(content, work=work)
assert result["valid"] is False
assert result["errors"] == sorted(result["errors"])
assert any("must name an earlier chapter" in error for error in result["errors"])
def test_validation_checks_acts_continuity_and_approval_ending(work: Work) -> None:
content = book_content()
content["acts"][0]["end_chapter"] = 2
content["continuity"][0]["resolved_in"] = "chapter-1"
content["continuity"][0]["established_in"] = "chapter-2"
content["chapters"][1]["ending_state"] = ""
draft = validate_book_state_content(content, work=work)
approval = validate_book_state_content(content, work=work, for_approval=True)
assert any("ranges must cover every chapter" in error for error in draft["errors"])
assert any("cannot precede establishment" in error for error in draft["errors"])
assert any("ending_state" in warning for warning in draft["warnings"])
assert any("ending_state" in error for error in approval["errors"])
def test_validation_reports_malformed_word_targets_without_raising(work: Work) -> None:
content = book_content(1)
content["chapters"][0]["target_words"] = "many"
result = validate_book_state_content(content, work=work)
assert result["valid"] is False
assert any("target_words" in error for error in result["errors"])
def test_service_allocates_versions_and_preserves_parent(work: Work) -> None:
service = BookStateService(FakeReviewRouter())
first = service.create(work=work, content=book_content())
revised_content = deepcopy(first.content)
revised_content["open_questions"] = ["Who notices the cost?"]
second = service.revise(first, content=revised_content)
assert (first.version, second.version) == (1, 2)
assert second.parent == first
assert second.change_summary["changed_sections"] == ["open_questions"]
assert second.change_summary["changed_from_chapter"] is None
first.content["premise"] = "Mutation is forbidden."
with pytest.raises(ValueError, match="immutable"):
first.save()
def test_impact_propagates_contract_change_to_dependents_and_later_continuity(
work: Work,
) -> None:
service = BookStateService(FakeReviewRouter())
first = service.create(work=work, content=book_content())
changed = deepcopy(first.content)
changed["chapters"][1]["purpose"] = "Force a materially different second turn."
second = service.revise(first, content=changed)
impact = service.impact(second)
assert impact["from_chapter"] == 2
assert impact["replan_required"] == ["chapter-2", "chapter-3"]
assert impact["continuity_review_required"] == ["chapter-3"]
def test_approval_requires_all_reviews_and_rejects_stale_parent(work: Work) -> None:
service = BookStateService(FakeReviewRouter())
first = service.create(work=work, content=book_content())
first.status = BookStateStatus.REVIEW
first.reviews = {"manuscript": {"passed": True, "state_sha256": first.sha256}}
first.save(update_fields=["status", "reviews", "updated_at"])
with pytest.raises(ValueError, match="act:act-1"):
service.approve(first)
first.reviews = passing_reviews(first)
first.save(update_fields=["reviews", "updated_at"])
service.approve(first, actor="editor")
child = service.revise(first, content=deepcopy(first.content))
make_review_ready(child)
work.current_book_state = None
work.save(update_fields=["current_book_state", "updated_at"])
with pytest.raises(ValueError, match="stale"):
service.approve(child)
def test_review_routes_structure_and_continuity_to_distinct_capabilities(work: Work) -> None:
router = FakeReviewRouter()
service = BookStateService(router)
state = service.create(work=work, content=book_content())
act_review = service.review(state, "act:act-1")
manuscript_review = service.review(state, "manuscript")
continuity_review = service.review(state, "continuity")
assert act_review["passed"] and manuscript_review["passed"] and continuity_review["passed"]
assert [str(request.purpose) for request in router.requests] == [
"STORY_REVIEW",
"STORY_REVIEW",
"STORY_CONTINUITY",
]
def test_run_sync_uses_only_scenes_bound_to_exact_state_and_chapter(work: Work) -> None:
service = BookStateService(FakeReviewRouter())
state = service.create(work=work, content=book_content(2))
make_review_ready(state)
state = service.approve(state)
run = service.start_run(state)
run = service.sync_run(run)
assert run.status == BookRunStatus.PAUSED
assert run.current_chapter_key == "chapter-1"
assert run.progress == {"chapter-1": "needs_scene", "chapter-2": "needs_scene"}
StandaloneScene.objects.create(
work=work,
book_state=state,
book_chapter_key="chapter-1",
scene_key="chapter-1-scene",
revision=1,
title="Chapter 1 Scene",
status=SceneDraftStatus.APPROVED,
brief="Perform the first turn.",
prose="The protagonist made the first choice.",
)
run = service.sync_run(run)
assert run.progress["chapter-1"] == "complete"
assert run.current_chapter_key == "chapter-2"
StandaloneScene.objects.create(
work=work,
book_state=state,
book_chapter_key="chapter-2",
scene_key="chapter-2-scene",
revision=1,
title="Chapter 2 Scene",
status=SceneDraftStatus.APPROVED,
brief="Perform the second turn.",
prose="The protagonist made the second choice.",
)
run = service.sync_run(run)
assert run.status == BookRunStatus.REVIEW
review = service.review_run(run)
run.refresh_from_db()
assert review["passed"] is True
assert run.status == BookRunStatus.COMPLETE
assert run.reviews["continuity"]["scene_set_sha256"]
assert [item["chapter_key"] for item in run.reviews["continuity"]["scene_manifest"]] == [
"chapter-1",
"chapter-2",
]
scene_service = StandaloneSceneService(FakeReviewRouter())
with pytest.raises(ValueError, match="start a new book run"):
scene_service.create(
work=work,
title="Chapter 2 Scene",
brief="Revise after completion.",
book_state=state,
book_chapter_key="chapter-2",
)
service.start_run(state)
revision = scene_service.create(
work=work,
title="Chapter 2 Scene",
brief="Revise in an explicit new run.",
book_state=state,
book_chapter_key="chapter-2",
)
assert revision.revision == 2
def test_lifecycle_persists_audit_metadata_and_run_policy(work: Work) -> None:
service = BookStateService(FakeReviewRouter())
state = service.create(
work=work,
content=book_content(1),
actor="planner",
context_pack={"sources": ["outline-v2"]},
generation_metadata={"model": "test-planner"},
)
state.status = BookStateStatus.REVIEW
state.save(update_fields=["status", "updated_at"])
with pytest.raises(ValueError, match="requires notes"):
service.approve(state, actor="editor", force=True)
state = service.approve(state, actor="editor", force=True, notes="Manual exception.")
run = service.start_run(state, policy={"max_parallel_chapters": 1})
assert state.created_by == "planner"
assert state.context_pack == {"sources": ["outline-v2"]}
assert state.generation_metadata == {"model": "test-planner"}
assert state.approval_forced is True
assert state.approval_notes == "Manual exception."
assert run.policy == {
"max_parallel_chapters": 1,
"required_reviews": ["act:act-1", "manuscript"],
"run_required_reviews": ["continuity"],
}
def test_rejection_persists_actor_notes_and_timestamp(work: Work) -> None:
service = BookStateService(FakeReviewRouter())
state = service.create(work=work, content=book_content(1))
state = service.reject(state, actor="editor", notes="Dependency needs revision.")
assert state.status == BookStateStatus.REJECTED
assert state.rejected_at is not None
assert state.rejected_by == "editor"
assert state.rejection_notes == "Dependency needs revision."
def test_book_state_artifact_paths_remain_pinned_through_approval(
work: Work, tmp_path: Path
) -> None:
service = BookStateService(FakeReviewRouter())
state = service.create(work=work, content=book_content(1))
original_json_uri = state.json_artifact_uri
original_markdown_uri = state.markdown_artifact_uri
story = work.story_project
story.artifact_root = str(tmp_path / "moved-artifacts")
story.save(update_fields=["artifact_root", "updated_at"])
make_review_ready(state)
state = service.approve(state)
assert state.json_artifact_uri == original_json_uri
assert state.markdown_artifact_uri == original_markdown_uri
def test_book_api_approval_and_run_actions_match_service_contract(
work: Work, monkeypatch: pytest.MonkeyPatch
) -> None:
service = BookStateService(FakeReviewRouter())
monkeypatch.setattr("control_plane.authoring.views.book_service", lambda: service)
client = Client()
response = client.post(
reverse("book_states"),
data=json.dumps(
{
"series_slug": work.series.slug,
"work_slug": work.slug,
"content": book_content(1),
"actor": "api-planner",
"context_pack": {"sources": ["outline-v2"]},
}
),
content_type="application/json",
)
assert response.status_code == 201
state_id = response.json()["id"]
state = work.book_state_versions.get(id=state_id)
state.status = BookStateStatus.REVIEW
state.save(update_fields=["status", "updated_at"])
invalid_boolean = client.post(
reverse("book_state_action", args=[state.id]),
data=json.dumps(
{
"action": "approve",
"force": "false",
"notes": "This must not be treated as true.",
}
),
content_type="application/json",
)
approval = client.post(
reverse("book_state_action", args=[state.id]),
data=json.dumps(
{
"action": "approve",
"actor": "api-editor",
"force": True,
"notes": "Reviewed outside Artifex.",
}
),
content_type="application/json",
)
run = client.post(
reverse("book_state_action", args=[state.id]),
data=json.dumps({"action": "start_run", "policy": {"max_parallel_chapters": 1}}),
content_type="application/json",
)
assert invalid_boolean.status_code == 400
assert invalid_boolean.json()["error"] == "force must be boolean"
assert approval.status_code == 200
assert approval.json()["approval_forced"] is True
assert approval.json()["approval_notes"] == "Reviewed outside Artifex."
assert run.status_code == 201
assert run.json()["policy"]["max_parallel_chapters"] == 1
def test_review_rejects_malformed_model_response(work: Work) -> None:
class MalformedReviewRouter(FakeReviewRouter):
def complete(self, request):
self.requests.append(request)
return ModelResponseContract(model="bad-reviewer", content="{}", metadata={})
service = BookStateService(MalformedReviewRouter())
state = service.create(work=work, content=book_content(1))
with pytest.raises(ValueError, match="findings list"):
service.review(state, "manuscript")
def test_sync_rejects_cancelled_run(work: Work) -> None:
service = BookStateService(FakeReviewRouter())
state = service.create(work=work, content=book_content(1))
make_review_ready(state)
state = service.approve(state)
run = service.start_run(state)
run.status = BookRunStatus.CANCELLED
run.save(update_fields=["status", "updated_at"])
with pytest.raises(ValueError, match="cancelled"):
service.sync_run(run)
def test_bound_scene_lineage_is_scoped_and_unambiguous(work: Work) -> None:
book_service = BookStateService(FakeReviewRouter())
state = book_service.create(work=work, content=book_content(1))
make_review_ready(state)
state = book_service.approve(state)
scene_service = StandaloneSceneService(FakeReviewRouter())
first = scene_service.create(
work=work,
title="First Draft",
brief="Perform the chapter turn.",
book_state=state,
book_chapter_key="chapter-1",
)
revision = scene_service.create(
work=work,
title="First Draft",
brief="Revise the chapter turn.",
book_state=state,
book_chapter_key="chapter-1",
)
assert revision.parent == first
assert revision.revision == 2
unbound = scene_service.create(
work=work,
title="First Draft",
brief="Use the same title outside book-state orchestration.",
)
assert scene_service._artifact_paths(first) != scene_service._artifact_paths(unbound)
with pytest.raises(ValueError, match="different scene lineage"):
scene_service.create(
work=work,
title="Competing Draft",
brief="Create an ambiguous chapter lineage.",
book_state=state,
book_chapter_key="chapter-1",
)
def test_scene_refs_reject_scenes_bound_to_another_book_state(work: Work) -> None:
service = BookStateService(FakeReviewRouter())
state = service.create(work=work, content=book_content(1))
make_review_ready(state)
state = service.approve(state)
scene = StandaloneScene.objects.create(
work=work,
book_state=state,
book_chapter_key="chapter-1",
scene_key="bound-source",
revision=1,
title="Bound Source",
status=SceneDraftStatus.APPROVED,
brief="A bound scene cannot be reused as an assembly reference.",
prose="Approved prose.",
)
revised = deepcopy(state.content)
revised["chapters"][0]["scene_refs"] = [
{"scene_id": str(scene.id), "revision": scene.revision, "sha256": scene.sha256}
]
with pytest.raises(ValueError, match="unbound assembly scenes"):
service.revise(state, content=revised)

View file

@ -1,12 +1,19 @@
from __future__ import annotations
import io
import json
import urllib.error
from agents.providers import DeterministicCodingProvider, DeterministicSolProvider
from control_plane.resources.models import ModelRequest, Resource, ResourceKind
from model_router.providers import ProviderError, QwenProvider, SolProvider, providers_from_resources
from model_router.router import ModelCapability, ModelRequestContract, ModelRouter
from model_router.policy import model_for_purpose
from model_router.providers import (
ProviderError,
QwenProvider,
SolProvider,
providers_from_resources,
)
from model_router.router import ModelCapability, ModelChunk, ModelRequestContract, ModelRouter
def test_model_router_persists_sanitized_request_metadata() -> None:
@ -34,6 +41,22 @@ def test_model_router_health_is_non_throwing() -> None:
assert router.health() == {"qwen": "AVAILABLE"}
def test_model_router_stream_falls_back_to_complete() -> None:
router = ModelRouter({"qwen": DeterministicCodingProvider()})
chunks = list(
router.stream(
ModelRequestContract(
purpose=ModelCapability.CODING, model_hint="qwen", prompt="hello"
)
)
)
assert len(chunks) == 1
assert isinstance(chunks[0], ModelChunk)
assert chunks[0].content
def test_opencode_model_key_resources_load_as_distinct_providers() -> None:
for key in ["sol", "terra", "luna"]:
Resource.objects.create(name=key.title(), kind=ResourceKind.MODEL, provider="opencode", roles=["REASONING"], config={"model_key": key})
@ -127,3 +150,76 @@ def test_qwen_provider_reports_retry_exhaustion(monkeypatch) -> None:
assert "Qwen provider failed after retries" in message
assert "attempt 1/2" in message
assert "attempt 2/2" in message
def test_qwen_stream_forwards_no_thinking_and_persists_usage(monkeypatch) -> None:
resource = Resource.objects.create(
name="Qwen",
kind=ResourceKind.MODEL,
provider="local_inference",
roles=["STORY_PROSE"],
config={
"endpoint_url": "http://qwen.test/v1/chat/completions",
"model": "qwen38",
"extra_body": {"chat_template_kwargs": {"enable_thinking": False}},
},
)
bodies = []
class Response:
status = 200
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
return iter(
[
b'data: {"choices":[{"delta":{"content":"draft"}}]}\n',
b'data: {"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3}}\n',
b"data: [DONE]\n",
]
)
def fake_urlopen(request, timeout):
bodies.append(json.loads(request.data.decode("utf-8")))
return Response()
monkeypatch.setattr("model_router.providers.urllib.request.urlopen", fake_urlopen)
router = ModelRouter({"qwen": QwenProvider(resource)}, persist_requests=True)
chunks = list(
router.stream(
ModelRequestContract(
purpose=ModelCapability.STORY_PROSE,
prompt="write",
model_hint="qwen",
)
)
)
request = ModelRequest.objects.get()
assert "".join(chunk.content for chunk in chunks) == "draft"
assert bodies[0]["chat_template_kwargs"]["enable_thinking"] is False
assert request.prompt_tokens == 12
assert request.completion_tokens == 3
def test_story_defaults_use_terra_luna_qwen_policy(monkeypatch) -> None:
for name in [
"ARTIFEX_STORY_PLANNING_MODEL",
"ARTIFEX_STORY_PROSE_MODEL",
"ARTIFEX_STORY_CONTINUITY_MODEL",
"ARTIFEX_STORY_REVIEW_MODEL",
"ARTIFEX_STORY_REVISION_MODEL",
]:
monkeypatch.delenv(name, raising=False)
assert model_for_purpose(ModelCapability.STORY_PLANNING) == "terra"
assert model_for_purpose(ModelCapability.STORY_PROSE) == "terra"
assert model_for_purpose(ModelCapability.STORY_CONTINUITY) == "luna"
assert model_for_purpose(ModelCapability.STORY_REVIEW) == "terra"
assert model_for_purpose(ModelCapability.STORY_REVISION) == "luna"

View file

@ -0,0 +1,428 @@
from __future__ import annotations
import json
from io import StringIO
from pathlib import Path
from unittest.mock import patch
import pytest
from django.core.management import call_command
from django.test import Client
from django.urls import reverse
from control_plane.authoring.models import (
BookStateStatus,
BookStateVersion,
DocumentAuthority,
DocumentType,
SceneDraftStatus,
Series,
StandaloneScene,
Work,
)
from control_plane.authoring.sources import register_source
from control_plane.authoring.standalone_scenes import (
SceneIdeationService,
render_scene_ideation_compact_markdown,
render_scene_ideation_markdown,
)
from model_router.router import ModelRequestContract, ModelResponseContract, ModelRouter
class FakeIdeationProvider:
provider_name = "test"
def complete(self, _request: ModelRequestContract) -> ModelResponseContract:
return ModelResponseContract(
model="test-ideas",
content=json.dumps(
{
"candidates": [
{
"title": "The Unused Chair",
"brief": "Sabine and Corin negotiate an ordinary household choice.",
"purpose": "Spend an unresolved agency question through action.",
"placement": "After Sabine begins paid estate work.",
"pov_character": "Sabine",
"scene_type": "quiet_connection",
"type_fit": "The scene changes meaning through a freely chosen quiet presence.",
"scope_fit": "Sabine's paid estate work exists within Book Six.",
"prerequisites": ["Sabine has begun paid estate work."],
"target_words": 1600,
"citations": ["SRC-01", "SRC-02"],
"opportunity": "Turn a stated boundary into an ordinary decision.",
"future_opportunities": [
"Sabine can later delegate authority on her own terms.",
"Corin can face a higher-cost choice not to intervene.",
],
"constraints": ["Sabine makes the final choice."],
"forbidden_events": ["No sexual escalation."],
"boundary_constraints": ["The decision remains reversible."],
"continuity_questions": ["Confirm exact chronology."],
"risks": ["Do not make employment repayment for rescue."],
},
{
"title": "A Route Left Open",
"brief": "Corin declines to optimize a shared evening for Sabine.",
"purpose": "Test whether restraint can produce a new romantic option.",
"placement": "During an unassigned Book Two interval.",
"pov_character": "Corin",
"scene_type": "major_turn",
"type_fit": "Corin's refusal changes their available relationship choices.",
"scope_fit": "The established relationship question is active in Book Six.",
"prerequisites": ["Sabine and Corin know each other."],
"target_words": 1900,
"citations": ["SRC-01"],
"opportunity": (
"Use an unspent choice without resolving later milestones."
),
"future_opportunities": [
"Their unfinished route can acquire a different meaning later."
],
"constraints": [],
"forbidden_events": ["No completed intercourse."],
"boundary_constraints": ["Envelope After remains sealed."],
"continuity_questions": [],
"risks": [],
},
]
}
),
metadata={},
)
def health(self) -> str:
return "AVAILABLE"
def idea_work(tmp_path: Path) -> Work:
series = Series.objects.create(title="Labyrinth Hero", slug="labyrinth-hero")
work = Work.objects.create(
series=series,
title="The Fortune Below",
slug="the-fortune-below",
)
root = tmp_path / "idea-sources"
root.mkdir()
sources = [
("canon.md", "Sabine preserves explicit choice and independent authority.", "canon"),
("planning.md", "Sabine begins paid estate work only after freedom.", "planning"),
("experiment.md", "Sabine accepts an invented irreversible promise.", "provisional"),
]
for name, content, authority in sources:
path = root / name
path.write_text(content, encoding="utf-8")
register_source(
work=work,
path=path,
root=root,
authority=authority,
document_type=(
DocumentType.CANON
if authority == DocumentAuthority.CANON
else DocumentType.PLANNING
),
)
return work
def test_ideation_is_cited_read_only_and_selection_is_idempotent(tmp_path: Path) -> None:
work = idea_work(tmp_path)
service = SceneIdeationService(ModelRouter({"sol": FakeIdeationProvider()}))
idea = service.propose(
work=work,
target_book="Book Six",
focus="Sabine ordinary choice agency",
candidate_count=2,
governing_document_keys=["planning.md"],
model_hint="sol",
)
assert StandaloneScene.objects.count() == 0
assert {item["authority"] for item in idea.context_pack["citations"]} == {
DocumentAuthority.CANON,
DocumentAuthority.PLANNING,
}
assert [item["candidate_id"] for item in idea.candidates] == ["idea-01", "idea-02"]
assert idea.candidates[0]["future_opportunities"] == [
"Sabine can later delegate authority on her own terms.",
"Corin can face a higher-cost choice not to intervene.",
]
assert len(idea.generation_metadata["prompt_sha256"]) == 64
assert idea.target_book == "Book Six"
assert "physical_escalation" in idea.requested_scene_types
assert idea.context_pack["governing_document_keys"] == ["planning.md"]
assert idea.context_pack["citations"][0]["kind"] == "governing_document"
scene, created = service.select_candidate(idea, candidate_id="idea-01")
same_scene, created_again = service.select_candidate(idea, candidate_id="idea-01")
assert created is True
assert created_again is False
assert same_scene.id == scene.id
assert scene.status == SceneDraftStatus.PLANNING
assert scene.title == "The Unused Chair"
assert scene.constraints == [
"Sabine makes the final choice.",
"Placement scope: Book Six. Sabine's paid estate work exists within Book Six.",
]
assert StandaloneScene.objects.count() == 1
def test_scene_idea_api_proposes_and_selects_candidate(tmp_path: Path) -> None:
work = idea_work(tmp_path)
service = SceneIdeationService(ModelRouter({"sol": FakeIdeationProvider()}))
with patch("control_plane.authoring.views.ideation_service", return_value=service):
response = Client().post(
reverse("scene_ideas"),
data=json.dumps(
{
"series_slug": work.series.slug,
"work_slug": work.slug,
"target_book": "Book Six",
"focus": "Sabine ordinary choice agency",
"candidate_count": 2,
"model": "sol",
}
),
content_type="application/json",
)
idea_id = response.json()["id"]
selected = Client().post(
reverse("scene_idea_action", args=[idea_id]),
data=json.dumps({"action": "select", "candidate_id": "idea-02"}),
content_type="application/json",
)
selected_again = Client().post(
reverse("scene_idea_action", args=[idea_id]),
data=json.dumps({"action": "select", "candidate_id": "idea-02"}),
content_type="application/json",
)
assert response.status_code == 201
assert response.json()["candidates"][0]["candidate_id"] == "idea-01"
assert selected.status_code == 201
assert selected.json()["created"] is True
assert selected.json()["scene"]["status"] == SceneDraftStatus.PLANNING
assert selected_again.status_code == 200
assert selected_again.json()["created"] is False
def test_book_state_ideation_binds_selected_scene_to_chapter(tmp_path: Path) -> None:
work = idea_work(tmp_path)
state = BookStateVersion.objects.create(
work=work,
version=1,
status=BookStateStatus.APPROVED,
content={
"title": work.title,
"chapters": [
{
"chapter_key": "chapter-1",
"title": "Chapter 1",
"purpose": "Open the route.",
}
],
},
)
service = SceneIdeationService(ModelRouter({"sol": FakeIdeationProvider()}))
idea = service.propose(
work=work,
target_book="Book Two",
candidate_count=2,
model_hint="sol",
book_state=state,
)
with pytest.raises(ValueError, match="book_chapter_key"):
service.select_candidate(idea, candidate_id="idea-01")
scene, created = service.select_candidate(
idea,
candidate_id="idea-01",
book_chapter_key="chapter-1",
)
assert created is True
assert idea.book_state == state
assert idea.context_pack["book_state_sha256"] == state.sha256
assert scene.book_state == state
assert scene.book_chapter_key == "chapter-1"
def test_ideation_rejects_citations_outside_frozen_context() -> None:
with pytest.raises(ValueError, match="supplied citation IDs"):
SceneIdeationService._normalize_candidates(
{
"candidates": [
{
"title": "Unsupported Idea",
"brief": "A proposal grounded in evidence the model did not receive.",
"purpose": "Verify citation validation.",
"opportunity": "Spend an unsupported question.",
"scene_type": "quiet_connection",
"type_fit": "A quiet choice changes the relationship.",
"scope_fit": "All prerequisites exist in the selected book.",
"citations": ["SRC-99"],
"future_opportunities": ["A later choice becomes available."],
}
]
},
candidate_count=1,
citation_ids={"SRC-01"},
)
def test_ideation_requires_future_opportunities() -> None:
with pytest.raises(ValueError, match="future_opportunities"):
SceneIdeationService._normalize_candidates(
{
"candidates": [
{
"title": "Closed Door",
"brief": "A choice closes one route without resolving the relationship.",
"purpose": "Spend an established question.",
"opportunity": "The unresolved question can now be answered through action.",
"scene_type": "quiet_connection",
"type_fit": "The choice changes their physical distance.",
"scope_fit": "All prerequisites exist in the selected book.",
"citations": ["SRC-01"],
}
]
},
candidate_count=1,
citation_ids={"SRC-01"},
)
def test_ideation_markdown_export_is_deterministic_and_read_only(tmp_path: Path) -> None:
work = idea_work(tmp_path)
service = SceneIdeationService(ModelRouter({"sol": FakeIdeationProvider()}))
idea = service.propose(
work=work,
target_book="Book Six",
focus="Sabine ordinary choice agency",
candidate_count=2,
model_hint="sol",
)
expected = render_scene_ideation_markdown(idea)
output = tmp_path / "reviews" / "ideas.md"
stdout = StringIO()
call_command(
"fiction_ideas",
"export",
"--id",
str(idea.id),
"--output",
str(output),
stdout=stdout,
)
assert output.read_text(encoding="utf-8") == expected
assert "# Scene Ideas: The Fortune Below" in expected
assert "## idea-01: The Unused Chair" in expected
assert "**Scene type:** `quiet_connection`" in expected
assert "### Future Opportunities Created" in expected
assert "Sabine can later delegate authority on her own terms." in expected
assert "- No candidate has been selected." in expected
assert "- Target book: `Book Six`" in expected
assert "- Requested scene types:" in expected
assert "- Governing documents: (none)" in expected
assert "### Book Scope Fit" in expected
assert "### Scene Type Fit" in expected
assert "## Frozen Citation Index" in expected
assert "`canon.md`" in expected
assert str(output) in stdout.getvalue()
assert StandaloneScene.objects.count() == 0
def test_compact_ideation_normalization_and_export(tmp_path: Path) -> None:
candidates = SceneIdeationService._normalize_candidates(
{
"candidates": [
{
"title": "A Deliberate Threshold",
"brief": "Corin makes a physical choice Sabine did not design for him.",
"scene_type": "physical_escalation",
"citations": ["SRC-01"],
"opportunity": "Reveal what Corin independently wants.",
"future_opportunities": [
"Sabine can recognize the choice when he makes it again.",
"Corin can decide whether to cross the next threshold.",
],
}
]
},
candidate_count=1,
citation_ids={"SRC-01"},
allowed_scene_types={"physical_escalation"},
compact=True,
)
work = idea_work(tmp_path)
service = SceneIdeationService(ModelRouter({"sol": FakeIdeationProvider()}))
idea = service.propose(
work=work,
target_book="Book Six",
candidate_count=2,
model_hint="sol",
)
idea.candidates = candidates
idea.save(update_fields=["candidates", "updated_at"])
rendered = render_scene_ideation_compact_markdown(idea)
output = tmp_path / "compact-ideas.md"
call_command(
"fiction_ideas",
"export",
"--id",
str(idea.id),
"--output",
str(output),
"--compact",
)
assert output.read_text(encoding="utf-8") == rendered
assert "### Brief" in rendered
assert "### Opportunity Spent" in rendered
assert "### Future Opportunities Created" in rendered
assert "### Evaluation:" in rendered
assert "### Feedback:" in rendered
assert "### Purpose" not in rendered
assert "### Constraints" not in rendered
assert "## Frozen Citation Index" not in rendered
assert set(candidates[0]) == {
"candidate_id",
"title",
"brief",
"scene_type",
"citations",
"opportunity",
"future_opportunities",
}
def test_ideation_requires_target_book(tmp_path: Path) -> None:
work = idea_work(tmp_path)
service = SceneIdeationService(ModelRouter({"sol": FakeIdeationProvider()}))
with pytest.raises(ValueError, match="target_book is required"):
service.propose(work=work, target_book="", candidate_count=2, model_hint="sol")
def test_ideation_allows_repeated_explicit_scene_type() -> None:
data = json.loads(FakeIdeationProvider().complete(None).content)
for candidate in data["candidates"]:
candidate["scene_type"] = "physical_escalation"
candidates = SceneIdeationService._normalize_candidates(
data,
candidate_count=2,
citation_ids={"SRC-01", "SRC-02"},
allowed_scene_types={"physical_escalation"},
)
assert [candidate["scene_type"] for candidate in candidates] == [
"physical_escalation",
"physical_escalation",
]

View file

@ -0,0 +1,83 @@
from __future__ import annotations
import json
from pathlib import Path
from django.test import Client
from django.urls import reverse
from control_plane.authoring.models import (
DocumentAuthority,
DocumentType,
Series,
StandaloneScene,
Work,
)
from control_plane.authoring.sources import register_source
def api_work() -> Work:
series = Series.objects.create(title="Labyrinth Hero", slug="labyrinth-hero")
return Work.objects.create(series=series, title="The Fortune Below", slug="the-fortune-below")
def test_scene_api_creates_lists_and_returns_optional_prose() -> None:
work = api_work()
response = Client().post(
reverse("standalone_scenes"),
data=json.dumps(
{
"series_slug": work.series.slug,
"work_slug": work.slug,
"title": "Office Visit",
"brief": "Sabine visits Corin during ordinary work.",
"target_words": 1200,
}
),
content_type="application/json",
)
assert response.status_code == 201
scene = StandaloneScene.objects.get(id=response.json()["id"])
scene.prose = "Draft prose."
scene.save()
listing = Client().get(reverse("standalone_scenes"))
detail = Client().get(reverse("standalone_scene_detail", args=[scene.id]))
detail_with_prose = Client().get(
reverse("standalone_scene_detail", args=[scene.id]), {"include_prose": "1"}
)
assert listing.status_code == 200
assert listing.json()["scenes"][0]["id"] == str(scene.id)
assert "prose" not in detail.json()
assert detail_with_prose.json()["prose"] == "Draft prose."
def test_scene_api_previews_cited_context_without_model_call(tmp_path: Path) -> None:
work = api_work()
root = tmp_path / "sources"
root.mkdir()
source = root / "canon.md"
source.write_text("Sabine visits Corin's office during ordinary work.", encoding="utf-8")
register_source(
work=work,
path=source,
root=root,
authority=DocumentAuthority.CANON,
document_type=DocumentType.CANON,
)
scene = StandaloneScene.objects.create(
work=work,
scene_key="office-visit",
title="Office Visit",
brief="Sabine visits Corin's office.",
)
response = Client().post(
reverse("standalone_scene_action", args=[scene.id]),
data=json.dumps({"action": "context", "authorities": ["canon"]}),
content_type="application/json",
)
assert response.status_code == 200
assert response.json()["citations"][0]["document_key"] == "canon.md"

View file

@ -0,0 +1,318 @@
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from control_plane.authoring.models import (
DocumentAuthority,
DocumentType,
SceneDraftStatus,
Series,
StoryProject,
Work,
WorkType,
)
from control_plane.authoring.scene_context import build_scene_context_pack
from control_plane.authoring.sources import register_source
from control_plane.authoring.standalone_scenes import StandaloneSceneService
from control_plane.projects.models import Project
from model_router.router import (
ModelCapability,
ModelChunk,
ModelRequestContract,
ModelResponseContract,
ModelRouter,
)
class FakeSceneProvider:
provider_name = "test"
def complete(self, request: ModelRequestContract) -> ModelResponseContract:
if str(request.purpose) == str(ModelCapability.STORY_PLANNING):
content = json.dumps(
{
"purpose": "Sabine gives Corin a precise answer.",
"pov_character": "Sabine",
"tense": "past",
"location": "Corin's office",
"time_context": "ordinary daytime work",
"present": ["Sabine", "Corin"],
"beats": [
{"text": "Sabine crosses the room.", "required": True},
{"text": "Corin waits for her answer.", "required": True},
{"text": "Sabine leaves on her own timing.", "required": True},
{"text": "Corin does not turn it into a negotiation.", "required": True},
{"text": "Ordinary work remains unfinished.", "required": True},
],
"exact_values": [],
"constraints": [],
"forbidden_events": [],
"ending_state": "Sabine has left the office.",
"final_image": "Corin remains beside the unfinished paperwork.",
"boundary_constraints": [],
"continuity_questions": [],
}
)
else:
requirement_ids = re.findall(r'"id":\s*"([^"]+)"', request.prompt)
content = json.dumps(
{
"passed": True,
"requirement_results": [
{
"requirement_id": requirement_id,
"status": "HIT",
"evidence_quote": "Sabine crossed the room.",
"details": "Satisfied in the scene.",
}
for requirement_id in requirement_ids
],
"findings": [],
"observed_state": {"scene_end": {"location": "office"}},
"proposed_changes": [],
}
)
return ModelResponseContract(model="test-model", content=content, metadata={})
def stream(self, _request: ModelRequestContract):
prose = "Sabine crossed the room. " + "She waited. " * 245
yield ModelChunk(prose + "[[END_OF_SCENE]]")
def health(self) -> str:
return "AVAILABLE"
def scene_work(tmp_path: Path) -> Work:
series = Series.objects.create(title="Labyrinth Hero", slug="labyrinth-hero")
work = Work.objects.create(series=series, title="The Fortune Below", slug="the-fortune-below")
project = Project.objects.create(name="The Fortune Below", project_type="STORY", goal="Write")
StoryProject.objects.create(
project=project,
work=work,
title=work.title,
series=series.title,
slug=work.slug,
artifact_root=str(tmp_path / "artifacts"),
)
return work
def add_canon_source(work: Work, tmp_path: Path) -> None:
root = tmp_path / "sources"
root.mkdir()
source = root / "relationship-canon.md"
source.write_text(
"# Relationship Canon\n\n"
"Sabine and Corin preserve explicit choice and ordinary work boundaries.\n",
encoding="utf-8",
)
register_source(
work=work,
path=source,
root=root,
authority=DocumentAuthority.CANON,
document_type=DocumentType.CANON,
)
def test_context_pack_only_uses_latest_allowed_authority(tmp_path: Path) -> None:
work = scene_work(tmp_path)
root = tmp_path / "sources"
root.mkdir()
source = root / "facts.md"
source.write_text("# Facts\n\nSabine works at the estate.\n", encoding="utf-8")
register_source(
work=work,
path=source,
root=root,
authority=DocumentAuthority.CANON,
document_type=DocumentType.CANON,
)
register_source(
work=work,
path=source,
root=root,
authority=DocumentAuthority.SUPERSEDED,
document_type=DocumentType.CANON,
)
pack, ranked = build_scene_context_pack(work=work, query="Sabine estate")
assert ranked == []
assert pack["citations"] == []
def test_context_pack_includes_series_reference_but_not_sibling_book(tmp_path: Path) -> None:
work = scene_work(tmp_path)
reference = Work.objects.create(
series=work.series,
title="Series Reference",
slug="series-reference",
work_type=WorkType.SERIES_REFERENCE,
)
sibling = Work.objects.create(
series=work.series,
title="Sibling Book",
slug="sibling-book",
)
root = tmp_path / "series-sources"
root.mkdir()
reference_path = root / "shared.md"
sibling_path = root / "sibling.md"
reference_path.write_text("Sabine follows the shared household rule.", encoding="utf-8")
sibling_path.write_text("Sabine ignores a sibling-only invention.", encoding="utf-8")
for source_work, path in [(reference, reference_path), (sibling, sibling_path)]:
register_source(
work=source_work,
path=path,
root=root,
authority=DocumentAuthority.CANON,
document_type=DocumentType.CANON,
)
pack, _ranked = build_scene_context_pack(work=work, query="Sabine shared sibling invention")
keys = {citation["document_key"] for citation in pack["citations"]}
assert "shared.md" in keys
assert "sibling.md" not in keys
def test_pinned_planning_sources_do_not_crowd_out_canon(tmp_path: Path) -> None:
work = scene_work(tmp_path)
root = tmp_path / "mixed-sources"
root.mkdir()
canon_path = root / "canon.md"
planning_path = root / "planning.md"
canon_path.write_text(
"\n\n".join(f"Sabine canon boundary {index}." for index in range(4)),
encoding="utf-8",
)
planning_path.write_text(
"\n\n".join(f"Sabine planning detail {index}." for index in range(10)),
encoding="utf-8",
)
register_source(
work=work,
path=canon_path,
root=root,
authority=DocumentAuthority.CANON,
document_type=DocumentType.CANON,
)
register_source(
work=work,
path=planning_path,
root=root,
authority=DocumentAuthority.PLANNING,
document_type=DocumentType.PLANNING,
)
pack, _ranked = build_scene_context_pack(
work=work,
query="Sabine boundary planning detail",
authorities=[DocumentAuthority.CANON, DocumentAuthority.PLANNING],
pinned_document_keys=["planning.md"],
limit=6,
)
authorities = {citation["authority"] for citation in pack["citations"]}
assert authorities == {DocumentAuthority.CANON, DocumentAuthority.PLANNING}
def test_governing_document_is_supplied_in_full_before_rag(tmp_path: Path) -> None:
work = scene_work(tmp_path)
root = tmp_path / "governing-sources"
root.mkdir()
governing_path = root / "rules.md"
canon_path = root / "canon.md"
governing_text = "# Rules\n\nFirst governing rule.\n\nFinal governing rule.\n"
governing_path.write_text(governing_text, encoding="utf-8")
canon_path.write_text("Sabine has an additional canon fact.", encoding="utf-8")
register_source(
work=work,
path=governing_path,
root=root,
authority=DocumentAuthority.PLANNING,
document_type=DocumentType.PLANNING,
)
register_source(
work=work,
path=canon_path,
root=root,
authority=DocumentAuthority.CANON,
document_type=DocumentType.CANON,
)
pack, _ranked = build_scene_context_pack(
work=work,
query="Sabine canon",
authorities=[DocumentAuthority.CANON, DocumentAuthority.PLANNING],
governing_document_keys=["rules.md"],
limit=2,
)
assert pack["governing_document_keys"] == ["rules.md"]
assert pack["citations"][0]["kind"] == "governing_document"
assert pack["citations"][0]["sha256"]
assert "# Rules" in pack["rendered_context"]
assert "First governing rule." in pack["rendered_context"]
assert "Final governing rule." in pack["rendered_context"]
assert "additional canon fact" in pack["rendered_context"]
def test_standalone_scene_runs_from_cited_plan_to_provisional_approval(tmp_path: Path) -> None:
work = scene_work(tmp_path)
add_canon_source(work, tmp_path)
provider = FakeSceneProvider()
service = StandaloneSceneService(ModelRouter({"terra": provider, "luna": provider}))
scene = service.create(
work=work,
title="Fourteen Seconds",
brief="Sabine interrupts Corin's ordinary work, gives him a deliberate answer, and leaves.",
target_words=300,
constraints=["Sabine owns the timing."],
forbidden_events=["Do not turn this into a scored turn."],
boundary_constraints=["Stop when Sabine leaves the office."],
)
service.prepare_context(scene)
assert scene.status == SceneDraftStatus.PLANNING
assert scene.context_citations.count() == 1
service.plan(scene)
assert scene.status == SceneDraftStatus.PLAN_REVIEW
assert scene.context_citations.count() == 1
assert scene.context_pack["citations"][0]["authority"] == DocumentAuthority.CANON
assert scene.contract_requirements
blocking_beats = [
item
for item in scene.contract_requirements
if item["type"] == "BEAT" and item["blocking"]
]
assert len(blocking_beats) == 5
service.approve_plan(scene)
service.write(scene)
scene.prose = scene.prose.removeprefix("# Fourteen Seconds\n\n")
scene.save()
service.review(scene)
service.approve(scene, actor="test")
scene.refresh_from_db()
assert scene.status == SceneDraftStatus.APPROVED
assert scene.prose.startswith("# Fourteen Seconds\n\n")
assert scene.word_count >= 250
assert scene.review["passed"] is True
assert scene.source_version.authority == DocumentAuthority.PROVISIONAL
assert len(scene.generation_metadata["planning"]["prompt_sha256"]) == 64
assert len(scene.generation_metadata["prose"]["prompt_sha256"]) == 64
assert scene.generation_metadata["approval"]["forced"] is False
assert Path(scene.artifact_uri).exists()
assert Path(scene.review_artifact_uri).exists()
scene.prose += " Changed after approval."
with pytest.raises(ValueError, match="immutable"):
scene.save()

View file

@ -0,0 +1,835 @@
from __future__ import annotations
import json
import zipfile
from contextlib import contextmanager
from pathlib import Path
import pytest
from django.test import Client
from django.urls import reverse
from control_plane.authoring.epub import write_epub
from control_plane.authoring.models import (
CanonSnapshot,
Chapter,
ChapterRevision,
ChapterStateDocument,
EditorialFinding,
GenerationContextSnapshot,
OutlineVersion,
StateChange,
StateChangeStatus,
StoryBibleVersion,
StoryEntity,
StoryProject,
)
from control_plane.authoring.runner import StoryWorkflowRunner
from control_plane.authoring.services import (
DjangoStoryWorkflowServices,
apply_exact_edits,
compact_chapter_plan,
compact_scene_contract,
deterministic_temporal_findings,
scene_draft_packet,
)
from control_plane.authoring.state_management import (
apply_state_changes,
build_contract_requirements,
evidence_is_present,
)
from control_plane.authoring.streaming import DraftResult, ResumableDraftWriter
from control_plane.authoring.workflow import build_story_workflow
from control_plane.projects.models import Project
from graph.models import GraphApproval, GraphApprovalStatus, GraphRunStatus
from model_router.router import (
ModelChunk,
ModelRequestContract,
ModelResponseContract,
ModelRouter,
)
def story_revision() -> ChapterRevision:
project = Project.objects.create(name="Story Test", project_type="STORY", goal="Write")
story = StoryProject.objects.create(project=project, title="Test Book", slug="test-book")
bible = StoryBibleVersion.objects.create(story=story, version=1, content="Canon")
outline = OutlineVersion.objects.create(
story=story,
version=1,
content={"chapters": [{"number": 1, "title": "Opening", "beats": ["Begin"]}]},
)
chapter = Chapter.objects.create(story=story, number=1, title="Opening")
return ChapterRevision.objects.create(
chapter=chapter, revision=1, story_bible=bible, outline=outline
)
class FakeStoryServices:
def __init__(self) -> None:
self.review_calls: list[str] = []
self.audit_calls = 0
self.plan_calls = 0
def build_context(self, state):
return {"context_snapshot_id": "context-1"}
def plan_chapter(self, state):
self.plan_calls += 1
return {"scene_plan": {"scenes": [{"number": 1}]}, "human_notes": ""}
def draft_chapter(self, state):
return {"revision_id": state["revision_id"]}
def extract_continuity(self, state):
return {}
def judge_state_contract(self, state):
self.audit_calls += 1
return {"state_judge_status": "pass", "editorial_finding_ids": []}
def decide_patch(self, state):
return {"patch_decision": "human_review", "patch_finding_ids": []}
def apply_automatic_patch(self, state):
raise AssertionError("patch should not run")
def verify_patch(self, state):
raise AssertionError("verification should not run")
def state_approval_payload(self, state):
return {}
def review_chapter(self, state, review_kind):
self.review_calls.append(review_kind)
return [f"finding-{review_kind}"]
def judge_chapter(self, state):
return "human_review"
def revise_chapter(self, state):
return {
"revision_id": state["revision_id"],
"revision_attempt": int(state.get("revision_attempt") or 0) + 1,
}
def ensure_approval(self, state, gate, payload):
approval, _ = GraphApproval.objects.get_or_create(
graph_run_id=state["graph_run_id"],
reason=f"{gate}:{state['revision_id']}",
status=GraphApprovalStatus.PENDING,
defaults={"payload": payload},
)
return approval
def decide_approval(self, approval_id, decision):
approval = GraphApproval.objects.get(id=approval_id)
approval.status = (
GraphApprovalStatus.APPROVED
if decision["action"] == "approve"
else GraphApprovalStatus.REJECTED
)
approval.save(update_fields=["status", "updated_at"])
def commit_chapter(self, state):
return {"canon_snapshot_id": "canon-1"}
def publish_story(self, state):
return "test.epub"
def test_story_graph_runs_one_consolidated_review() -> None:
from langgraph.checkpoint.memory import MemorySaver
revision = story_revision()
services = FakeStoryServices()
runner = StoryWorkflowRunner(build_story_workflow(services, MemorySaver()))
graph_run = runner.start(revision)
assert graph_run.status == GraphRunStatus.PAUSED
assert graph_run.current_node == "approve_plan"
assert GraphApproval.objects.filter(status=GraphApprovalStatus.PENDING).count() == 1
graph_run = runner.resume(graph_run.id, {"action": "approve", "actor": "test"})
assert graph_run.status == GraphRunStatus.PAUSED
assert graph_run.current_node == "approve_chapter"
assert services.audit_calls == 1
assert services.review_calls == []
graph_run = runner.resume(graph_run.id, {"action": "approve", "actor": "test"})
assert graph_run.status == GraphRunStatus.COMPLETE
assert graph_run.metadata["final_state"]["export_uri"] == "test.epub"
def test_story_graph_allows_one_patch_then_one_final_extraction() -> None:
from langgraph.checkpoint.memory import MemorySaver
class PatchServices(FakeStoryServices):
def __init__(self) -> None:
super().__init__()
self.extraction_calls = 0
self.patch_calls = 0
self.verification_calls = 0
def extract_continuity(self, state):
self.extraction_calls += 1
return {}
def judge_state_contract(self, state):
self.audit_calls += 1
return {"state_judge_status": "revise", "editorial_finding_ids": ["finding-1"]}
def decide_patch(self, state):
return {"patch_decision": "patch", "patch_finding_ids": ["finding-1"]}
def apply_automatic_patch(self, state):
self.patch_calls += 1
return {
"revision_id": state["revision_id"],
"patch_attempted": True,
"patch_status": "applied",
"patch_source_revision_id": state["revision_id"],
"changed_passages": [{"old_text": "old", "new_text": "new"}],
}
def verify_patch(self, state):
self.verification_calls += 1
return {"verification_status": "pass"}
revision = story_revision()
services = PatchServices()
runner = StoryWorkflowRunner(build_story_workflow(services, MemorySaver()))
graph_run = runner.start(revision)
graph_run = runner.resume(graph_run.id, {"action": "approve", "actor": "test"})
assert graph_run.current_node == "approve_chapter"
assert services.audit_calls == 1
assert services.extraction_calls == 1
assert services.patch_calls == 1
assert services.verification_calls == 1
graph_run = runner.resume(
graph_run.id, {"action": "request_revision", "actor": "test"}
)
assert graph_run.status == GraphRunStatus.COMPLETE
assert services.patch_calls == 1
assert services.verification_calls == 1
def test_patch_selection_excludes_subjective_low_and_optional_contract_findings() -> None:
from control_plane.authoring.services import DjangoStoryWorkflowServices
revision = story_revision()
EditorialFinding.objects.create(
revision=revision,
review_kind="story_audit",
severity="LOW",
category="style",
description="Optional preference",
evidence={"objective": True, "exact_patch_suitable": True},
)
EditorialFinding.objects.create(
revision=revision,
review_kind="state_contract",
severity="MEDIUM",
category="contract:BEAT",
description="Optional beat detail",
evidence={
"objective": True,
"exact_patch_suitable": True,
"blocking": False,
},
)
objective = EditorialFinding.objects.create(
revision=revision,
review_kind="story_audit",
severity="MEDIUM",
category="continuity",
description="Concrete contradiction",
evidence={"objective": True, "exact_patch_suitable": True},
)
services = DjangoStoryWorkflowServices(ModelRouter({}))
result = services.decide_patch({"revision_id": str(revision.id)})
assert result["patch_decision"] == "patch"
assert result["patch_finding_ids"] == [str(objective.id)]
def test_approval_inbox_resumes_story_checkpoint(monkeypatch) -> None:
from langgraph.checkpoint.memory import MemorySaver
from control_plane.projects import views
revision = story_revision()
services = FakeStoryServices()
checkpointer = MemorySaver()
graph_run = StoryWorkflowRunner(build_story_workflow(services, checkpointer)).start(revision)
approval = GraphApproval.objects.get(graph_run=graph_run, status=GraphApprovalStatus.PENDING)
assert graph_run.execution_graph_version.graph.name == "story_authoring"
@contextmanager
def checkpointer_context():
yield checkpointer
monkeypatch.setattr(views, "open_story_checkpointer", checkpointer_context)
monkeypatch.setattr(views, "DjangoStoryWorkflowServices", lambda *_args, **_kwargs: services)
response = Client().post(
reverse("approval_action", args=[approval.id]),
{"action": "approve", "notes": "Keep the opening quiet."},
)
graph_run.refresh_from_db()
approval.refresh_from_db()
assert response.status_code == 302
assert approval.status == GraphApprovalStatus.APPROVED, list(
GraphApproval.objects.filter(graph_run=graph_run).values_list("reason", "status")
)
assert graph_run.status == GraphRunStatus.PAUSED
assert graph_run.current_node == "approve_chapter"
class InterruptedStreamingProvider:
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
def stream(self, request):
self.calls += 1
if self.calls == 1:
yield ModelChunk("one two three ")
raise RuntimeError("connection lost")
yield ModelChunk("four five [[END_OF_CHAPTER]]")
def health(self):
return "AVAILABLE"
def test_streamed_story_draft_resumes_preserved_fragment(tmp_path: Path) -> None:
provider = InterruptedStreamingProvider()
writer = ResumableDraftWriter(ModelRouter({"terra": provider}))
result = writer.generate(
request=ModelRequestContract(
purpose="STORY_PROSE", prompt="write", model_hint="terra"
),
partial_path=tmp_path / "chapter.partial.md",
minimum_words=5,
max_attempts=2,
)
assert result.text == "one two three four five"
assert result.resumed is False
assert provider.calls == 2
assert not (tmp_path / "chapter.partial.md.attempt").exists()
def test_short_completed_draft_restarts_without_duplication(tmp_path: Path) -> None:
class ShortThenCompleteProvider:
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
def stream(self, request):
self.calls += 1
if self.calls == 1:
yield ModelChunk("too short [[END_OF_CHAPTER]]")
else:
yield ModelChunk("one two three four five [[END_OF_CHAPTER]]")
def health(self):
return "AVAILABLE"
provider = ShortThenCompleteProvider()
writer = ResumableDraftWriter(ModelRouter({"qwen": provider}))
result = writer.generate(
request=ModelRequestContract(
purpose="STORY_PROSE", prompt="write", model_hint="qwen"
),
partial_path=tmp_path / "chapter.partial.md",
minimum_words=5,
max_attempts=2,
)
assert result.text == "one two three four five"
assert provider.calls == 2
def test_short_completed_draft_stops_after_one_regeneration(tmp_path: Path) -> None:
class AlwaysShortProvider:
provider_name = "test"
def __init__(self) -> None:
self.calls = 0
def stream(self, request):
self.calls += 1
yield ModelChunk("too short [[END_OF_CHAPTER]]")
def health(self):
return "AVAILABLE"
provider = AlwaysShortProvider()
writer = ResumableDraftWriter(ModelRouter({"qwen": provider}))
with pytest.raises(RuntimeError, match="shorter than 5 words"):
writer.generate(
request=ModelRequestContract(
purpose="STORY_PROSE", prompt="write", model_hint="qwen"
),
partial_path=tmp_path / "chapter.partial.md",
minimum_words=5,
max_attempts=4,
)
assert provider.calls == 2
def test_draft_chapter_generates_full_chapter_in_one_call(tmp_path: Path) -> None:
revision = story_revision()
revision.chapter.story.artifact_root = str(tmp_path)
revision.chapter.story.save(update_fields=["artifact_root", "updated_at"])
revision.scene_plan = {
"target_words": 2000,
"scenes": [
{"number": 1, "word_budget": 1000, "beats": ["first"]},
{"number": 2, "word_budget": 1000, "beats": ["second"]},
],
}
revision.save(update_fields=["scene_plan", "updated_at"])
services = DjangoStoryWorkflowServices(ModelRouter({}))
services._context = lambda state, current: {
"chapter": {},
"structured_canon": {},
"prior_canon": {},
"previous_chapter_tail": "",
}
class ChapterWriter:
def __init__(self) -> None:
self.requests = []
def generate(self, *, request, **kwargs):
self.requests.append(request)
assert kwargs["max_attempts"] == 1
return DraftResult("Complete chapter prose.", 1, False, 3)
writer = ChapterWriter()
services.writer = writer
services.draft_chapter({"revision_id": str(revision.id)})
revision.refresh_from_db()
assert revision.prose == "Complete chapter prose."
assert len(writer.requests) == 1
assert writer.requests[0].model_hint == "terra"
assert revision.generation_metadata["draft_mode"] == "full_chapter"
def test_compact_scene_contract_consolidates_all_required_beats_into_three() -> None:
scene = {
"number": 1,
"purpose": "Test",
"beats": [{"text": f"beat {number}", "required": True} for number in range(1, 13)],
}
compact = compact_scene_contract(scene)
assert len(compact["beats"]) == 3
assert [beat["source_beat_count"] for beat in compact["beats"]] == [4, 4, 4]
combined = " ".join(beat["text"] for beat in compact["beats"])
assert all(f"beat {number}" in combined for number in range(1, 13))
def test_scene_draft_packet_excludes_other_scenes() -> None:
selected = {"number": 1, "beats": [{"text": "selected", "required": True}]}
packet = scene_draft_packet(
{
"target_words": 2000,
"exact_values": ["exact"],
"scenes": [selected, {"number": 2, "beats": [{"text": "unrelated"}]}],
},
selected,
)
assert packet["target_words"] == 1000
assert packet["scene"]["beats"] == [{"text": "selected", "required": True}]
assert "scenes" not in packet["chapter_scope"]
def test_compact_chapter_plan_preserves_all_required_beats() -> None:
plan = {
"target_words": 5000,
"scenes": [
{
"number": number,
"beats": [
{"text": f"scene {number} beat {beat}", "required": True}
for beat in range(1, 7)
],
}
for number in range(1, 3)
],
}
compact = compact_chapter_plan(plan)
assert [len(scene["beats"]) for scene in compact["scenes"]] == [3, 3]
combined = json.dumps(compact)
assert all(
f"scene {scene} beat {beat}" in combined
for scene in range(1, 3)
for beat in range(1, 7)
)
def test_deterministic_temporal_findings_catch_premature_wealth() -> None:
prose = (
"The dungeon had not noticed that he had become wealthy.\n\n"
"The bids were opened one at a time."
)
findings = deterministic_temporal_findings(prose)
assert len(findings) == 1
assert findings[0]["evidence_quote"] == "The dungeon had not noticed that he had become wealthy."
assert findings[0]["suggested_revision"] == "The dungeon had not noticed that he might soon become wealthy."
def test_deterministic_temporal_findings_catch_exact_payout_before_bidding() -> None:
prose = (
"Three and a half million crowns would buy better weapons.\n\n"
"The bids were opened one at a time."
)
plan = {
"exact_values": [
"Corin's five-sixths finder share is exactly 3,500,000 silver crowns."
]
}
findings = deterministic_temporal_findings(prose, plan)
assert len(findings) == 1
assert findings[0]["category"] == "premature_exact_value"
assert findings[0]["suggested_revision"] == "The sale proceeds would buy better weapons."
def test_epub_contains_approved_chapter_entries(tmp_path: Path) -> None:
destination = write_epub(
title="Test Book",
series="Test Series",
chapters=[{"title": "Chapter 1: Opening", "content": "First paragraph.\n\nSecond."}],
destination=tmp_path / "book.epub",
)
with zipfile.ZipFile(destination) as archive:
content = archive.read("OEBPS/chapter-1.xhtml").decode("utf-8")
assert "Chapter 1: Opening" in content
assert "First paragraph." in content
def test_story_versions_hash_content() -> None:
revision = story_revision()
assert len(revision.story_bible.sha256) == 64
assert len(revision.outline.sha256) == 64
def test_exact_patch_preserves_unaffected_prose() -> None:
prose = "First paragraph.\n\nThe water remained.\n\nLast paragraph."
revised = apply_exact_edits(
prose,
[{"old_text": "The water remained.", "new_text": "The water ran thinner than before."}],
)
assert revised == "First paragraph.\n\nThe water ran thinner than before.\n\nLast paragraph."
def test_exact_patch_rejects_ambiguous_source_text() -> None:
import pytest
with pytest.raises(ValueError, match="exactly once"):
apply_exact_edits(
"Repeated. Repeated.",
[{"old_text": "Repeated.", "new_text": "Changed."}],
)
def test_contract_requirements_receive_stable_ids() -> None:
requirements = build_contract_requirements(
{
"day_start": "Morning",
"day_end": "Evening",
"scenes": [
{
"number": 2,
"beats": ["First beat", "Second beat"],
"ending_state": "The door is closed.",
}
],
"exact_values": ["Ten crowns"],
"forbidden_shortcuts": ["Do not montage the sale."],
}
)
assert [item["id"] for item in requirements] == [
"S02-B01",
"S02-B02",
"S02-END",
"VALUE-01",
"SHORTCUT-01",
"TIME-START",
"TIME-END",
]
assert requirements[0]["blocking"] is False
assert requirements[3]["blocking"] is True
assert requirements[4]["blocking"] is False
def test_contract_marks_only_explicit_required_beats_as_blocking() -> None:
requirements = build_contract_requirements(
{
"scenes": [
{
"number": 1,
"beats": [
{"text": "The sale settles.", "required": True},
{"text": "Rain taps the window.", "required": False},
],
}
]
}
)
assert requirements[0]["blocking"] is True
assert requirements[1]["blocking"] is False
def test_contract_caps_required_beats_per_scene() -> None:
requirements = build_contract_requirements(
{
"scenes": [
{
"number": 1,
"beats": [
{"text": f"Beat {index}", "required": True}
for index in range(7)
],
}
]
}
)
assert sum(1 for item in requirements if item["blocking"]) == 3
def test_exact_patch_rejects_overlapping_and_over_budget_edits() -> None:
import pytest
with pytest.raises(ValueError, match="overlap"):
apply_exact_edits(
"abcdefghij",
[
{"old_text": "abcde", "new_text": "ABCDE"},
{"old_text": "defgh", "new_text": "DEFGH"},
],
)
with pytest.raises(ValueError, match="limit"):
apply_exact_edits(
"a" * 100 + " target " + "b" * 100,
[{"old_text": " target ", "new_text": " a much longer replacement passage "}],
max_change_ratio=0.05,
)
def test_state_changes_build_queryable_book_snapshot() -> None:
state = apply_state_changes(
{},
[
{
"sequence": 1,
"entity_key": "character.corin.vale",
"entity_kind": "character",
"canonical_name": "Corin Vale",
"predicate": "finances.balance",
"operation": "SET",
"previous_value": None,
"new_value": 10,
}
],
through_chapter=2,
chapter_state={"summary": "Corin receives ten crowns."},
)
assert state["through_chapter"] == 2
assert state["entities"]["character.corin.vale"]["facts"]["finances"]["balance"] == 10
def test_transfer_state_change_records_explicit_custody_destination() -> None:
state = apply_state_changes(
{},
[
{
"sequence": 1,
"entity_key": "object.envelope.after",
"entity_kind": "object",
"canonical_name": "Envelope After",
"predicate": "custody.holder",
"operation": "TRANSFER",
"previous_value": None,
"new_value": None,
"related_entity_key": "character.sabine",
}
],
through_chapter=2,
chapter_state={},
)
envelope = state["entities"]["object.envelope.after"]
assert envelope["facts"]["custody"]["holder"] == "character.sabine"
assert envelope["relations"]["custody.holder"] == "character.sabine"
def test_transfer_state_change_requires_destination_entity() -> None:
with pytest.raises(ValueError, match="related_entity_key"):
apply_state_changes(
{},
[
{
"sequence": 1,
"entity_key": "object.envelope.after",
"predicate": "custody.holder",
"operation": "TRANSFER",
"previous_value": None,
"new_value": None,
}
],
through_chapter=2,
chapter_state={},
)
class StateWorkflowProvider:
provider_name = "test"
def complete(self, request):
if str(request.purpose) == "STORY_CONTINUITY":
content = """{
"schema_version": 2,
"through_chapter": 1,
"state_document": {
"timeline": {"relative": "Morning"},
"scene_end": {"location": "Market"},
"characters": {"Corin Vale": {"location": "Market"}},
"inventory": [],
"money": [{"owner": "Corin Vale", "balance": 10}],
"relationships": [],
"open_threads": [],
"promises_and_constraints": [],
"reveals": {},
"chapter_summary": ["Corin received ten crowns."]
},
"changes": [{
"entity_key": "character.corin.vale",
"entity_kind": "character",
"canonical_name": "Corin Vale",
"change_type": "MONEY_CHANGED",
"predicate": "finances.balance",
"operation": "SET",
"previous_value": null,
"new_value": 10,
"related_entity_key": "",
"evidence_quote": "Corin received ten crowns.",
"evidence_location": "Scene 1"
}]
}"""
else:
content = """{
"verdict": "PASS",
"requirements": [
{"requirement_id":"S01-B01","status":"HIT","evidence_quote":"Corin received ten crowns.","evidence_location":"Scene 1","details":""},
{"requirement_id":"S01-END","status":"HIT","evidence_quote":"Corin received ten crowns.","evidence_location":"Scene 1","details":""}
]
}"""
return ModelResponseContract(model="test", content=content, metadata={})
def test_validated_state_document_commits_entity_history(tmp_path: Path) -> None:
from control_plane.authoring.services import DjangoStoryWorkflowServices
revision = story_revision()
story = revision.chapter.story
story.artifact_root = str(tmp_path)
story.save(update_fields=["artifact_root", "updated_at"])
revision.scene_plan = {
"scenes": [
{
"number": 1,
"beats": ["Corin receives ten crowns."],
"ending_state": "Corin has ten crowns.",
}
]
}
revision.prose = "Corin received ten crowns."
revision.save()
context = {
"chapter": {"number": 1, "title": "Opening", "outline": {}},
"story_bible": "Canon",
"structured_canon": {},
"prior_canon": {},
"previous_chapter_tail": "",
"source_revision": "",
}
snapshot = GenerationContextSnapshot.objects.create(
story=story,
chapter=revision.chapter,
story_bible=revision.story_bible,
outline=revision.outline,
content=context,
sha256="0" * 64,
)
revision.context_snapshot = snapshot
revision.save(update_fields=["context_snapshot", "updated_at"])
provider = StateWorkflowProvider()
services = DjangoStoryWorkflowServices(
ModelRouter({"qwen": provider, "terra": provider, "luna": provider})
)
state = {
"revision_id": str(revision.id),
"story_id": str(story.id),
"context_snapshot_id": str(snapshot.id),
}
services.extract_continuity(state)
result = services.judge_state_contract(state)
commit = services.commit_chapter(state)
document = ChapterStateDocument.objects.get(revision=revision)
change = StateChange.objects.get(state_document=document)
canon = CanonSnapshot.objects.get(id=commit["canon_snapshot_id"])
assert result["state_judge_status"] == "pass"
assert document.status == "COMMITTED"
assert StoryEntity.objects.get(entity_key="character.corin.vale").canonical_name == "Corin Vale"
assert change.status == StateChangeStatus.COMMITTED
assert canon.state["entities"]["character.corin.vale"]["facts"]["finances"]["balance"] == 10
assert Path(document.json_artifact_uri).exists()
assert Path(document.markdown_artifact_uri).exists()
def test_evidence_accepts_ordered_dialogue_fragments_with_attribution() -> None:
prose = '"Replace those clothes," she said. "Eat somewhere respectable. Make no promises tonight."'
assert evidence_is_present(
prose,
"Replace those clothes. Eat somewhere respectable. Make no promises tonight.",
)

122
tests/test_story_sources.py Normal file
View file

@ -0,0 +1,122 @@
from __future__ import annotations
import hashlib
from pathlib import Path
import pytest
from django.core.management import call_command
from control_plane.authoring.models import (
DocumentAuthority,
DocumentType,
Series,
SourceDocument,
Work,
)
from control_plane.authoring.sources import discover_source_paths, passage_spans, register_source
def source_work() -> Work:
series = Series.objects.create(title="Labyrinth Hero", slug="labyrinth-hero")
return Work.objects.create(series=series, title="The Fortune Below", slug="the-fortune-below")
def test_passage_spans_preserve_exact_source_locations() -> None:
content = "# Heading\n\nFirst line.\nSecond line.\n\nLast.\n"
passages = passage_spans(content)
assert [(item["start_line"], item["end_line"]) for item in passages] == [(1, 1), (3, 4), (6, 6)]
assert all(
content[item["start_char"] : item["end_char"]] == item["content"]
for item in passages
)
def test_source_discovery_supports_explicit_classification_globs(tmp_path: Path) -> None:
canon = tmp_path / "canon"
planning = tmp_path / "planning"
canon.mkdir()
planning.mkdir()
(canon / "facts.md").write_text("Canon", encoding="utf-8")
(planning / "notes.md").write_text("Planning", encoding="utf-8")
discovered = discover_source_paths(tmp_path, ["canon/*.md"])
assert [path.name for path in discovered] == ["facts.md"]
def test_register_source_is_idempotent_and_versions_changed_content(tmp_path: Path) -> None:
work = source_work()
root = tmp_path / "corpus"
root.mkdir()
path = root / "scene.md"
path.write_text("# Scene\n\nFirst version.\n", encoding="utf-8")
first = register_source(
work=work,
path=path,
root=root,
authority=DocumentAuthority.PROVISIONAL,
document_type=DocumentType.SCENE,
)
unchanged = register_source(
work=work,
path=path,
root=root,
authority=DocumentAuthority.PROVISIONAL,
document_type=DocumentType.SCENE,
)
reclassified = register_source(
work=work,
path=path,
root=root,
authority=DocumentAuthority.CANON,
document_type=DocumentType.SCENE,
)
path.write_text("# Scene\n\nSecond version.\n", encoding="utf-8")
second = register_source(
work=work,
path=path,
root=root,
authority=DocumentAuthority.CANON,
document_type=DocumentType.SCENE,
)
document = SourceDocument.objects.get(work=work, logical_key="scene.md")
versions = list(document.versions.order_by("version"))
assert first.status == "created"
assert unchanged.status == "unchanged"
assert second.status == "versioned"
assert reclassified.status == "versioned"
assert len(versions) == 3
assert versions[1].supersedes == versions[0]
assert versions[1].source_sha256 == versions[0].source_sha256
assert versions[1].authority == DocumentAuthority.CANON
assert versions[2].supersedes == versions[1]
assert versions[2].passages.count() == 2
assert versions[2].source_sha256 == hashlib.sha256(path.read_bytes()).hexdigest()
versions[2].authority = DocumentAuthority.REJECTED
with pytest.raises(ValueError, match="immutable"):
versions[2].save()
def test_story_sources_dry_run_does_not_create_database_records(tmp_path: Path) -> None:
path = tmp_path / "canon.md"
path.write_text("# Canon\n\nA fact.\n", encoding="utf-8")
call_command(
"story_sources",
"register",
root=tmp_path,
series_slug="labyrinth-hero",
series_title="Labyrinth Hero",
work_slug="the-fortune-below",
work_title="The Fortune Below",
authority=DocumentAuthority.PROVISIONAL,
dry_run=True,
)
assert not Series.objects.exists()
assert not SourceDocument.objects.exists()

29
uv.lock generated
View file

@ -31,6 +31,7 @@ source = { virtual = "." }
dependencies = [
{ name = "django" },
{ name = "langgraph" },
{ name = "langgraph-checkpoint-postgres" },
{ name = "numba" },
{ name = "numpy" },
{ name = "psycopg", extra = ["binary"] },
@ -49,6 +50,7 @@ dev = [
requires-dist = [
{ name = "django", specifier = ">=5.1,<6.0" },
{ name = "langgraph", specifier = ">=0.2,<0.3" },
{ name = "langgraph-checkpoint-postgres", specifier = ">=2.0,<3.0" },
{ name = "numba", specifier = ">=0.60,<1.0" },
{ name = "numpy", specifier = ">=2.0,<3.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4.0" },
@ -363,6 +365,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/f2/06bf5addf8ee664291e1b9ffa1f28fc9d97e59806dc7de5aea9844cbf335/langgraph_checkpoint-2.1.2-py3-none-any.whl", hash = "sha256:911ebffb069fd01775d4b5184c04aaafc2962fcdf50cf49d524cd4367c4d0c60", size = 45763, upload-time = "2025-10-07T17:45:16.19Z" },
]
[[package]]
name = "langgraph-checkpoint-postgres"
version = "2.0.25"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langgraph-checkpoint" },
{ name = "orjson" },
{ name = "psycopg" },
{ name = "psycopg-pool" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/6a/e2c5163b274c80bf7afe48a766b788d922d5a0685b6a6cf65a4e1f0b6ba1/langgraph_checkpoint_postgres-2.0.25.tar.gz", hash = "sha256:916b80f73a641a589301f6c54414974768b6d646d82db7b301ff8d47105c3613", size = 118843, upload-time = "2025-10-07T18:44:55.116Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/43/f406097fe110f637282d583f2d1b490c107f6a4c661977bc59aed44f2baa/langgraph_checkpoint_postgres-2.0.25-py3-none-any.whl", hash = "sha256:cf1248a58fe828c9cfc36ee57ff118d7799ce214d4b35718e57ec98407130fb5", size = 40944, upload-time = "2025-10-07T18:44:54.25Z" },
]
[[package]]
name = "langgraph-sdk"
version = "0.1.74"
@ -695,6 +712,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
]
[[package]]
name = "psycopg-pool"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" },
]
[[package]]
name = "pyarrow"
version = "25.0.1"