diff --git a/README.md b/README.md index 30fa54b..9076463 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/artifex/settings.py b/artifex/settings.py index de60383..225cf21 100644 --- a/artifex/settings.py +++ b/artifex/settings.py @@ -27,6 +27,7 @@ INSTALLED_APPS = [ "control_plane.secrets", "control_plane.knowledge", "control_plane.verification", + "control_plane.authoring", "graph", ] diff --git a/artifex/urls.py b/artifex/urls.py index f0624a5..6cb2093 100644 --- a/artifex/urls.py +++ b/artifex/urls.py @@ -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//", trading_studio_views.trading_studio_project, name="trading_studio_project"), path("approvals/", views.approvals, name="approvals"), path("approvals//action/", views.approval_action, name="approval_action"), + path("api/authoring/book-states/", authoring_views.book_states, name="book_states"), + path( + "api/authoring/book-states//", + authoring_views.book_state_detail, + name="book_state_detail", + ), + path( + "api/authoring/book-states//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//", + authoring_views.scene_idea_detail, + name="scene_idea_detail", + ), + path( + "api/authoring/ideas//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//", + authoring_views.standalone_scene_detail, + name="standalone_scene_detail", + ), + path( + "api/authoring/scenes//actions/", + authoring_views.standalone_scene_action, + name="standalone_scene_action", + ), path("activity/", views.activity, name="activity"), path("admin/", admin.site.urls), ] diff --git a/control_plane/authoring/__init__.py b/control_plane/authoring/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/control_plane/authoring/admin.py b/control_plane/authoring/admin.py new file mode 100644 index 0000000..b413c2a --- /dev/null +++ b/control_plane/authoring/admin.py @@ -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 diff --git a/control_plane/authoring/apps.py b/control_plane/authoring/apps.py new file mode 100644 index 0000000..869872f --- /dev/null +++ b/control_plane/authoring/apps.py @@ -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" diff --git a/control_plane/authoring/book_state.py b/control_plane/authoring/book_state.py new file mode 100644 index 0000000..1df937b --- /dev/null +++ b/control_plane/authoring/book_state.py @@ -0,0 +1,1265 @@ +from __future__ import annotations + +import hashlib +import json +import re +from copy import deepcopy +from pathlib import Path +from typing import Any + +from django.conf import settings +from django.db import transaction +from django.db.models import Max +from django.utils import timezone + +from control_plane.authoring.models import ( + BookRun, + BookRunStatus, + BookStateStatus, + BookStateVersion, + SceneDraftStatus, + StandaloneScene, + Work, +) +from control_plane.authoring.prompts import ( + BOOK_CONTINUITY_REVIEW_SYSTEM, + BOOK_CONTINUITY_REVIEW_TEMPLATE, + BOOK_STRUCTURE_REVIEW_SYSTEM, + BOOK_STRUCTURE_REVIEW_TEMPLATE, +) +from control_plane.authoring.standalone_scenes import render_authoring_prompt +from control_plane.authoring.streaming import atomic_write_text +from model_router.providers import extract_json_object +from model_router.router import ModelCapability, ModelRequestContract, ModelRouter + +CHAPTER_KEY_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +CONTINUITY_CATEGORIES = { + "character", + "reveal", + "object", + "injury", + "route", + "promise", + "relationship", + "location", + "money", + "other", +} +REVIEW_CATEGORIES = { + "structure", + "continuity", + "chronology", + "character", + "plot", + "pacing", + "contract", + "canon", + "logic", + "relationship", + "location", + "object", + "injury", + "route", + "promise", + "money", + "other", +} +ROOT_KEYS = { + "schema_version", + "title", + "premise", + "target_words", + "constraints", + "forbidden_events", + "acts", + "arcs", + "threads", + "continuity", + "chapters", + "ending", + "open_questions", +} + + +def _canonical(value: Any, *, pretty: bool = False) -> str: + options: dict[str, Any] = {"ensure_ascii": False, "sort_keys": True} + if pretty: + options["indent"] = 2 + else: + options["separators"] = (",", ":") + return json.dumps(value, **options) + + +def _sha256(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _text(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _integer(value: Any) -> int | None: + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def _range_value(item: dict[str, Any], side: str) -> Any: + return item.get(f"{side}_chapter", item.get(f"chapter_{side}")) + + +def validate_book_state_content( + content: Any, *, work: Work, for_approval: bool = False +) -> dict[str, Any]: + """Validate schema v1 without changing or normalizing the supplied document.""" + errors: list[str] = [] + warnings: list[str] = [] + + def error(path: str, message: str) -> None: + errors.append(f"{path}: {message}") + + def warning(path: str, message: str) -> None: + warnings.append(f"{path}: {message}") + + if not isinstance(content, dict): + return { + "schema_version": 1, + "valid": False, + "errors": ["$: must be an object"], + "warnings": [], + } + + unknown = sorted(set(content) - ROOT_KEYS) + for key in unknown: + error(f"$.{key}", "unknown root property") + if content.get("schema_version") != 1: + error("$.schema_version", "must equal 1") + for key in ("title", "premise"): + if not _text(content.get(key)): + error(f"$.{key}", "must be a nonempty string") + target_words = _integer(content.get("target_words")) + if target_words is None or target_words < 300: + error("$.target_words", "must be an integer of at least 300") + for key in ("constraints", "forbidden_events", "open_questions"): + value = content.get(key, []) + if not isinstance(value, list) or any(not _text(item) for item in value): + error(f"$.{key}", "must be a list of nonempty strings") + + chapters = content.get("chapters") + if not isinstance(chapters, list) or not chapters: + error("$.chapters", "must be a nonempty list") + chapters = [] + chapter_keys: list[str] = [] + seen_chapter_keys: set[str] = set() + chapter_numbers: dict[str, int] = {} + beat_ids: set[str] = set() + scene_placements: set[str] = set() + scene_lookups: list[tuple[str, int, dict[str, Any]]] = [] + for index, raw in enumerate(chapters): + path = f"$.chapters[{index}]" + if not isinstance(raw, dict): + error(path, "must be an object") + continue + key = _text(raw.get("chapter_key")) + if not key or not CHAPTER_KEY_RE.fullmatch(key): + error(f"{path}.chapter_key", "must be lowercase hyphen-separated text") + elif key in chapter_numbers: + error(f"{path}.chapter_key", "must be unique") + else: + chapter_keys.append(key) + number = _integer(raw.get("number")) + order = _integer(raw.get("order")) + expected = index + 1 + if number != expected: + error(f"{path}.number", f"must equal {expected}") + if order != expected: + error(f"{path}.order", f"must equal {expected}") + for field in ("title", "purpose"): + if not _text(raw.get(field)): + error(f"{path}.{field}", "must be a nonempty string") + words = _integer(raw.get("target_words")) + if words is None or not 300 <= words <= 20000: + error(f"{path}.target_words", "must be an integer from 300 through 20000") + dependencies = raw.get("depends_on", []) + if not isinstance(dependencies, list): + error(f"{path}.depends_on", "must be a list") + else: + seen_dependencies: set[str] = set() + for dependency in dependencies: + if not isinstance(dependency, str) or dependency not in seen_chapter_keys: + error(f"{path}.depends_on", f"{dependency!r} must name an earlier chapter") + elif dependency in seen_dependencies: + error(f"{path}.depends_on", f"{dependency!r} is duplicated") + seen_dependencies.add(str(dependency)) + if key: + chapter_numbers[key] = number or expected + seen_chapter_keys.add(key) + for field in ("act_id",): + if not _text(raw.get(field)): + error(f"{path}.{field}", "must be a nonempty string") + for field in ("arc_ids", "thread_ids"): + values = raw.get(field, []) + if not isinstance(values, list) or any(not _text(item) for item in values): + error(f"{path}.{field}", "must be a list of nonempty IDs") + elif len(values) != len(set(values)): + error(f"{path}.{field}", "must not contain duplicate IDs") + beats = raw.get("beats") + if not isinstance(beats, list) or not beats: + error(f"{path}.beats", "must be a nonempty list") + else: + for beat_index, beat in enumerate(beats): + beat_path = f"{path}.beats[{beat_index}]" + if not isinstance(beat, dict): + error(beat_path, "must be an object") + continue + beat_id = _text(beat.get("beat_id")) + if not beat_id: + error(f"{beat_path}.beat_id", "must be a nonempty string") + elif beat_id in beat_ids: + error(f"{beat_path}.beat_id", "must be globally unique") + beat_ids.add(beat_id) + if not _text(beat.get("text")): + error(f"{beat_path}.text", "must be a nonempty string") + if not isinstance(beat.get("required"), bool): + error(f"{beat_path}.required", "must be boolean") + if not _text(raw.get("ending_state")): + (error if for_approval else warning)( + f"{path}.ending_state", "must be a nonempty string before approval" + ) + refs = raw.get("scene_refs", []) + if not isinstance(refs, list): + error(f"{path}.scene_refs", "must be a list") + else: + for ref_index, ref in enumerate(refs): + ref_path = f"{path}.scene_refs[{ref_index}]" + if not isinstance(ref, dict): + error(ref_path, "must be an object") + continue + scene_id = _text(ref.get("scene_id")) + raw_placement = ref.get("placement", ref.get("order")) + placement = f"{key}:{raw_placement}" if raw_placement is not None else scene_id + if not scene_id: + error(f"{ref_path}.scene_id", "must be a nonempty string") + continue + if scene_id in scene_placements or placement in scene_placements: + error(f"{ref_path}.placement", "must be unique across the book") + scene_placements.add(scene_id) + scene_placements.add(placement) + scene_lookups.append((ref_path, index, ref)) + + chapter_word_total = sum( + _integer(chapter.get("target_words")) or 0 + for chapter in chapters + if isinstance(chapter, dict) + ) + if target_words is not None and target_words != chapter_word_total: + error("$.target_words", "must equal the sum of chapter target_words") + if not _text(content.get("ending")): + error("$.ending", "must be a nonempty string") + + chapter_key_set = set(chapter_keys) + acts = content.get("acts") + if not isinstance(acts, list) or not acts: + error("$.acts", "must be a nonempty list") + acts = [] + act_ids: set[str] = set() + act_ranges: dict[str, tuple[int, int]] = {} + previous_end = 0 + for index, raw in enumerate(acts): + path = f"$.acts[{index}]" + if not isinstance(raw, dict): + error(path, "must be an object") + continue + act_id = _text(raw.get("act_id")) + if not act_id: + error(f"{path}.act_id", "must be a nonempty string") + elif act_id in act_ids: + error(f"{path}.act_id", "must be unique") + act_ids.add(act_id) + if _integer(raw.get("number")) != index + 1: + error(f"{path}.number", f"must equal {index + 1}") + start = _integer(_range_value(raw, "start")) + end = _integer(_range_value(raw, "end")) + if start is None or end is None or start > end: + error(path, "must define a valid inclusive chapter range") + else: + if start != previous_end + 1: + error(path, f"range must start at chapter {previous_end + 1}") + previous_end = end + if act_id: + act_ranges[act_id] = (start, end) + if chapters and previous_end != len(chapters): + error("$.acts", "ranges must cover every chapter exactly once") + + id_sets: dict[str, set[str]] = {"arc_ids": set(), "thread_ids": set()} + item_ranges: dict[str, dict[str, tuple[int, int]]] = { + "arc_ids": {}, + "thread_ids": {}, + } + for collection, id_name, chapter_field in ( + ("arcs", "arc_id", "arc_ids"), + ("threads", "thread_id", "thread_ids"), + ): + values = content.get(collection) + if not isinstance(values, list): + error(f"$.{collection}", "must be a list") + values = [] + for index, raw in enumerate(values): + path = f"$.{collection}[{index}]" + if not isinstance(raw, dict): + error(path, "must be an object") + continue + item_id = _text(raw.get(id_name)) + if not item_id: + error(f"{path}.{id_name}", "must be a nonempty string") + elif item_id in id_sets[chapter_field]: + error(f"{path}.{id_name}", "must be unique") + id_sets[chapter_field].add(item_id) + start_value = _range_value(raw, "start") + end_value = _range_value(raw, "end") + start = chapter_numbers.get(str(start_value), _integer(start_value)) + end = chapter_numbers.get(str(end_value), _integer(end_value)) + if ( + start is None + or end is None + or not 1 <= start <= len(chapters) + or not 1 <= end <= len(chapters) + ): + error(path, "chapter range must reference existing chapters") + elif start > end: + error(path, "chapter range cannot end before it starts") + elif item_id: + item_ranges[chapter_field][item_id] = (start, end) + + for index, raw in enumerate(chapters): + if not isinstance(raw, dict): + continue + path = f"$.chapters[{index}]" + act_id = raw.get("act_id") + if act_id not in act_ids: + error(f"{path}.act_id", "must reference an existing act") + elif act_id in act_ranges and not ( + act_ranges[act_id][0] <= index + 1 <= act_ranges[act_id][1] + ): + error(f"{path}.act_id", "does not match the act range containing this chapter") + for field in ("arc_ids", "thread_ids"): + values = raw.get(field, []) + if isinstance(values, list): + for item_id in values: + if item_id not in id_sets[field]: + error(f"{path}.{field}", f"{item_id!r} does not exist") + elif item_id in item_ranges[field] and not ( + item_ranges[field][item_id][0] + <= index + 1 + <= item_ranges[field][item_id][1] + ): + error( + f"{path}.{field}", + f"{item_id!r} is assigned outside its chapter range", + ) + + continuity = content.get("continuity") + if not isinstance(continuity, list): + error("$.continuity", "must be a list") + continuity = [] + fact_ids: set[str] = set() + for index, raw in enumerate(continuity): + path = f"$.continuity[{index}]" + if not isinstance(raw, dict): + error(path, "must be an object") + continue + fact_id = _text(raw.get("fact_id")) + if not fact_id: + error(f"{path}.fact_id", "must be a nonempty string") + elif fact_id in fact_ids: + error(f"{path}.fact_id", "must be unique") + fact_ids.add(fact_id) + if not _text(raw.get("entity_key")): + error(f"{path}.entity_key", "must be a nonempty string") + if raw.get("category") not in CONTINUITY_CATEGORIES: + error(f"{path}.category", f"must be one of {', '.join(sorted(CONTINUITY_CATEGORIES))}") + if not _text(raw.get("description")): + error(f"{path}.description", "must be a nonempty string") + established = raw.get("established_in") + resolved = raw.get("resolved_in") + if established not in chapter_key_set: + error(f"{path}.established_in", "must reference an existing chapter") + if resolved is not None: + if resolved not in chapter_key_set: + error(f"{path}.resolved_in", "must reference an existing chapter") + elif ( + established in chapter_numbers + and chapter_numbers[str(resolved)] < chapter_numbers[str(established)] + ): + error(f"{path}.resolved_in", "cannot precede establishment") + + for path, _chapter_index, ref in scene_lookups: + try: + scene = StandaloneScene.objects.get(pk=ref["scene_id"]) + except (StandaloneScene.DoesNotExist, ValueError, TypeError): + error(f"{path}.scene_id", "does not resolve to a standalone scene") + continue + if scene.work_id != work.pk: + error(f"{path}.scene_id", "scene belongs to a different work") + if scene.book_state_id: + error(f"{path}.scene_id", "scene_refs must reference unbound assembly scenes") + revision = _integer(ref.get("revision")) + if revision is None or scene.revision != revision: + error(f"{path}.revision", "does not match the scene revision") + expected_hash = _text(ref.get("sha256")) + if not expected_hash or scene.sha256 != expected_hash: + error(f"{path}.sha256", "does not match the scene content hash") + if for_approval and scene.status != SceneDraftStatus.APPROVED: + error(f"{path}.scene_id", "scene must be approved") + + return { + "schema_version": 1, + "valid": not errors, + "errors": sorted(set(errors)), + "warnings": sorted(set(warnings)), + } + + +def _field_names(instance_or_model: Any) -> set[str]: + model = instance_or_model if isinstance(instance_or_model, type) else type(instance_or_model) + return {name for field in model._meta.concrete_fields for name in (field.name, field.attname)} + + +def _value(instance: Any, *names: str, default: Any = None) -> Any: + fields = _field_names(instance) + for name in names: + if name in fields: + return getattr(instance, name) + return default + + +def _assign(instance: Any, value: Any, *names: str) -> str | None: + fields = _field_names(instance) + for name in names: + if name in fields: + setattr(instance, name, value) + return name + return None + + +def _status(enum: Any, name: str, fallback: str) -> str: + return str(getattr(enum, name, fallback)) + + +class BookStateService: + def __init__(self, router: ModelRouter) -> None: + self.router = router + + @transaction.atomic + def create( + self, + *, + work: Work, + content: dict[str, Any], + actor: str = "", + context_pack: dict[str, Any] | None = None, + generation_metadata: dict[str, Any] | None = None, + ) -> BookStateVersion: + self._validate_metadata(context_pack, "context_pack") + self._validate_metadata(generation_metadata, "generation_metadata") + locked_work = Work.objects.select_for_update().get(pk=work.pk) + version = ( + BookStateVersion.objects.filter(work=locked_work).aggregate(value=Max("version"))[ + "value" + ] + or 0 + ) + 1 + validation = validate_book_state_content(content, work=locked_work) + if not validation["valid"]: + raise ValueError("invalid book state: " + "; ".join(validation["errors"])) + summary = { + "type": "create", + "chapter_count": len(content["chapters"]), + "changed_from_chapter": 1, + } + state = self._new_state( + locked_work, + version, + content, + None, + summary, + validation, + actor, + context_pack, + generation_metadata, + ) + self._write_artifacts(state) + return state + + @transaction.atomic + def revise( + self, + state: BookStateVersion, + *, + content: dict[str, Any], + actor: str = "", + context_pack: dict[str, Any] | None = None, + generation_metadata: dict[str, Any] | None = None, + ) -> BookStateVersion: + self._validate_metadata(context_pack, "context_pack") + self._validate_metadata(generation_metadata, "generation_metadata") + locked_work = Work.objects.select_for_update().get(pk=state.work_id) + parent = ( + BookStateVersion.objects.select_for_update().select_related("work").get(pk=state.pk) + ) + version = ( + BookStateVersion.objects.filter(work=locked_work).aggregate(value=Max("version"))[ + "value" + ] + or 0 + ) + 1 + validation = validate_book_state_content(content, work=locked_work) + if not validation["valid"]: + raise ValueError("invalid book state: " + "; ".join(validation["errors"])) + summary = self._change_summary(_value(parent, "content", default={}), content) + child = self._new_state( + locked_work, + version, + content, + parent, + summary, + validation, + actor, + context_pack, + generation_metadata, + ) + self._write_artifacts(child) + return child + + def validate(self, state: BookStateVersion, *, for_approval: bool = False) -> dict[str, Any]: + result = validate_book_state_content( + _value(state, "content", default={}), work=state.work, for_approval=for_approval + ) + field = _assign(state, result, "validation", "validation_result") + if field: + state.save(update_fields=[field, "updated_at"]) + return result + + def review( + self, + state: BookStateVersion, + level: str | None = None, + *, + review_level: str | None = None, + model_hint: str | None = None, + ) -> dict[str, Any]: + level = _text(level or review_level) + content = _value(state, "content", default={}) + if _value(state, "status") not in { + _status(BookStateStatus, "DRAFT", "draft"), + _status(BookStateStatus, "REVIEW", "review"), + }: + raise ValueError("only draft or review book states may be reviewed") + valid_levels = { + "manuscript", + "continuity", + *{f"act:{item.get('act_id')}" for item in content.get("acts", [])}, + } + if level not in valid_levels: + raise ValueError(f"invalid review level: {level}") + if level == "continuity": + scene_packets = self._scene_packets(state) + prompt, prompt_version_id = render_authoring_prompt( + "BOOK_CONTINUITY_REVIEW", + BOOK_CONTINUITY_REVIEW_SYSTEM, + BOOK_CONTINUITY_REVIEW_TEMPLATE, + state=_canonical(self._compact_state(content), pretty=True), + scene_packets=_canonical(scene_packets, pretty=True), + ) + purpose = ModelCapability.STORY_CONTINUITY + else: + review_state = ( + content + if level == "manuscript" + else self._act_slice(content, level.partition(":")[2]) + ) + prompt, prompt_version_id = render_authoring_prompt( + "BOOK_STRUCTURE_REVIEW", + BOOK_STRUCTURE_REVIEW_SYSTEM, + BOOK_STRUCTURE_REVIEW_TEMPLATE, + review_level=level, + state=_canonical(self._compact_state(review_state), pretty=True), + ) + purpose = ModelCapability.STORY_REVIEW + story = getattr(state.work, "story_project", None) + response = self.router.complete( + ModelRequestContract( + purpose=purpose, + prompt=prompt, + model_hint=model_hint, + token_budget=6000, + project=story.project if story else None, + ) + ) + review = self._normalize_review(extract_json_object(response.content), content) + review.update( + { + "level": level, + "state_sha256": self._state_hash(state), + "model": response.model, + "prompt_version_id": prompt_version_id, + "prompt_sha256": _sha256(prompt), + "response_sha256": _sha256(response.content), + "reviewed_at": timezone.now().isoformat(), + } + ) + with transaction.atomic(): + locked = BookStateVersion.objects.select_for_update().get(pk=state.pk) + if _value(locked, "status") not in { + _status(BookStateStatus, "DRAFT", "draft"), + _status(BookStateStatus, "REVIEW", "review"), + }: + raise ValueError("book state changed while review was running") + reviews = deepcopy(_value(locked, "reviews", "review", default={}) or {}) + reviews[level] = review + fields = [] + field = _assign(locked, reviews, "reviews", "review") + if field: + fields.append(field) + field = _assign(locked, _status(BookStateStatus, "REVIEW", "review"), "status") + if field: + fields.append(field) + if not fields: + raise ValueError("BookStateVersion must provide a reviews or review JSON field") + locked.save(update_fields=[*fields, "updated_at"]) + self._write_artifacts(locked) + return review + + @transaction.atomic + def approve( + self, + state: BookStateVersion, + *, + actor: str = "", + force: bool = False, + notes: str = "", + ) -> BookStateVersion: + work = Work.objects.select_for_update().get(pk=state.work_id) + locked = ( + BookStateVersion.objects.select_for_update().select_related("work").get(pk=state.pk) + ) + if _value(locked, "status") != _status(BookStateStatus, "REVIEW", "review"): + raise ValueError("book state must be in review before approval") + validation = validate_book_state_content( + _value(locked, "content", default={}), work=work, for_approval=True + ) + if not validation["valid"]: + raise ValueError("book state is not approval-valid: " + "; ".join(validation["errors"])) + parent_id = _value(locked, "parent_id") + current_id = _value(work, "current_book_state_id") + if parent_id != current_id: + raise ValueError("book state parent is stale relative to the work's approved state") + if force and not notes.strip(): + raise ValueError("forced approval requires notes") + content = _value(locked, "content", default={}) + required = ["manuscript", *[f"act:{act['act_id']}" for act in content.get("acts", [])]] + if any(chapter.get("scene_refs") for chapter in content.get("chapters", [])): + required.append("continuity") + reviews = _value(locked, "reviews", "review", default={}) or {} + state_hash = self._state_hash(locked) + for level in required: + review = reviews.get(level) or {} + if (not review.get("passed") or review.get("state_sha256") != state_hash) and not force: + raise ValueError(f"passing current {level} review is required") + fields = [] + for value, names in ( + (_status(BookStateStatus, "APPROVED", "approved"), ("status",)), + (timezone.now(), ("approved_at",)), + (actor.strip(), ("approved_by",)), + (notes.strip(), ("approval_notes",)), + (force, ("approval_forced",)), + (validation, ("validation", "validation_result")), + ): + field = _assign(locked, value, *names) + if field: + fields.append(field) + locked.save(update_fields=[*fields, "updated_at"]) + current_field = _assign(work, locked, "current_book_state") + if not current_field: + raise ValueError("Work must provide current_book_state") + work.save(update_fields=[current_field, "updated_at"]) + self._write_artifacts(locked) + return locked + + @transaction.atomic + def reject( + self, state: BookStateVersion, *, actor: str = "", notes: str = "" + ) -> BookStateVersion: + locked = BookStateVersion.objects.select_for_update().get(pk=state.pk) + if _value(locked, "status") not in { + _status(BookStateStatus, "DRAFT", "draft"), + _status(BookStateStatus, "REVIEW", "review"), + }: + raise ValueError("only draft or review book states may be rejected") + fields = [] + for value, names in ( + (_status(BookStateStatus, "REJECTED", "rejected"), ("status",)), + (timezone.now(), ("rejected_at",)), + (actor.strip(), ("rejected_by",)), + (notes.strip(), ("rejection_notes",)), + ): + field = _assign(locked, value, *names) + if field: + fields.append(field) + locked.save(update_fields=[*fields, "updated_at"]) + self._write_artifacts(locked) + return locked + + def impact(self, state: BookStateVersion) -> dict[str, Any]: + parent = _value(state, "parent") + if parent is None: + chapter_keys = [ + item.get("chapter_key") + for item in _value(state, "content", default={}).get("chapters", []) + ] + return { + "schema_version": 1, + "from_chapter": 1 if chapter_keys else None, + "replan_required": chapter_keys, + "continuity_review_required": chapter_keys[1:], + "reasons": ["initial_state"], + } + return self._impact_content( + _value(parent, "content", default={}), _value(state, "content", default={}) + ) + + @transaction.atomic + def start_run( + self, state: BookStateVersion, *, policy: dict[str, Any] | None = None + ) -> BookRun: + self._validate_metadata(policy, "policy") + locked = BookStateVersion.objects.select_for_update().get(pk=state.pk) + if _value(locked, "status") != _status(BookStateStatus, "APPROVED", "approved"): + raise ValueError("book runs require an approved book state") + chapters = _value(locked, "content", default={}).get("chapters", []) + progress = {item["chapter_key"]: "needs_scene" for item in chapters} + run_policy = deepcopy(policy or {}) + run_policy["required_reviews"] = self._required_reviews( + _value(locked, "content", default={}) + ) + run_policy["run_required_reviews"] = ["continuity"] + values: dict[str, Any] = {} + fields = _field_names(BookRun) + for names, value in ( + (("state", "book_state"), locked), + (("work",), locked.work), + (("status",), _status(BookRunStatus, "RUNNING", "running")), + (("progress",), progress), + ( + ("current_chapter_key", "cursor_chapter_key", "cursor"), + chapters[0]["chapter_key"] if chapters else "", + ), + ( + ("policy",), + run_policy, + ), + (("started_at",), timezone.now()), + ): + for name in names: + if name in fields: + values[name] = value + break + return BookRun.objects.create(**values) + + @transaction.atomic + def sync_run(self, run: BookRun) -> BookRun: + run_scope = BookRun.objects.select_related("book_state").get(pk=run.pk) + Work.objects.select_for_update().get(pk=run_scope.book_state.work_id) + locked = BookRun.objects.select_for_update().select_related("book_state").get(pk=run.pk) + if locked.status == BookRunStatus.COMPLETE: + return locked + if locked.status in { + BookRunStatus.FAILED, + BookRunStatus.CANCELLED, + }: + raise ValueError(f"cannot synchronize a {locked.status} book run") + state = _value(locked, "state", "book_state") + chapters = _value(state, "content", default={}).get("chapters", []) + progress: dict[str, str] = {} + for chapter in chapters: + key = chapter["chapter_key"] + progress[key] = self._chapter_progress(state, chapter) + first_incomplete = next( + (key for key, status in progress.items() if status != "complete"), "" + ) + if first_incomplete: + status = _status(BookRunStatus, "PAUSED", "paused") + elif self._run_reviews_pass(locked, state): + status = _status(BookRunStatus, "COMPLETE", "complete") + else: + status = _status(BookRunStatus, "REVIEW", "review") + fields = [] + finished_at = ( + timezone.now() if status == _status(BookRunStatus, "COMPLETE", "complete") else None + ) + for value, names in ( + (progress, ("progress",)), + (first_incomplete, ("current_chapter_key", "cursor_chapter_key", "cursor")), + (status, ("status",)), + (finished_at, ("finished_at",)), + ): + field = _assign(locked, value, *names) + if field: + fields.append(field) + locked.save(update_fields=[*fields, "updated_at"]) + return locked + + def review_run(self, run: BookRun, *, model_hint: str | None = None) -> dict[str, Any]: + run = BookRun.objects.select_related("book_state__work").get(pk=run.pk) + if run.status != BookRunStatus.REVIEW: + raise ValueError("book run must have all chapters complete before continuity review") + state = run.book_state + content = state.content or {} + scene_packets = self._run_scene_packets(state) + scene_set_sha256 = _sha256(_canonical(scene_packets)) + prompt, prompt_version_id = render_authoring_prompt( + "BOOK_CONTINUITY_REVIEW", + BOOK_CONTINUITY_REVIEW_SYSTEM, + BOOK_CONTINUITY_REVIEW_TEMPLATE, + state=_canonical(self._compact_state(content), pretty=True), + scene_packets=_canonical(scene_packets, pretty=True), + ) + story = getattr(state.work, "story_project", None) + response = self.router.complete( + ModelRequestContract( + purpose=ModelCapability.STORY_CONTINUITY, + prompt=prompt, + model_hint=model_hint, + token_budget=6000, + project=story.project if story else None, + ) + ) + review = self._normalize_review(extract_json_object(response.content), content) + review.update( + { + "level": "continuity", + "state_sha256": self._state_hash(state), + "scene_set_sha256": scene_set_sha256, + "scene_manifest": self._scene_manifest(scene_packets), + "model": response.model, + "prompt_version_id": prompt_version_id, + "prompt_sha256": _sha256(prompt), + "response_sha256": _sha256(response.content), + "reviewed_at": timezone.now().isoformat(), + } + ) + with transaction.atomic(): + locked = BookRun.objects.select_for_update().select_related("book_state").get(pk=run.pk) + if locked.status != BookRunStatus.REVIEW: + raise ValueError("book run changed while continuity review was running") + current_packets = self._run_scene_packets(locked.book_state) + if _sha256(_canonical(current_packets)) != scene_set_sha256: + raise ValueError("book run scenes changed while continuity review was running") + reviews = deepcopy(locked.reviews or {}) + reviews["continuity"] = review + locked.reviews = reviews + locked.save(update_fields=["reviews", "updated_at"]) + self.sync_run(locked) + return review + + def _new_state( + self, + work: Work, + version: int, + content: dict[str, Any], + parent: BookStateVersion | None, + summary: dict[str, Any], + validation: dict[str, Any], + actor: str, + context_pack: dict[str, Any] | None, + generation_metadata: dict[str, Any] | None, + ) -> BookStateVersion: + fields = _field_names(BookStateVersion) + values: dict[str, Any] = {"work": work, "version": version, "content": deepcopy(content)} + candidates = ( + ("parent", parent), + ("status", _status(BookStateStatus, "DRAFT", "draft")), + ("change_summary", summary), + ("validation", validation), + ("validation_result", validation), + ("sha256", _sha256(_canonical(content))), + ("created_by", actor.strip()), + ("context_pack", deepcopy(context_pack or {})), + ("generation_metadata", deepcopy(generation_metadata or {})), + ) + for name, value in candidates: + if name in fields and name not in values: + values[name] = value + return BookStateVersion.objects.create(**values) + + @staticmethod + def _validate_metadata(value: Any, name: str) -> None: + if value is not None and not isinstance(value, dict): + raise ValueError(f"{name} must be an object") + + @staticmethod + def _change_summary(parent: dict[str, Any], child: dict[str, Any]) -> dict[str, Any]: + changed = sorted(key for key in ROOT_KEYS if parent.get(key) != child.get(key)) + impact = BookStateService._impact_content(parent, child) + return { + "type": "revision", + "changed_sections": changed, + "changed_from_chapter": impact["from_chapter"], + "replan_required": impact["replan_required"], + "continuity_review_required": impact["continuity_review_required"], + } + + @staticmethod + def _impact_content(parent: dict[str, Any], child: dict[str, Any]) -> dict[str, Any]: + old_chapters = parent.get("chapters", []) + new_chapters = child.get("chapters", []) + new_keys = [item.get("chapter_key") for item in new_chapters] + reasons: list[str] = [] + earliest: int | None = None + global_fields = ( + "premise", + "target_words", + "constraints", + "forbidden_events", + "acts", + "arcs", + "threads", + "continuity", + "ending", + ) + for field in global_fields: + if parent.get(field) != child.get(field): + earliest = 1 + reasons.append(field) + old_keys = [item.get("chapter_key") for item in old_chapters] + directly_changed: list[str] = [] + limit = max(len(old_chapters), len(new_chapters)) + for index in range(limit): + old = old_chapters[index] if index < len(old_chapters) else None + new = new_chapters[index] if index < len(new_chapters) else None + if old != new: + candidate = index + 1 + earliest = candidate if earliest is None else min(earliest, candidate) + if old is None: + reasons.append("chapter_inserted") + elif new is None: + reasons.append("chapter_deleted") + elif old.get("chapter_key") != new.get("chapter_key"): + reasons.append("chapter_reordered") + else: + reasons.append(f"chapter_contract:{new.get('chapter_key')}") + directly_changed.append(new.get("chapter_key")) + if ( + old_keys != new_keys + and "chapter_reordered" not in reasons + and len(old_keys) == len(new_keys) + ): + reasons.append("chapter_reordered") + structural = any( + reason in {"chapter_inserted", "chapter_deleted", "chapter_reordered"} + for reason in reasons + ) + global_change = any(field in reasons for field in global_fields) + if global_change or structural: + replan = new_keys[earliest - 1 :] if earliest else [] + else: + affected = set(directly_changed) + changed = True + while changed: + changed = False + for chapter in new_chapters: + key = chapter.get("chapter_key") + if key not in affected and affected.intersection(chapter.get("depends_on", [])): + affected.add(key) + changed = True + replan = [key for key in new_keys if key in affected] + continuity = new_keys[earliest:] if earliest else [] + return { + "schema_version": 1, + "from_chapter": earliest, + "replan_required": replan, + "continuity_review_required": continuity, + "reasons": sorted(set(reasons)), + } + + @staticmethod + def _compact_state(content: dict[str, Any]) -> dict[str, Any]: + return deepcopy(content) + + @staticmethod + def _act_slice(content: dict[str, Any], act_id: str) -> dict[str, Any]: + act = next(item for item in content.get("acts", []) if item.get("act_id") == act_id) + start = int(_range_value(act, "start")) + end = int(_range_value(act, "end")) + sliced = {key: deepcopy(value) for key, value in content.items() if key != "chapters"} + sliced["acts"] = [deepcopy(act)] + sliced["chapters"] = deepcopy(content.get("chapters", [])[start - 1 : end]) + keys = {item["chapter_key"] for item in sliced["chapters"]} + sliced["continuity"] = [ + deepcopy(item) + for item in content.get("continuity", []) + if item.get("established_in") in keys or item.get("resolved_in") in keys + ] + return sliced + + @staticmethod + def _normalize_review(data: dict[str, Any], content: dict[str, Any]) -> dict[str, Any]: + raw_findings = data.get("findings") + if not isinstance(raw_findings, list): + raise ValueError("review response must contain a findings list") + chapter_keys = {item.get("chapter_key") for item in content.get("chapters", [])} + findings = [] + for raw in raw_findings: + if not isinstance(raw, dict): + raise ValueError("each review finding must be an object") + severity = str(raw.get("severity") or "").upper() + if severity not in {"INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"}: + raise ValueError("each review finding must have a valid severity") + category = str(raw.get("category") or "").lower() + if category not in REVIEW_CATEGORIES: + raise ValueError("each review finding must have a valid category") + chapter_key = _text(raw.get("chapter_key")) + if chapter_key not in chapter_keys: + chapter_key = "" + description = _text(raw.get("description")) + if not description: + raise ValueError("each review finding must have a description") + findings.append( + { + "severity": severity, + "category": category, + "chapter_key": chapter_key, + "description": description, + "suggested_revision": _text(raw.get("suggested_revision")), + } + ) + findings.sort( + key=lambda item: ( + item["chapter_key"], + item["severity"], + item["category"], + item["description"], + ) + ) + blocking = sum(item["severity"] in {"HIGH", "CRITICAL"} for item in findings) + return { + "schema_version": 1, + "passed": blocking == 0, + "blocking_count": blocking, + "findings": findings, + } + + def _scene_packets(self, state: BookStateVersion) -> list[dict[str, Any]]: + packets = [] + for chapter in _value(state, "content", default={}).get("chapters", []): + for placement, ref in enumerate(chapter.get("scene_refs", []), start=1): + scene = StandaloneScene.objects.get(pk=ref["scene_id"], work=state.work) + packets.append( + { + "chapter_key": chapter["chapter_key"], + "placement": ref.get("placement", placement), + "scene_key": scene.scene_key, + "revision": scene.revision, + "sha256": scene.sha256, + "title": scene.title, + "prose": scene.prose, + } + ) + return packets + + @staticmethod + def _referenced_scenes( + state: BookStateVersion, chapter: dict[str, Any] + ) -> list[StandaloneScene]: + references = chapter.get("scene_refs") or [] + if not references: + return [] + scenes = { + str(scene.id): scene + for scene in StandaloneScene.objects.filter( + work=state.work, + id__in=[reference["scene_id"] for reference in references], + ) + } + missing = [ + str(reference["scene_id"]) + for reference in references + if str(reference["scene_id"]) not in scenes + ] + if missing: + raise ValueError("referenced scenes no longer exist: " + ", ".join(missing)) + return [scenes[str(reference["scene_id"])] for reference in references] + + @staticmethod + def _latest_bound_scenes(state: BookStateVersion, chapter_key: str) -> list[StandaloneScene]: + scenes = StandaloneScene.objects.filter( + work=state.work, + book_state=state, + book_chapter_key=chapter_key, + ).order_by("scene_key", "-revision", "-created_at") + latest: dict[str, StandaloneScene] = {} + for scene in scenes: + latest.setdefault(scene.scene_key, scene) + return list(latest.values()) + + def _chapter_progress(self, state: BookStateVersion, chapter: dict[str, Any]) -> str: + scenes = self._referenced_scenes(state, chapter) + if not scenes: + scenes = self._latest_bound_scenes(state, chapter["chapter_key"]) + if not scenes: + return "needs_scene" + if len(scenes) > 1 and not chapter.get("scene_refs"): + return "ambiguous_scenes" + if all(scene.status == SceneDraftStatus.APPROVED for scene in scenes): + return "complete" + statuses = sorted({str(scene.status) for scene in scenes}) + return statuses[0] if len(statuses) == 1 else "mixed_scene_status" + + def _run_scene_packets(self, state: BookStateVersion) -> list[dict[str, Any]]: + packets = [] + for chapter in (state.content or {}).get("chapters", []): + scenes = self._referenced_scenes(state, chapter) + if not scenes: + scenes = self._latest_bound_scenes(state, chapter["chapter_key"]) + for placement, scene in enumerate(scenes, start=1): + packets.append( + { + "chapter_key": chapter["chapter_key"], + "placement": placement, + "scene_id": str(scene.id), + "scene_key": scene.scene_key, + "revision": scene.revision, + "sha256": scene.sha256, + "title": scene.title, + "prose": scene.prose, + } + ) + return packets + + def _run_reviews_pass(self, run: BookRun, state: BookStateVersion) -> bool: + review = (run.reviews or {}).get("continuity") or {} + scene_set_sha256 = _sha256(_canonical(self._run_scene_packets(state))) + return bool( + review.get("passed") + and review.get("state_sha256") == self._state_hash(state) + and review.get("scene_set_sha256") == scene_set_sha256 + ) + + @staticmethod + def _scene_manifest(scene_packets: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + { + "chapter_key": packet["chapter_key"], + "placement": packet["placement"], + "scene_id": packet["scene_id"], + "scene_key": packet["scene_key"], + "revision": packet["revision"], + "sha256": packet["sha256"], + } + for packet in scene_packets + ] + + @staticmethod + def _required_reviews(content: dict[str, Any]) -> list[str]: + levels = [*[f"act:{item['act_id']}" for item in content.get("acts", [])], "manuscript"] + if any(item.get("scene_refs") for item in content.get("chapters", [])): + levels.append("continuity") + return levels + + def _reviews_pass(self, state: BookStateVersion, levels: list[str]) -> bool: + reviews = _value(state, "reviews", "review", default={}) or {} + state_hash = self._state_hash(state) + return all( + reviews.get(level, {}).get("passed") + and reviews[level].get("state_sha256") == state_hash + for level in levels + ) + + @staticmethod + def _state_hash(state: BookStateVersion) -> str: + return _value(state, "sha256", default="") or _sha256( + _canonical(_value(state, "content", default={})) + ) + + def _artifact_root(self, state: BookStateVersion) -> Path: + story = getattr(state.work, "story_project", None) + if story and story.artifact_root.strip(): + return Path(story.artifact_root) / "book-states" + return ( + Path(settings.BASE_DIR) + / "artifacts" + / "stories" + / state.work.series.slug + / state.work.slug + / "book-states" + ) + + def _write_artifacts(self, state: BookStateVersion) -> None: + stem = f"v{state.version:04d}" + content = _value(state, "content", default={}) + payload = { + "schema_version": 1, + "work_id": str(state.work_id), + "version": state.version, + "status": str(_value(state, "status", default="")), + "sha256": self._state_hash(state), + "parent_id": str(_value(state, "parent_id", default="") or ""), + "change_summary": _value(state, "change_summary", default={}) or {}, + "context_pack": _value(state, "context_pack", default={}) or {}, + "context_pack_sha256": _value(state, "context_pack_sha256", default=""), + "generation_metadata": _value(state, "generation_metadata", default={}) or {}, + "created_by": _value(state, "created_by", default=""), + "validation": _value(state, "validation", "validation_result", default={}) or {}, + "reviews": _value(state, "reviews", "review", default={}) or {}, + "approved_at": str(_value(state, "approved_at", default="") or ""), + "approved_by": _value(state, "approved_by", default=""), + "approval_notes": _value(state, "approval_notes", default=""), + "approval_forced": bool(_value(state, "approval_forced", default=False)), + "rejected_at": str(_value(state, "rejected_at", default="") or ""), + "rejected_by": _value(state, "rejected_by", default=""), + "rejection_notes": _value(state, "rejection_notes", default=""), + "content": content, + } + json_path = ( + Path(state.json_artifact_uri) + if state.json_artifact_uri + else self._artifact_root(state) / f"{stem}.json" + ) + markdown_path = ( + Path(state.markdown_artifact_uri) + if state.markdown_artifact_uri + else self._artifact_root(state) / f"{stem}.md" + ) + atomic_write_text(json_path, _canonical(payload, pretty=True) + "\n") + lines = [ + f"# {content.get('title') or state.work.title}", + "", + f"Book state version: {state.version}", + f"Status: {payload['status']}", + f"SHA-256: `{payload['sha256']}`", + "", + "## Premise", + "", + str(content.get("premise") or ""), + "", + "## Chapters", + "", + ] + for chapter in content.get("chapters", []): + lines.extend( + [ + f"### {chapter.get('number')}. {chapter.get('title')}", + "", + str(chapter.get("purpose") or ""), + "", + ] + ) + atomic_write_text(markdown_path, "\n".join(lines).rstrip() + "\n") + fields = [] + for value, names in ( + (str(json_path), ("json_artifact_uri", "artifact_uri")), + (str(markdown_path), ("markdown_artifact_uri",)), + ): + field = _assign(state, value, *names) + if field: + fields.append(field) + if fields: + state.save(update_fields=[*fields, "updated_at"]) diff --git a/control_plane/authoring/checkpoints.py b/control_plane/authoring/checkpoints.py new file mode 100644 index 0000000..6992feb --- /dev/null +++ b/control_plane/authoring/checkpoints.py @@ -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() diff --git a/control_plane/authoring/epub.py b/control_plane/authoring/epub.py new file mode 100644 index 0000000..082cd9e --- /dev/null +++ b/control_plane/authoring/epub.py @@ -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 = [ + '', + '', + '', + '', + ] + spine = [''] + 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", + '' + '' + "", + ) + 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'

{html.escape(title)}

{html.escape(series)}

'), + ) + 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'' + ) + spine.append(f'') + navigation.append( + f'
  • {html.escape(chapter_title)}
  • ' + ) + ncx.append( + f'{html.escape(chapter_title)}' + f'' + ) + 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"{html.escape(paragraph)}

    ") + archive.writestr( + f"OEBPS/{filename}", + _xhtml(chapter_title, f"

    {html.escape(chapter_title)}

    {''.join(paragraphs)}"), + ) + archive.writestr( + "OEBPS/nav.xhtml", + _xhtml(title, f''), + ) + archive.writestr( + "OEBPS/toc.ncx", + f'' + f"{html.escape(title)}{''.join(ncx)}", + ) + archive.writestr( + "OEBPS/content.opf", + f'' + f'artifex-{html.escape(title)}' + f"{html.escape(title)}en" + f'{"".join(manifest)}{"".join(spine)}', + ) + return destination + + +def _xhtml(title: str, body: str) -> str: + return ( + '' + '' + f"{html.escape(title)}" + f"{body}" + ) diff --git a/control_plane/authoring/management/__init__.py b/control_plane/authoring/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/control_plane/authoring/management/commands/__init__.py b/control_plane/authoring/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/control_plane/authoring/management/commands/benchmark_story_prose.py b/control_plane/authoring/management/commands/benchmark_story_prose.py new file mode 100644 index 0000000..c262480 --- /dev/null +++ b/control_plane/authoring/management/commands/benchmark_story_prose.py @@ -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, + ) + ) diff --git a/control_plane/authoring/management/commands/fiction_book.py b/control_plane/authoring/management/commands/fiction_book.py new file mode 100644 index 0000000..516b763 --- /dev/null +++ b/control_plane/authoring/management/commands/fiction_book.py @@ -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(), + } diff --git a/control_plane/authoring/management/commands/fiction_ideas.py b/control_plane/authoring/management/commands/fiction_ideas.py new file mode 100644 index 0000000..c04b9bc --- /dev/null +++ b/control_plane/authoring/management/commands/fiction_ideas.py @@ -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, + } diff --git a/control_plane/authoring/management/commands/fiction_scene.py b/control_plane/authoring/management/commands/fiction_scene.py new file mode 100644 index 0000000..e660df7 --- /dev/null +++ b/control_plane/authoring/management/commands/fiction_scene.py @@ -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)) diff --git a/control_plane/authoring/management/commands/story_book_run.py b/control_plane/authoring/management/commands/story_book_run.py new file mode 100644 index 0000000..2535324 --- /dev/null +++ b/control_plane/authoring/management/commands/story_book_run.py @@ -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 diff --git a/control_plane/authoring/management/commands/story_book_supervisor.py b/control_plane/authoring/management/commands/story_book_supervisor.py new file mode 100644 index 0000000..7c54de1 --- /dev/null +++ b/control_plane/authoring/management/commands/story_book_supervisor.py @@ -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 diff --git a/control_plane/authoring/management/commands/story_candidate_step.py b/control_plane/authoring/management/commands/story_candidate_step.py new file mode 100644 index 0000000..04e2da4 --- /dev/null +++ b/control_plane/authoring/management/commands/story_candidate_step.py @@ -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)) diff --git a/control_plane/authoring/management/commands/story_chapter_probe.py b/control_plane/authoring/management/commands/story_chapter_probe.py new file mode 100644 index 0000000..53b6adb --- /dev/null +++ b/control_plane/authoring/management/commands/story_chapter_probe.py @@ -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}" + ) + ) diff --git a/control_plane/authoring/management/commands/story_scene_probe.py b/control_plane/authoring/management/commands/story_scene_probe.py new file mode 100644 index 0000000..2c0886e --- /dev/null +++ b/control_plane/authoring/management/commands/story_scene_probe.py @@ -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}" + ) + ) diff --git a/control_plane/authoring/management/commands/story_sources.py b/control_plane/authoring/management/commands/story_sources.py new file mode 100644 index 0000000..7c83c84 --- /dev/null +++ b/control_plane/authoring/management/commands/story_sources.py @@ -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." + ) + ) diff --git a/control_plane/authoring/management/commands/story_state.py b/control_plane/authoring/management/commands/story_state.py new file mode 100644 index 0000000..b7522c4 --- /dev/null +++ b/control_plane/authoring/management/commands/story_state.py @@ -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, + ) + ) diff --git a/control_plane/authoring/management/commands/story_temporal_probe.py b/control_plane/authoring/management/commands/story_temporal_probe.py new file mode 100644 index 0000000..6488868 --- /dev/null +++ b/control_plane/authoring/management/commands/story_temporal_probe.py @@ -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']}")) diff --git a/control_plane/authoring/management/commands/story_workflow.py b/control_plane/authoring/management/commands/story_workflow.py new file mode 100644 index 0000000..68b1cb3 --- /dev/null +++ b/control_plane/authoring/management/commands/story_workflow.py @@ -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}" + ) diff --git a/control_plane/authoring/migrations/0001_initial.py b/control_plane/authoring/migrations/0001_initial.py new file mode 100644 index 0000000..f5de48d --- /dev/null +++ b/control_plane/authoring/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/control_plane/authoring/migrations/0002_chaptercontract_chapterstatedocument_storyentity_and_more.py b/control_plane/authoring/migrations/0002_chaptercontract_chapterstatedocument_storyentity_and_more.py new file mode 100644 index 0000000..8cfad30 --- /dev/null +++ b/control_plane/authoring/migrations/0002_chaptercontract_chapterstatedocument_storyentity_and_more.py @@ -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), + ] diff --git a/control_plane/authoring/migrations/0003_series_sourcedocument_sourcedocumentversion_and_more.py b/control_plane/authoring/migrations/0003_series_sourcedocument_sourcedocumentversion_and_more.py new file mode 100644 index 0000000..6973ac2 --- /dev/null +++ b/control_plane/authoring/migrations/0003_series_sourcedocument_sourcedocumentversion_and_more.py @@ -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'), + ), + ] diff --git a/control_plane/authoring/migrations/0004_standalonescene_scenecontextcitation_and_more.py b/control_plane/authoring/migrations/0004_standalonescene_scenecontextcitation_and_more.py new file mode 100644 index 0000000..12d3e7b --- /dev/null +++ b/control_plane/authoring/migrations/0004_standalonescene_scenecontextcitation_and_more.py @@ -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'), + ), + ] diff --git a/control_plane/authoring/migrations/0005_sceneideation.py b/control_plane/authoring/migrations/0005_sceneideation.py new file mode 100644 index 0000000..3524d00 --- /dev/null +++ b/control_plane/authoring/migrations/0005_sceneideation.py @@ -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'], + }, + ), + ] diff --git a/control_plane/authoring/migrations/0006_alter_sceneideation_candidate_count.py b/control_plane/authoring/migrations/0006_alter_sceneideation_candidate_count.py new file mode 100644 index 0000000..cb19927 --- /dev/null +++ b/control_plane/authoring/migrations/0006_alter_sceneideation_candidate_count.py @@ -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)], + ), + ), + ] diff --git a/control_plane/authoring/migrations/0007_sceneideation_target_book.py b/control_plane/authoring/migrations/0007_sceneideation_target_book.py new file mode 100644 index 0000000..4390060 --- /dev/null +++ b/control_plane/authoring/migrations/0007_sceneideation_target_book.py @@ -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), + ), + ] diff --git a/control_plane/authoring/migrations/0008_sceneideation_requested_scene_types.py b/control_plane/authoring/migrations/0008_sceneideation_requested_scene_types.py new file mode 100644 index 0000000..88aa14c --- /dev/null +++ b/control_plane/authoring/migrations/0008_sceneideation_requested_scene_types.py @@ -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), + ), + ] diff --git a/control_plane/authoring/migrations/0009_book_authoring_state.py b/control_plane/authoring/migrations/0009_book_authoring_state.py new file mode 100644 index 0000000..4bda439 --- /dev/null +++ b/control_plane/authoring/migrations/0009_book_authoring_state.py @@ -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"], + }, + ), + ] diff --git a/control_plane/authoring/migrations/__init__.py b/control_plane/authoring/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/control_plane/authoring/models.py b/control_plane/authoring/models.py new file mode 100644 index 0000000..50f52b1 --- /dev/null +++ b/control_plane/authoring/models.py @@ -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) diff --git a/control_plane/authoring/prompts.py b/control_plane/authoring/prompts.py new file mode 100644 index 0000000..d091e85 --- /dev/null +++ b/control_plane/authoring/prompts.py @@ -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} +""" diff --git a/control_plane/authoring/runner.py b/control_plane/authoring/runner.py new file mode 100644 index 0000000..b7b799f --- /dev/null +++ b/control_plane/authoring/runner.py @@ -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 diff --git a/control_plane/authoring/scene_context.py b/control_plane/authoring/scene_context.py new file mode 100644 index 0000000..2daa3eb --- /dev/null +++ b/control_plane/authoring/scene_context.py @@ -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 diff --git a/control_plane/authoring/services.py b/control_plane/authoring/services.py new file mode 100644 index 0000000..8c69ddb --- /dev/null +++ b/control_plane/authoring/services.py @@ -0,0 +1,1690 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from django.conf import settings +from django.db import transaction +from django.db.models import Max, Q +from django.utils import timezone + +from control_plane.authoring.epub import write_epub +from control_plane.authoring.models import ( + CanonSnapshot, + Chapter, + ChapterContract, + ChapterRevision, + ChapterStateDocument, + ChapterStatus, + EditorialFinding, + FindingSeverity, + FindingStatus, + GenerationContextSnapshot, + PromptVersion, + RequirementCheck, + RequirementStatus, + RevisionStatus, + StateChange, + StateChangeStatus, + StateDocumentStatus, + StateOperation, + StoryEntity, + StoryProject, + text_sha256, +) +from control_plane.authoring.prompts import ( + DEFAULT_CONTINUITY_TEMPLATE, + DEFAULT_DRAFT_SYSTEM, + DEFAULT_FINAL_STATE_TEMPLATE, + DEFAULT_FULL_CHAPTER_DRAFT_TEMPLATE, + DEFAULT_PATCH_REVISION_TEMPLATE, + DEFAULT_PLAN_SYSTEM, + DEFAULT_PLAN_TEMPLATE, + DEFAULT_QUALITY_REVIEW_TEMPLATE, + DEFAULT_REPAIR_PLAN_TEMPLATE, + DEFAULT_REVIEW_TEMPLATE, + DEFAULT_REVISION_TEMPLATE, + DEFAULT_STATE_JUDGE_TEMPLATE, + DEFAULT_TARGETED_VERIFICATION_TEMPLATE, +) +from control_plane.authoring.state_management import ( + apply_state_changes, + build_contract_requirements, + evidence_is_present, + json_sha256, + normalize_entity_key, + render_state_markdown, + requirement_is_blocking, +) +from control_plane.authoring.streaming import ResumableDraftWriter, atomic_write_text +from graph.models import GraphApproval, GraphApprovalStatus +from model_router.providers import ProviderError, extract_json_object +from model_router.router import ModelCapability, ModelRequestContract, ModelRouter + + +def compact_scene_contract(scene: dict[str, Any], maximum_beats: int = 3) -> dict[str, Any]: + compact = { + key: scene[key] + for key in ("number", "purpose", "location", "present", "ending_state") + if key in scene + } + beats = [beat if isinstance(beat, dict) else {"text": str(beat)} for beat in scene.get("beats") or []] + required = [beat for beat in beats if beat.get("required", True)] + if len(required) <= maximum_beats: + compact["beats"] = required + return compact + consolidated = [] + for index in range(maximum_beats): + start = index * len(required) // maximum_beats + end = (index + 1) * len(required) // maximum_beats + group = required[start:end] + consolidated.append( + { + "text": " ".join(str(beat.get("text") or "") for beat in group), + "required": True, + "source_beat_count": len(group), + } + ) + compact["beats"] = consolidated + return compact + + +def deterministic_temporal_findings( + prose: str, scene_plan: dict[str, Any] | None = None +) -> list[dict[str, Any]]: + settlement_offset = prose.find("The bids were opened") + if settlement_offset < 0: + return [] + findings = [] + for match in re.finditer( + r"[^.!?\n]*(?:had become|was already|was now) (?:rich|wealthy|a millionaire)[^.!?\n]*[.!?]", + prose[:settlement_offset], + flags=re.IGNORECASE, + ): + evidence = match.group(0).strip() + replacement = re.sub( + r"had become wealthy", + "might soon become wealthy", + evidence, + flags=re.IGNORECASE, + ) + findings.append( + { + "severity": FindingSeverity.HIGH, + "category": "premature_state", + "location": "before bidding and settlement", + "evidence_quote": evidence, + "description": "Wealth cannot be established before bidding and settlement.", + "suggested_revision": replacement, + "objective": True, + "exact_patch_suitable": True, + "source": "deterministic", + } + ) + exact_values = (scene_plan or {}).get("exact_values") or [] + payout = None + for value in exact_values: + text = str(value) + if "finder share" not in text.lower() and "corin receives" not in text.lower(): + continue + match = re.search(r"(?:exactly|is)\s+([\d,]+)\s+(?:silver\s+)?crowns", text, re.IGNORECASE) + if match: + payout = int(match.group(1).replace(",", "")) + break + aliases = [] + if payout: + aliases.extend([f"{payout:,} crowns", f"{payout / 1_000_000:g} million crowns"]) + whole_millions, remainder = divmod(payout, 1_000_000) + number_words = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"] + if remainder == 500_000 and 0 < whole_millions < len(number_words): + aliases.append(f"{number_words[whole_millions]} and a half million crowns") + for alias in aliases: + match = re.search( + rf"[^.!?\n]*\b{re.escape(alias)}\b[^.!?\n]*[.!?]", + prose[:settlement_offset], + flags=re.IGNORECASE, + ) + if not match: + continue + evidence = match.group(0).strip() + replacement = re.sub(re.escape(alias), "The sale proceeds", evidence, flags=re.IGNORECASE) + findings.append( + { + "severity": FindingSeverity.HIGH, + "category": "premature_exact_value", + "location": "before bidding and settlement", + "evidence_quote": evidence, + "description": "Exact finder proceeds cannot be known before bidding and settlement.", + "suggested_revision": replacement, + "objective": True, + "exact_patch_suitable": True, + "source": "deterministic", + } + ) + break + return findings + + +def scene_draft_packet(scene_plan: dict[str, Any], scene: dict[str, Any]) -> dict[str, Any]: + scenes = scene_plan.get("scenes") or [] + target_words = int(scene_plan.get("target_words") or 5500) + default_scene_words = max(800, target_words // max(1, len(scenes))) + return { + "chapter_scope": { + key: scene_plan.get(key) + for key in ( + "day_start", + "day_end", + "exact_values", + "forbidden_events", + "chapter_constraints", + "final_image", + ) + if scene_plan.get(key) + }, + "scene": compact_scene_contract(scene), + "target_words": int(scene.get("word_budget") or default_scene_words), + } + + +def compact_chapter_plan(scene_plan: dict[str, Any]) -> dict[str, Any]: + packet = scene_draft_packet(scene_plan, {}) + return { + **packet["chapter_scope"], + "target_words": int(scene_plan.get("target_words") or 5500), + "scenes": [compact_scene_contract(scene) for scene in scene_plan.get("scenes") or []], + } + + +def patch_change_ratio(prose: str, edits: list[dict[str, Any]]) -> float: + if not prose: + return 1.0 + return sum( + max(len(str(edit.get("old_text") or "")), len(str(edit.get("new_text") or ""))) + for edit in edits + ) / len(prose) + + +def apply_exact_edits( + prose: str, + edits: list[dict[str, Any]], + *, + max_change_ratio: float = 1.0, +) -> str: + if not edits: + raise ValueError("patch response contains no edits") + spans: list[tuple[int, int, str]] = [] + for index, edit in enumerate(edits, start=1): + old_text = str(edit.get("old_text") or "") + new_text = str(edit.get("new_text") or "") + if not old_text or not new_text or old_text == new_text: + raise ValueError(f"patch edit {index} is empty or unchanged") + occurrences = prose.count(old_text) + if occurrences != 1: + raise ValueError( + f"patch edit {index} old_text must occur exactly once; found {occurrences}" + ) + start = prose.index(old_text) + spans.append((start, start + len(old_text), new_text)) + ordered = sorted(spans) + for previous, current in zip(ordered, ordered[1:], strict=False): + if current[0] < previous[1]: + raise ValueError("patch edits overlap in the original chapter") + ratio = patch_change_ratio(prose, edits) + if ratio > max_change_ratio: + raise ValueError( + f"patch changes {ratio:.1%} of the chapter; limit is {max_change_ratio:.1%}" + ) + revised = prose + for start, end, new_text in reversed(ordered): + revised = revised[:start] + new_text + revised[end:] + return revised + + +class DjangoStoryWorkflowServices: + def __init__(self, router: ModelRouter) -> None: + self.router = router + self.writer = ResumableDraftWriter(router) + + def build_context(self, state: dict[str, Any]) -> dict[str, Any]: + revision = self._revision(state) + chapter = revision.chapter + pinned_canon_id = (revision.generation_metadata or {}).get("pinned_prior_canon_id") + if pinned_canon_id: + prior_canon = CanonSnapshot.objects.filter( + id=pinned_canon_id, + story=chapter.story, + through_chapter__lt=chapter.number, + ).first() + if prior_canon is None: + raise RuntimeError("pinned prior canon is unavailable or invalid for this chapter") + else: + prior_canon = ( + CanonSnapshot.objects.filter( + story=chapter.story, through_chapter__lt=chapter.number + ) + .order_by("-through_chapter", "-version") + .first() + ) + previous = ( + Chapter.objects.filter(story=chapter.story, number__lt=chapter.number) + .exclude(current_revision=None) + .select_related("current_revision") + .order_by("-number") + .first() + ) + chapter_outline = self._chapter_outline(revision) + content = { + "chapter": { + "number": chapter.number, + "title": chapter.title, + "outline": chapter_outline, + }, + "story_bible": revision.story_bible.content, + "structured_canon": revision.story_bible.structured_canon, + "prior_canon": prior_canon.state if prior_canon else {}, + "previous_chapter_tail": self._tail( + previous.current_revision.prose if previous and previous.current_revision else "" + ), + "source_revision": ( + revision.source_revision.prose + if revision.source_revision_id + else revision.parent.prose if revision.parent_id else "" + ), + } + canonical = json.dumps(content, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + snapshot = GenerationContextSnapshot.objects.create( + story=chapter.story, + chapter=chapter, + story_bible=revision.story_bible, + outline=revision.outline, + prior_canon=prior_canon, + content=content, + sha256=text_sha256(canonical), + ) + revision.context_snapshot = snapshot + revision.save(update_fields=["context_snapshot", "updated_at"]) + return {"context_snapshot_id": str(snapshot.id)} + + def plan_chapter(self, state: dict[str, Any]) -> dict[str, Any]: + revision = self._revision(state) + context = self._context(state, revision) + human_notes = str(state.get("human_notes") or "") + if revision.scene_plan and not human_notes: + self._ensure_contract(revision) + return {"scene_plan": revision.scene_plan} + prompt = self._render_prompt( + "STORY_PLAN", + DEFAULT_PLAN_SYSTEM, + DEFAULT_PLAN_TEMPLATE, + chapter_number=revision.chapter.number, + chapter_title=revision.chapter.title, + story_bible=context["story_bible"], + chapter_outline=json.dumps(context["chapter"]["outline"], ensure_ascii=False, indent=2), + prior_canon=json.dumps(context["prior_canon"], ensure_ascii=False, indent=2), + source_prose=context.get("source_revision", ""), + human_notes=human_notes, + ) + request = ModelRequestContract( + purpose=ModelCapability.STORY_PLANNING, + prompt=prompt, + model_hint="terra", + token_budget=5000, + project=revision.chapter.story.project, + ) + response = self.router.complete(request) + plan = extract_json_object(response.content) + if not isinstance(plan.get("scenes"), list) or not plan["scenes"]: + raise RuntimeError("story planner returned no scenes") + revision.scene_plan = plan + revision.generation_metadata = { + **revision.generation_metadata, + "planning_model": response.model, + } + revision.save(update_fields=["scene_plan", "generation_metadata", "updated_at"]) + self._ensure_contract(revision) + return {"scene_plan": plan, "human_notes": ""} + + def draft_chapter(self, state: dict[str, Any]) -> dict[str, Any]: + revision = self._revision(state) + if revision.prose: + return {"revision_id": str(revision.id)} + context = self._context(state, revision) + scenes = revision.scene_plan.get("scenes") or [] + if not scenes: + raise ValueError("approved scene plan has no scenes") + previous_chapter = ( + Chapter.objects.select_related("current_revision") + .filter(story=revision.chapter.story, number=revision.chapter.number - 1) + .first() + ) + source_chapter = ( + previous_chapter.current_revision.prose + if previous_chapter and previous_chapter.current_revision + else "[opening chapter; no previous chapter]" + ) + prompt = self._render_prompt( + "STORY_CHAPTER_PROSE", + DEFAULT_DRAFT_SYSTEM, + DEFAULT_FULL_CHAPTER_DRAFT_TEMPLATE, + chapter_number=revision.chapter.number, + chapter_title=revision.chapter.title, + source_chapter=source_chapter, + structured_canon=json.dumps(context["structured_canon"], ensure_ascii=False, indent=2), + scene_plan=json.dumps(compact_chapter_plan(revision.scene_plan), ensure_ascii=False, indent=2), + ) + partial_path = self._partial_path(revision) + result = self.writer.generate( + request=ModelRequestContract( + purpose=ModelCapability.STORY_PROSE, + prompt=prompt, + model_hint="terra", + token_budget=15000, + project=revision.chapter.story.project, + ), + partial_path=partial_path, + minimum_words=4000, + maximum_words=8000, + completion_marker="[[END_OF_CHAPTER]]", + max_attempts=1, + ) + revision.prose = result.text + atomic_write_text(partial_path, revision.prose) + revision.artifact_uri = str(partial_path) + revision.status = RevisionStatus.REVIEW + revision.generation_metadata = { + **revision.generation_metadata, + "draft_attempts": result.attempts, + "resumed": result.resumed, + "draft_model": "terra", + "draft_mode": "full_chapter", + } + revision.save() + revision.chapter.status = ChapterStatus.REVIEW + revision.chapter.save(update_fields=["status", "updated_at"]) + return {"revision_id": str(revision.id)} + + def quality_review(self, state: dict[str, Any]) -> dict[str, Any]: + revision = self._revision(state) + previous = ( + Chapter.objects.select_related("current_revision") + .filter(story=revision.chapter.story, number=revision.chapter.number - 1) + .first() + ) + prompt = self._render_prompt( + "STORY_QUALITY_REVIEW", + "You are a rigorous developmental and continuity editor. Return strict JSON only.", + DEFAULT_QUALITY_REVIEW_TEMPLATE, + previous_chapter=( + previous.current_revision.prose + if previous and previous.current_revision + else "[opening chapter]" + ), + scene_plan=json.dumps(revision.scene_plan, ensure_ascii=False, indent=2), + prose=revision.prose, + ) + response = self.router.complete( + ModelRequestContract( + purpose=ModelCapability.STORY_REVIEW, + prompt=prompt, + model_hint="terra", + token_budget=5000, + project=revision.chapter.story.project, + ) + ) + reviewed = extract_json_object(response.content) + revision.findings.filter( + review_kind="quality_finish", status=FindingStatus.OPEN + ).update(status=FindingStatus.RESOLVED) + finding_ids: list[str] = [] + allowed_severities = { + FindingSeverity.MEDIUM, + FindingSeverity.HIGH, + FindingSeverity.CRITICAL, + } + occupied_ranges: list[tuple[int, int]] = [] + for item in (reviewed.get("findings") or [])[:6]: + quote = str(item.get("evidence_quote") or "") + replacement = str(item.get("suggested_revision") or "") + if not quote or revision.prose.count(quote) != 1 or not replacement: + continue + start = revision.prose.index(quote) + end = start + len(quote) + if any(start < right and left < end for left, right in occupied_ranges): + continue + occupied_ranges.append((start, end)) + severity = str(item.get("severity") or FindingSeverity.MEDIUM).upper() + if severity not in allowed_severities: + severity = FindingSeverity.MEDIUM + finding = EditorialFinding.objects.create( + revision=revision, + review_kind="quality_finish", + severity=severity, + category=str(item.get("category") or "quality")[:80], + location=str(item.get("location") or "")[:255], + description=str(item.get("description") or "Material chapter defect"), + suggested_revision=replacement, + evidence={ + "quote": quote, + "blocking": severity in [FindingSeverity.HIGH, FindingSeverity.CRITICAL], + "objective": True, + "exact_patch_suitable": bool(item.get("exact_patch_suitable", True)), + }, + model_metadata={"model": response.model, "review_phase": "quality_finish"}, + ) + finding_ids.append(str(finding.id)) + return {"editorial_finding_ids": finding_ids, "quality_finding_count": len(finding_ids)} + + def extract_continuity(self, state: dict[str, Any]) -> dict[str, Any]: + return self._extract_state(state, final=False) + + def extract_final_state(self, state: dict[str, Any]) -> dict[str, Any]: + return self._extract_state(state, final=True) + + def _extract_state(self, state: dict[str, Any], *, final: bool) -> dict[str, Any]: + revision = self._revision(state) + context = self._context(state, revision) + prompt = self._render_prompt( + "STORY_FINAL_STATE" if final else "STORY_CONTINUITY", + "Return strict factual JSON only. Never repair or invent story facts.", + DEFAULT_FINAL_STATE_TEMPLATE if final else DEFAULT_CONTINUITY_TEMPLATE, + chapter_number=revision.chapter.number, + scene_plan=json.dumps(revision.scene_plan, ensure_ascii=False, indent=2), + prior_canon=json.dumps(context["prior_canon"], ensure_ascii=False, indent=2), + prose=revision.prose, + ) + request = ModelRequestContract( + purpose=ModelCapability.STORY_CONTINUITY, + prompt=prompt, + model_hint="luna", + token_budget=7000 if final else 12000, + project=revision.chapter.story.project, + ) + response = self.router.complete(request) + extracted = extract_json_object(response.content) + observed_state = extracted.get("state_document") or {} + proposed_delta = extracted.get("changes") or [] + objective_findings = deterministic_temporal_findings(revision.prose, revision.scene_plan) + if not final: + objective_findings.extend(extracted.get("objective_findings") or []) + if not isinstance(observed_state, dict) or not isinstance(proposed_delta, list): + raise RuntimeError("continuity extraction returned an invalid state document") + if not isinstance(objective_findings, list): + raise RuntimeError("continuity extraction returned invalid objective findings") + contract = self._ensure_contract(revision) + state_document, _ = ChapterStateDocument.objects.update_or_create( + revision=revision, + defaults={ + "contract": contract, + "status": StateDocumentStatus.EXTRACTED, + "start_state": context["prior_canon"], + "observed_state": observed_state, + "proposed_delta": proposed_delta, + "coverage": {}, + "verdict": "", + "sha256": json_sha256(observed_state), + "model_metadata": {"model": response.model}, + "validated_at": None, + "committed_at": None, + }, + ) + state_document.changes.all().delete() + state_document.requirement_checks.all().delete() + normalized_delta = self._persist_state_changes(state_document, proposed_delta) + state_document.proposed_delta = normalized_delta + state_document.save(update_fields=["proposed_delta", "updated_at"]) + revision.continuity_state = observed_state + revision.generation_metadata = { + **revision.generation_metadata, + "continuity_model": response.model, + } + revision.save(update_fields=["continuity_state", "generation_metadata", "updated_at"]) + revision.findings.filter( + review_kind="continuity_audit", status=FindingStatus.OPEN + ).update(status=FindingStatus.RESOLVED) + finding_ids = [] + allowed_severities = { + FindingSeverity.MEDIUM, + FindingSeverity.HIGH, + FindingSeverity.CRITICAL, + } + occupied_ranges: list[tuple[int, int]] = [] + for item in objective_findings[:8]: + evidence_quote = str(item.get("evidence_quote") or "") + if not evidence_quote or revision.prose.count(evidence_quote) != 1: + continue + start = revision.prose.index(evidence_quote) + end = start + len(evidence_quote) + if any(start < occupied_end and occupied_start < end for occupied_start, occupied_end in occupied_ranges): + continue + occupied_ranges.append((start, end)) + severity = str(item.get("severity") or FindingSeverity.MEDIUM).upper() + if severity not in allowed_severities: + severity = FindingSeverity.MEDIUM + finding = EditorialFinding.objects.create( + revision=revision, + review_kind="continuity_audit", + severity=severity, + category=str(item.get("category") or "continuity")[:80], + location=str(item.get("location") or "")[:255], + description=str(item.get("description") or "Objective continuity defect"), + suggested_revision=str(item.get("suggested_revision") or ""), + evidence={ + "quote": evidence_quote, + "objective": bool(item.get("objective", True)), + "exact_patch_suitable": bool(item.get("exact_patch_suitable", True)), + "blocking": True, + }, + model_metadata={ + "model": response.model, + "source": item.get("source") or "combined_continuity_audit", + }, + ) + finding_ids.append(str(finding.id)) + self._write_state_artifacts(state_document) + return { + "state_document_id": str(state_document.id), + "editorial_finding_ids": finding_ids, + "objective_finding_count": len(finding_ids), + } + + def finalize_combined_audit(self, state: dict[str, Any]) -> dict[str, Any]: + revision = self._revision(state) + state_document = ChapterStateDocument.objects.get(revision=revision) + revision.findings.filter( + review_kind="state_contract", status=FindingStatus.OPEN + ).update(status=FindingStatus.RESOLVED) + failed = False + for error in self._validate_state_change_evidence(state_document, revision.prose): + failed = True + EditorialFinding.objects.create( + revision=revision, + review_kind="state_contract", + severity=FindingSeverity.HIGH, + category="state_change", + location=error.get("location", ""), + description=error["description"], + suggested_revision="Correct or remove the unsupported state transition.", + evidence={**error, "blocking": True, "objective": True}, + model_metadata={"source": "deterministic"}, + ) + if not state_document.proposed_delta: + failed = True + EditorialFinding.objects.create( + revision=revision, + review_kind="state_contract", + severity=FindingSeverity.HIGH, + category="state_change", + description="The chapter state extraction produced no trackable state changes.", + suggested_revision="Extract the chapter's material state changes.", + evidence={"blocking": True, "objective": True}, + model_metadata={"source": "deterministic"}, + ) + open_findings = revision.findings.filter(status=FindingStatus.OPEN) + failed = failed or open_findings.filter( + severity__in=[FindingSeverity.HIGH, FindingSeverity.CRITICAL] + ).exists() + verdict = "FAIL" if failed else "PASS" + state_document.coverage = { + "counts": { + "objective_findings": open_findings.filter( + review_kind__in=["continuity_audit", "state_contract"] + ).count() + } + } + state_document.verdict = verdict + state_document.status = ( + StateDocumentStatus.NEEDS_REVISION if failed else StateDocumentStatus.VALIDATED + ) + state_document.validated_at = None if failed else timezone.now() + state_document.model_metadata = { + **state_document.model_metadata, + "validation": "combined_luna_and_deterministic", + } + state_document.save() + state_document.changes.exclude(status=StateChangeStatus.REJECTED).update( + status=StateChangeStatus.PROPOSED if failed else StateChangeStatus.VALIDATED + ) + self._write_state_artifacts(state_document) + finding_ids = [str(value) for value in open_findings.values_list("id", flat=True)] + return { + "state_document_id": str(state_document.id), + "state_judge_status": "revise" if failed else "pass", + "editorial_finding_ids": finding_ids, + } + + def judge_state_contract(self, state: dict[str, Any]) -> dict[str, Any]: + revision = self._revision(state) + contract = self._ensure_contract(revision) + state_document = ChapterStateDocument.objects.get(revision=revision) + state_document.changes.filter(metadata__source="judge").delete() + state_document.proposed_delta = [ + item + for item in state_document.proposed_delta + if not isinstance(item, dict) or item.get("source") != "judge" + ] + invalid_sequences: set[int] = set() + extraction_changes = state_document.changes.filter( + Q(metadata__source="extraction") | Q(metadata__source__isnull=True) + ) + for change in extraction_changes: + if evidence_is_present(revision.prose, change.evidence_quote): + change.status = StateChangeStatus.PROPOSED + else: + change.status = StateChangeStatus.REJECTED + invalid_sequences.add(change.sequence) + change.save(update_fields=["status"]) + state_document.proposed_delta = [ + item + for item in state_document.proposed_delta + if not isinstance(item, dict) or item.get("sequence") not in invalid_sequences + ] + state_document.save(update_fields=["proposed_delta", "updated_at"]) + revision.findings.filter( + review_kind__in=["state_contract", "story_audit"], + status=FindingStatus.OPEN, + ).update(status=FindingStatus.RESOLVED) + prompt = self._render_prompt( + "STORY_STATE_JUDGE", + "You are the final story contract judge. Return strict JSON only.", + DEFAULT_STATE_JUDGE_TEMPLATE, + contract=json.dumps(contract.requirements, ensure_ascii=False, indent=2), + prior_state=json.dumps(state_document.start_state, ensure_ascii=False, indent=2), + observed_state=json.dumps(state_document.observed_state, ensure_ascii=False, indent=2), + proposed_delta=json.dumps(state_document.proposed_delta, ensure_ascii=False, indent=2), + scene_plan=json.dumps(revision.scene_plan, ensure_ascii=False, indent=2), + prose=revision.prose, + ) + request = ModelRequestContract( + purpose=ModelCapability.STORY_REVIEW, + prompt=prompt, + model_hint="terra", + token_budget=max(6000, len(contract.requirements) * 120), + project=revision.chapter.story.project, + ) + response = self.router.complete(request) + judged = extract_json_object(response.content) + missing_changes = judged.get("missing_state_changes") or [] + if isinstance(missing_changes, list) and missing_changes: + appended = self._persist_state_changes( + state_document, + missing_changes, + start_sequence=state_document.changes.count() + 1, + source="judge", + ) + state_document.proposed_delta = [*state_document.proposed_delta, *appended] + state_document.save(update_fields=["proposed_delta", "updated_at"]) + returned = { + str(item.get("requirement_id") or ""): item + for item in judged.get("requirements") or [] + if item.get("requirement_id") + } + state_document.requirement_checks.all().delete() + finding_ids: list[str] = [] + coverage: list[dict[str, Any]] = [] + allowed_statuses = set(RequirementStatus.values) + failed = False + for requirement in contract.requirements: + requirement_id = requirement["id"] + item = returned.get(requirement_id) or { + "status": RequirementStatus.MISSED, + "details": "Judge omitted this frozen requirement.", + } + status = str(item.get("status") or RequirementStatus.UNVERIFIABLE).upper() + if status not in allowed_statuses: + status = RequirementStatus.UNVERIFIABLE + quote = str(item.get("evidence_quote") or "").strip() + details = str(item.get("details") or "") + if status in [RequirementStatus.HIT, RequirementStatus.PARTIAL] and not evidence_is_present( + revision.prose, quote + ): + status = RequirementStatus.UNVERIFIABLE + details = (details + " Evidence quote is absent from the chapter.").strip() + check = RequirementCheck.objects.create( + state_document=state_document, + requirement_id=requirement_id, + requirement_type=requirement["type"], + requirement_text=requirement["text"], + status=status, + severity=requirement["severity"], + evidence_quote=quote, + evidence_location=str(item.get("evidence_location") or "")[:255], + details=details, + model_metadata={"model": response.model}, + ) + coverage.append( + { + "requirement_id": check.requirement_id, + "requirement_type": check.requirement_type, + "requirement_text": check.requirement_text, + "status": check.status, + "severity": check.severity, + "evidence_quote": check.evidence_quote, + "evidence_location": check.evidence_location, + "details": check.details, + "blocking": requirement_is_blocking(requirement), + } + ) + if status != RequirementStatus.HIT: + blocking = requirement_is_blocking(requirement) + failed = failed or blocking + finding_severity = ( + FindingSeverity.MEDIUM + if not blocking or status == RequirementStatus.PARTIAL + else requirement["severity"] + ) + finding = EditorialFinding.objects.create( + revision=revision, + review_kind="state_contract", + severity=finding_severity, + category=f"contract:{requirement['type']}"[:80], + location=check.evidence_location, + description=f"{requirement_id} is {status}: {requirement['text']}. {details}".strip(), + suggested_revision="Revise the chapter so this frozen requirement is explicitly satisfied.", + evidence={ + "quote": quote, + "requirement_id": requirement_id, + "blocking": blocking, + "objective": True, + "exact_patch_suitable": True, + }, + model_metadata={"model": response.model, "review_phase": "initial"}, + ) + finding_ids.append(str(finding.id)) + allowed_severities = set(FindingSeverity.values) + for item in (judged.get("findings") or [])[:8]: + severity = str(item.get("severity") or FindingSeverity.INFO).upper() + if severity not in allowed_severities: + severity = FindingSeverity.INFO + quote = str(item.get("evidence_quote") or "").strip() + objective = bool(item.get("objective")) and severity != FindingSeverity.LOW + patch_suitable = bool(item.get("exact_patch_suitable")) and objective + finding = EditorialFinding.objects.create( + revision=revision, + review_kind="story_audit", + severity=severity, + category=str(item.get("category") or "editorial")[:80], + location=str(item.get("location") or "")[:255], + description=str(item.get("description") or "No description supplied"), + suggested_revision=str(item.get("suggested_revision") or ""), + evidence={ + "quote": quote, + "blocking": objective and severity in [FindingSeverity.HIGH, FindingSeverity.CRITICAL], + "objective": objective, + "exact_patch_suitable": patch_suitable, + }, + model_metadata={"model": response.model, "review_phase": "initial"}, + ) + finding_ids.append(str(finding.id)) + change_errors = self._validate_state_change_evidence(state_document, revision.prose) + for error in change_errors: + failed = True + finding = EditorialFinding.objects.create( + revision=revision, + review_kind="state_contract", + severity=FindingSeverity.HIGH, + category="state_change", + location=error.get("location", ""), + description=error["description"], + suggested_revision="Correct or remove the unsupported state transition.", + evidence=error, + model_metadata={"model": response.model}, + ) + finding_ids.append(str(finding.id)) + if not state_document.proposed_delta: + failed = True + finding = EditorialFinding.objects.create( + revision=revision, + review_kind="state_contract", + severity=FindingSeverity.HIGH, + category="state_change", + description="The chapter state extraction produced no trackable state changes.", + suggested_revision="Extract the chapter's character, item, money, and timeline changes.", + model_metadata={"model": response.model}, + ) + finding_ids.append(str(finding.id)) + verdict = "FAIL" if failed else "PASS" + state_document.coverage = { + "requirements": coverage, + "counts": { + status: sum(1 for item in coverage if item["status"] == status) + for status in RequirementStatus.values + }, + } + state_document.verdict = verdict + state_document.status = ( + StateDocumentStatus.NEEDS_REVISION if verdict == "FAIL" else StateDocumentStatus.VALIDATED + ) + state_document.validated_at = timezone.now() if verdict == "PASS" else None + state_document.model_metadata = { + **state_document.model_metadata, + "judge_model": response.model, + } + state_document.save() + state_document.changes.exclude(status=StateChangeStatus.REJECTED).update( + status=( + StateChangeStatus.VALIDATED + if verdict == "PASS" + else StateChangeStatus.PROPOSED + ) + ) + self._write_state_artifacts(state_document) + return { + "state_document_id": str(state_document.id), + "state_judge_status": "revise" if verdict == "FAIL" else "pass", + "editorial_finding_ids": finding_ids, + } + + def decide_patch(self, state: dict[str, Any]) -> dict[str, Any]: + if state.get("patch_attempted"): + return {"patch_decision": "human_review", "patch_finding_ids": []} + revision = self._revision(state) + candidates: list[str] = [] + for finding in revision.findings.filter(status=FindingStatus.OPEN).order_by("created_at"): + evidence = finding.evidence or {} + objective = bool(evidence.get("objective")) + suitable = bool(evidence.get("exact_patch_suitable")) + if finding.review_kind == "state_contract" and not evidence.get("blocking"): + continue + severity_ok = finding.severity in [ + FindingSeverity.MEDIUM, + FindingSeverity.HIGH, + FindingSeverity.CRITICAL, + ] + if objective and suitable and severity_ok and finding.category != "state_change": + candidates.append(str(finding.id)) + return { + "patch_decision": "patch" if candidates else "human_review", + "patch_finding_ids": candidates[:8], + } + + def apply_automatic_patch(self, state: dict[str, Any]) -> dict[str, Any]: + current = self._revision(state) + finding_ids = state.get("patch_finding_ids") or [] + findings = list( + current.findings.filter(id__in=finding_ids, status=FindingStatus.OPEN).values( + "id", "severity", "category", "location", "description", "suggested_revision" + ) + ) + if not findings: + return {"patch_attempted": True, "patch_status": "failed"} + base_prompt = self._render_prompt( + "STORY_PATCH_REVISION", + "You are a precise fiction copy editor. Return strict JSON only.", + DEFAULT_PATCH_REVISION_TEMPLATE, + findings=json.dumps(findings, ensure_ascii=False, indent=2, default=str), + human_notes="", + prose=current.prose, + ) + try: + response = self.router.complete( + ModelRequestContract( + purpose=ModelCapability.STORY_REVISION, + prompt=base_prompt, + model_hint="luna", + token_budget=4000, + project=current.chapter.story.project, + ) + ) + patch = extract_json_object(response.content) + edits = patch.get("edits") or [] + revised_prose = apply_exact_edits(current.prose, edits, max_change_ratio=0.05) + except (ProviderError, ValueError): + return { + "patch_attempted": True, + "patch_status": "failed", + "patch_source_revision_id": str(current.id), + } + revised = ChapterRevision.objects.create( + chapter=current.chapter, + revision=(current.chapter.revisions.aggregate(value=Max("revision"))["value"] or 0) + 1, + status=RevisionStatus.REVIEW, + parent=current, + source_revision=current.source_revision, + story_bible=current.story_bible, + outline=current.outline, + context_snapshot=current.context_snapshot, + scene_plan=current.scene_plan, + graph_thread_id=current.graph_thread_id, + prose=revised_prose, + ) + changed_passages = [ + { + "old_text": str(edit.get("old_text") or ""), + "new_text": str(edit.get("new_text") or ""), + } + for edit in edits + ] + ratio = patch_change_ratio(current.prose, edits) + revised.artifact_uri = str(self._partial_path(revised)) + revised.generation_metadata = { + "revision_mode": "bounded_exact_patch", + "graph_run_id": state.get("graph_run_id"), + "base_revision_id": str(current.id), + "finding_ids": [str(item["id"]) for item in findings], + "patch_generation_attempts": 1, + "patch_count": len(edits), + "patch_change_ratio": ratio, + "changed_passages": changed_passages, + "model": response.model, + } + atomic_write_text(Path(revised.artifact_uri), revised.prose) + revised.save() + current.findings.filter(id__in=finding_ids).update(status=FindingStatus.RESOLVED) + return { + "revision_id": str(revised.id), + "patch_attempted": True, + "patch_status": "applied", + "patch_source_revision_id": str(current.id), + "patch_change_ratio": ratio, + "changed_passages": changed_passages, + } + + def verify_patch(self, state: dict[str, Any]) -> dict[str, Any]: + revision = self._revision(state) + parent = ChapterRevision.objects.get(id=state["patch_source_revision_id"]) + contract = self._ensure_contract(revision) + state_document = ChapterStateDocument.objects.get(revision=revision) + parent_document = ChapterStateDocument.objects.get(revision=parent) + findings = list( + parent.findings.filter(id__in=state.get("patch_finding_ids") or []).values( + "id", "severity", "category", "location", "description", "suggested_revision" + ) + ) + finding_requirement_ids = { + str((finding.evidence or {}).get("requirement_id")) + for finding in parent.findings.filter(id__in=state.get("patch_finding_ids") or []) + if (finding.evidence or {}).get("requirement_id") + } + requirements_to_verify = [ + item + for item in contract.requirements + if requirement_is_blocking(item) or item["id"] in finding_requirement_ids + ] + prompt = self._render_prompt( + "STORY_TARGETED_VERIFICATION", + "You are a narrow regression verifier. Return strict JSON only.", + DEFAULT_TARGETED_VERIFICATION_TEMPLATE, + findings=json.dumps(findings, ensure_ascii=False, indent=2, default=str), + requirements=json.dumps(requirements_to_verify, ensure_ascii=False, indent=2), + changed_passages=json.dumps(state.get("changed_passages") or [], ensure_ascii=False, indent=2), + prose=revision.prose, + ) + request = ModelRequestContract( + purpose=ModelCapability.STORY_REVIEW, + prompt=prompt, + model_hint="luna", + token_budget=max(4000, len(requirements_to_verify) * 100), + project=revision.chapter.story.project, + ) + response = self.router.complete(request) + verified = extract_json_object(response.content) + requirement_results = { + str(item.get("requirement_id") or ""): item + for item in verified.get("requirement_results") or [] + } + finding_results = { + str(item.get("finding_id") or ""): item + for item in verified.get("finding_results") or [] + } + parent_checks = { + check.requirement_id: check for check in parent_document.requirement_checks.all() + } + state_document.requirement_checks.all().delete() + coverage: list[dict[str, Any]] = [] + blocking_failed = False + for requirement in contract.requirements: + source = requirement_results.get(requirement["id"]) + parent_check = parent_checks.get(requirement["id"]) + status = str( + (source or {}).get("status") + or (parent_check.status if parent_check else RequirementStatus.UNVERIFIABLE) + ).upper() + if status not in set(RequirementStatus.values): + status = RequirementStatus.UNVERIFIABLE + quote = str( + (source or {}).get("evidence_quote") + or (parent_check.evidence_quote if parent_check else "") + ).strip() + details = str( + (source or {}).get("details") + or (parent_check.details if parent_check else "Verifier omitted requirement.") + ) + if status in [RequirementStatus.HIT, RequirementStatus.PARTIAL] and not evidence_is_present( + revision.prose, quote + ): + status = RequirementStatus.UNVERIFIABLE + details = (details + " Evidence quote is absent from the revised chapter.").strip() + check = RequirementCheck.objects.create( + state_document=state_document, + requirement_id=requirement["id"], + requirement_type=requirement["type"], + requirement_text=requirement["text"], + status=status, + severity=requirement["severity"], + evidence_quote=quote, + evidence_location=str((source or {}).get("evidence_location") or "")[:255], + details=details, + model_metadata={"model": response.model, "review_phase": "targeted"}, + ) + blocking = requirement_is_blocking(requirement) + blocking_failed = blocking_failed or (blocking and status != RequirementStatus.HIT) + coverage.append( + { + "requirement_id": check.requirement_id, + "requirement_type": check.requirement_type, + "requirement_text": check.requirement_text, + "status": check.status, + "severity": check.severity, + "evidence_quote": check.evidence_quote, + "evidence_location": check.evidence_location, + "details": check.details, + "blocking": blocking, + } + ) + unresolved_ids: list[str] = [] + for finding in parent.findings.filter(id__in=state.get("patch_finding_ids") or []): + result = finding_results.get(str(finding.id)) or {} + if str(result.get("status") or "UNRESOLVED").upper() == "RESOLVED": + finding.status = FindingStatus.RESOLVED + finding.save(update_fields=["status", "updated_at"]) + continue + unresolved_ids.append(str(finding.id)) + EditorialFinding.objects.create( + revision=revision, + review_kind="targeted_verification", + severity=finding.severity, + category=finding.category, + location=finding.location, + description=finding.description, + suggested_revision=finding.suggested_revision, + evidence={**(finding.evidence or {}), "source_finding_id": str(finding.id)}, + model_metadata={"model": response.model, "review_phase": "targeted"}, + ) + change_errors = self._validate_state_change_evidence(state_document, revision.prose) + failed = blocking_failed or bool(unresolved_ids) or bool(change_errors) or not state_document.proposed_delta + verdict = "FAIL" if failed else "PASS" + state_document.coverage = { + "requirements": coverage, + "counts": { + status: sum(1 for item in coverage if item["status"] == status) + for status in RequirementStatus.values + }, + } + state_document.verdict = verdict + state_document.status = ( + StateDocumentStatus.NEEDS_REVISION if failed else StateDocumentStatus.VALIDATED + ) + state_document.validated_at = None if failed else timezone.now() + state_document.model_metadata = { + **state_document.model_metadata, + "verification_model": response.model, + "verification_scope": "targeted", + } + state_document.save() + state_document.changes.exclude(status=StateChangeStatus.REJECTED).update( + status=StateChangeStatus.PROPOSED if failed else StateChangeStatus.VALIDATED + ) + self._write_state_artifacts(state_document) + return { + "state_document_id": str(state_document.id), + "verification_status": "fail" if failed else "pass", + } + + def review_chapter(self, state: dict[str, Any], review_kind: str) -> list[str]: + revision = self._revision(state) + context = self._context(state, revision) + prompt = self._render_prompt( + f"STORY_REVIEW_{review_kind.upper()}", + "You are an independent fiction editor. Return strict JSON only.", + DEFAULT_REVIEW_TEMPLATE, + review_kind=review_kind, + context=json.dumps(context, ensure_ascii=False, indent=2), + scene_plan=json.dumps(revision.scene_plan, ensure_ascii=False, indent=2), + prose=revision.prose, + ) + response = self.router.complete( + ModelRequestContract( + purpose=ModelCapability.STORY_REVIEW, + prompt=prompt, + model_hint="terra", + token_budget=5000, + project=revision.chapter.story.project, + ) + ) + data = extract_json_object(response.content) + finding_ids: list[str] = [] + allowed = set(FindingSeverity.values) + for item in data.get("findings") or []: + severity = str(item.get("severity", "INFO")).upper() + if severity not in allowed: + severity = FindingSeverity.INFO + finding = EditorialFinding.objects.create( + revision=revision, + review_kind=review_kind, + severity=severity, + category=str(item.get("category") or review_kind)[:80], + location=str(item.get("location") or "")[:255], + description=str(item.get("description") or "No description supplied"), + suggested_revision=str(item.get("suggested_revision") or ""), + model_metadata={"model": response.model}, + ) + finding_ids.append(str(finding.id)) + return finding_ids + + def judge_chapter(self, state: dict[str, Any]) -> str: + revision = self._revision(state) + serious = revision.findings.filter( + status="OPEN", severity__in=[FindingSeverity.HIGH, FindingSeverity.CRITICAL] + ).exists() + attempts = int(state.get("revision_attempt") or 0) + maximum = int(state.get("max_revisions") or 2) + if serious and attempts < maximum: + return "revise" + return "human_review" + + def revise_chapter(self, state: dict[str, Any]) -> dict[str, Any]: + current = self._revision(state) + findings = list( + current.findings.filter(status="OPEN").values( + "severity", "category", "location", "description", "suggested_revision" + ) + ) + next_number = ( + current.chapter.revisions.aggregate(value=Max("revision"))["value"] or 0 + ) + 1 + revised = ChapterRevision.objects.create( + chapter=current.chapter, + revision=next_number, + status=RevisionStatus.DRAFT, + parent=current, + source_revision=current.source_revision or current, + story_bible=current.story_bible, + outline=current.outline, + context_snapshot=current.context_snapshot, + scene_plan=current.scene_plan, + graph_thread_id=current.graph_thread_id, + ) + structural = any( + finding["severity"] in [FindingSeverity.HIGH, FindingSeverity.CRITICAL] + for finding in findings + ) + if findings and not structural: + base_prompt = self._render_prompt( + "STORY_PATCH_REVISION", + "You are a precise fiction copy editor. Return strict JSON only.", + DEFAULT_PATCH_REVISION_TEMPLATE, + findings=json.dumps(findings, ensure_ascii=False, indent=2), + human_notes=str(state.get("human_notes") or ""), + prose=current.prose, + ) + try: + response = self.router.complete(ModelRequestContract( + purpose=ModelCapability.STORY_REVISION, + prompt=base_prompt, + model_hint="luna", + token_budget=4000, + project=current.chapter.story.project, + )) + patch = extract_json_object(response.content) + edits = patch.get("edits") or [] + revised.prose = apply_exact_edits(current.prose, edits) + except (ProviderError, ValueError): + revised.status = RevisionStatus.REJECTED + revised.save(update_fields=["status", "updated_at"]) + raise + revised.artifact_uri = str(self._partial_path(revised)) + revised.status = RevisionStatus.REVIEW + revised.generation_metadata = { + "revision_attempt": int(state.get("revision_attempt") or 0) + 1, + "revision_mode": "exact_patch", + "patch_count": len(edits), + "model": response.model, + } + atomic_write_text(Path(revised.artifact_uri), revised.prose) + revised.save() + return { + "revision_id": str(revised.id), + "revision_attempt": int(state.get("revision_attempt") or 0) + 1, + } + context = self._context(state, current) + repair_prompt = self._render_prompt( + "STORY_REPAIR_PLAN", + "You are a developmental story architect. Return one valid JSON object only.", + DEFAULT_REPAIR_PLAN_TEMPLATE, + context=json.dumps(context, ensure_ascii=False, indent=2), + scene_plan=json.dumps(current.scene_plan, ensure_ascii=False, indent=2), + findings=json.dumps(findings, ensure_ascii=False, indent=2), + prose=current.prose, + ) + repair_response = self.router.complete( + ModelRequestContract( + purpose=ModelCapability.STORY_PLANNING, + prompt=repair_prompt, + model_hint="terra", + token_budget=5000, + project=current.chapter.story.project, + ) + ) + repair_plan = extract_json_object(repair_response.content) + revision_context = { + "chapter": context["chapter"], + "structured_canon": context["structured_canon"], + "prior_canon": context["prior_canon"], + "previous_chapter_tail": context["previous_chapter_tail"], + } + prompt = self._render_prompt( + "STORY_REVISION", + DEFAULT_DRAFT_SYSTEM, + DEFAULT_REVISION_TEMPLATE, + context=json.dumps(revision_context, ensure_ascii=False, indent=2), + scene_plan=json.dumps(current.scene_plan, ensure_ascii=False, indent=2), + findings=json.dumps(findings, ensure_ascii=False, indent=2), + repair_plan=json.dumps(repair_plan, ensure_ascii=False, indent=2), + prose=current.prose, + ) + result = self.writer.generate( + request=ModelRequestContract( + purpose=ModelCapability.STORY_REVISION, + prompt=prompt, + model_hint="terra", + token_budget=15000, + project=current.chapter.story.project, + ), + partial_path=self._partial_path(revised), + ) + revised.prose = result.text + revised.artifact_uri = str(self._partial_path(revised)) + revised.status = RevisionStatus.REVIEW + revised.generation_metadata = { + "revision_attempt": int(state.get("revision_attempt") or 0) + 1, + "draft_attempts": result.attempts, + "resumed": result.resumed, + } + revised.save() + return { + "revision_id": str(revised.id), + "revision_attempt": int(state.get("revision_attempt") or 0) + 1, + } + + def ensure_approval( + self, state: dict[str, Any], gate: str, payload: dict[str, Any] + ) -> GraphApproval: + revision = self._revision(state) + reason = f"{gate}:{revision.id}" + approval = GraphApproval.objects.filter( + graph_run_id=state["graph_run_id"], reason=reason, status=GraphApprovalStatus.PENDING + ).first() + if approval is None: + approval = GraphApproval.objects.create( + graph_run_id=state["graph_run_id"], reason=reason, payload=payload + ) + return approval + + def decide_approval( + self, approval_id: int, decision: dict[str, Any] + ) -> None: + approval = GraphApproval.objects.get(id=approval_id) + action = str(decision.get("action") or "").lower() + approval.status = ( + GraphApprovalStatus.APPROVED if action == "approve" else GraphApprovalStatus.REJECTED + ) + approval.decided_by = str(decision.get("actor") or "human") + approval.decided_at = timezone.now() + approval.payload = {**approval.payload, "decision": decision} + approval.save( + update_fields=["status", "decided_by", "decided_at", "payload", "updated_at"] + ) + if action == "approve" and approval.reason.startswith("STORY_PLAN_APPROVAL:"): + revision_id = approval.payload.get("revision_id") + if revision_id: + contract = self._ensure_contract(ChapterRevision.objects.get(id=revision_id)) + contract.approved_at = timezone.now() + contract.save(update_fields=["approved_at", "updated_at"]) + + @transaction.atomic + def commit_chapter(self, state: dict[str, Any]) -> dict[str, Any]: + revision = ChapterRevision.objects.select_for_update().select_related( + "chapter__story" + ).get(id=state["revision_id"]) + chapter = revision.chapter + state_document = ChapterStateDocument.objects.select_for_update().get(revision=revision) + if ( + state_document.status != StateDocumentStatus.VALIDATED + or state_document.verdict != "PASS" + ): + raise RuntimeError("chapter state document must pass contract validation before approval") + if revision.findings.filter( + status=FindingStatus.OPEN, + severity__in=[FindingSeverity.HIGH, FindingSeverity.CRITICAL], + ).exists(): + raise RuntimeError("chapter has unresolved blocking editorial findings") + revision.status = RevisionStatus.APPROVED + revision.approved_at = timezone.now() + revision.save(update_fields=["status", "approved_at", "updated_at"]) + chapter.current_revision = revision + chapter.status = ChapterStatus.APPROVED + chapter.save(update_fields=["current_revision", "status", "updated_at"]) + latest_version = ( + CanonSnapshot.objects.filter(story=chapter.story).aggregate(value=Max("version"))["value"] + or 0 + ) + prior_snapshot = ( + CanonSnapshot.objects.filter(story=chapter.story, through_chapter__lt=chapter.number) + .order_by("-through_chapter", "-version") + .first() + ) + changes = [ + { + "sequence": change.sequence, + "entity_key": change.entity.entity_key if change.entity else "book.state", + "entity_kind": change.entity.kind if change.entity else "book", + "canonical_name": change.entity.canonical_name if change.entity else "Book State", + "related_entity_key": ( + change.related_entity.entity_key if change.related_entity else "" + ), + "predicate": change.predicate, + "operation": change.operation, + "previous_value": change.previous_value, + "new_value": change.new_value, + } + for change in state_document.changes.filter( + status=StateChangeStatus.VALIDATED + ).select_related("entity", "related_entity").order_by("sequence") + ] + state_value = apply_state_changes( + prior_snapshot.state if prior_snapshot else {}, + changes, + through_chapter=chapter.number, + chapter_state=state_document.observed_state, + ) + canonical = json.dumps(state_value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + snapshot = CanonSnapshot.objects.create( + story=chapter.story, + through_chapter=chapter.number, + version=latest_version + 1, + state=state_value, + source_revision=revision, + sha256=text_sha256(canonical), + ) + state_document.status = StateDocumentStatus.COMMITTED + state_document.committed_at = timezone.now() + state_document.save(update_fields=["status", "committed_at", "updated_at"]) + state_document.changes.filter(status=StateChangeStatus.VALIDATED).update( + status=StateChangeStatus.COMMITTED + ) + self._write_state_artifacts(state_document) + return {"canon_snapshot_id": str(snapshot.id)} + + def publish_story(self, state: dict[str, Any]) -> str: + story = StoryProject.objects.get(id=state["story_id"]) + chapters = [ + {"title": f"Chapter {chapter.number}: {chapter.title}", "content": chapter.current_revision.prose} + for chapter in story.chapters.exclude(current_revision=None) + .select_related("current_revision") + .order_by("number") + ] + destination = self._artifact_root(story) / f"{story.slug}-current.epub" + write_epub( + title=story.title, + series=story.series, + chapters=chapters, + destination=destination, + ) + return str(destination) + + def state_approval_payload(self, state: dict[str, Any]) -> dict[str, Any]: + revision = self._revision(state) + document = ChapterStateDocument.objects.filter(revision=revision).first() + if document is None: + return {} + source_revision_id = state.get("patch_source_revision_id") + finding_revisions = [revision.id] + if source_revision_id: + finding_revisions.append(source_revision_id) + open_findings = EditorialFinding.objects.filter( + revision_id__in=finding_revisions, status=FindingStatus.OPEN + ) + return { + "state_document_id": str(document.id), + "state_verdict": document.verdict, + "state_status": document.status, + "coverage_counts": (document.coverage or {}).get("counts", {}), + "state_json": document.json_artifact_uri, + "state_markdown": document.markdown_artifact_uri, + "patch_attempted": bool(state.get("patch_attempted")), + "patch_status": state.get("patch_status", "not_needed"), + "patch_change_ratio": state.get("patch_change_ratio", 0), + "verification_status": state.get("verification_status", "not_needed"), + "open_finding_counts": { + severity: open_findings.filter(severity=severity).count() + for severity in FindingSeverity.values + }, + } + + def _ensure_contract(self, revision: ChapterRevision) -> ChapterContract: + plan_hash = json_sha256(revision.scene_plan) + requirements = build_contract_requirements(revision.scene_plan) + entry_canon = None + if revision.context_snapshot_id: + entry_canon = revision.context_snapshot.prior_canon + contract = ChapterContract.objects.filter(revision=revision).first() + if contract is not None and contract.approved_at and contract.scene_plan_sha256 != plan_hash: + raise RuntimeError("approved chapter contract cannot be changed") + if contract is None: + parent_approved_at = None + if revision.parent_id: + parent_approved_at = ChapterContract.objects.filter( + revision=revision.parent + ).values_list("approved_at", flat=True).first() + return ChapterContract.objects.create( + revision=revision, + entry_canon=entry_canon, + requirements=requirements, + scene_plan_sha256=plan_hash, + approved_at=parent_approved_at, + ) + contract.entry_canon = entry_canon + contract.requirements = requirements + contract.scene_plan_sha256 = plan_hash + contract.save( + update_fields=["entry_canon", "requirements", "scene_plan_sha256", "updated_at"] + ) + return contract + + def _persist_state_changes( + self, + state_document: ChapterStateDocument, + proposed_delta: list[dict[str, Any]], + *, + start_sequence: int = 1, + source: str = "extraction", + ) -> list[dict[str, Any]]: + revision = state_document.revision + story = revision.chapter.story + normalized: list[dict[str, Any]] = [] + allowed_operations = set(StateOperation.values) + for sequence, raw in enumerate(proposed_delta, start=start_sequence): + if not isinstance(raw, dict): + continue + kind = str(raw.get("entity_kind") or "book")[:64] + name = str(raw.get("canonical_name") or raw.get("entity_key") or "Book State")[:255] + key = normalize_entity_key(kind, name, str(raw.get("entity_key") or "")) + entity, _ = StoryEntity.objects.get_or_create( + story=story, + entity_key=key, + defaults={ + "kind": kind, + "canonical_name": name, + "first_revision": revision, + }, + ) + related_key = normalize_entity_key( + "unknown", "Related Entity", str(raw.get("related_entity_key") or "") + ) if raw.get("related_entity_key") else "" + related_entity = None + if related_key: + related_entity, _ = StoryEntity.objects.get_or_create( + story=story, + entity_key=related_key, + defaults={ + "kind": "unknown", + "canonical_name": related_key, + "first_revision": revision, + }, + ) + operation = str(raw.get("operation") or StateOperation.SET).upper() + if operation not in allowed_operations: + operation = StateOperation.SET + item = { + "sequence": sequence, + "entity_key": key, + "entity_kind": entity.kind, + "canonical_name": entity.canonical_name, + "related_entity_key": related_key, + "change_type": str(raw.get("change_type") or "STATE_CHANGED")[:80], + "predicate": str(raw.get("predicate") or "state")[:200], + "operation": operation, + "previous_value": raw.get("previous_value"), + "new_value": raw.get("new_value"), + "evidence_quote": str(raw.get("evidence_quote") or ""), + "evidence_location": str(raw.get("evidence_location") or "")[:255], + "source": source, + } + StateChange.objects.create( + state_document=state_document, + story=story, + revision=revision, + entity=entity, + related_entity=related_entity, + sequence=sequence, + change_type=item["change_type"], + predicate=item["predicate"], + operation=operation, + previous_value=item["previous_value"], + new_value=item["new_value"], + effective_chapter=revision.chapter.number, + evidence_quote=item["evidence_quote"], + evidence_location=item["evidence_location"], + metadata={"source": source}, + sha256=json_sha256(item), + ) + normalized.append(item) + return normalized + + def _validate_state_change_evidence( + self, state_document: ChapterStateDocument, prose: str + ) -> list[dict[str, str]]: + errors: list[dict[str, str]] = [] + for change in state_document.changes.exclude( + status=StateChangeStatus.REJECTED + ).order_by("sequence"): + if not evidence_is_present(prose, change.evidence_quote): + errors.append( + { + "sequence": str(change.sequence), + "location": change.evidence_location, + "quote": change.evidence_quote, + "description": ( + f"State change {change.sequence} ({change.predicate}) has no exact " + "supporting quotation in the chapter." + ), + } + ) + return errors + + def _write_state_artifacts(self, state_document: ChapterStateDocument) -> None: + revision = state_document.revision + stem = f"chapter-{revision.chapter.number:02d}-r{revision.revision}.state" + root = self._artifact_root(revision.chapter.story) / "states" + json_path = root / f"{stem}.json" + markdown_path = root / f"{stem}.md" + payload = { + "schema_version": 2, + "through_chapter": revision.chapter.number, + "revision_id": str(revision.id), + "status": state_document.status, + "verdict": state_document.verdict, + "start_state": state_document.start_state, + "observed_state": state_document.observed_state, + "proposed_delta": state_document.proposed_delta, + "coverage": state_document.coverage, + } + atomic_write_text(json_path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n") + atomic_write_text(markdown_path, render_state_markdown(payload)) + state_document.json_artifact_uri = str(json_path) + state_document.markdown_artifact_uri = str(markdown_path) + state_document.sha256 = json_sha256(payload) + state_document.save( + update_fields=[ + "json_artifact_uri", + "markdown_artifact_uri", + "sha256", + "updated_at", + ] + ) + + def _revision(self, state: dict[str, Any]) -> ChapterRevision: + return ChapterRevision.objects.select_related( + "chapter__story__project", "story_bible", "outline", "context_snapshot" + ).get(id=state["revision_id"]) + + def _context( + self, state: dict[str, Any], revision: ChapterRevision + ) -> dict[str, Any]: + snapshot_id = state.get("context_snapshot_id") or revision.context_snapshot_id + if not snapshot_id: + raise RuntimeError("story workflow has no generation context snapshot") + return GenerationContextSnapshot.objects.get(id=snapshot_id).content + + def _chapter_outline(self, revision: ChapterRevision) -> dict[str, Any]: + chapters = revision.outline.content.get("chapters") or [] + for chapter in chapters: + if int(chapter.get("number") or 0) == revision.chapter.number: + return chapter + raise RuntimeError(f"outline has no Chapter {revision.chapter.number}") + + def _render_prompt( + self, + purpose: str, + default_system: str, + default_template: str, + **values: Any, + ) -> str: + stored = PromptVersion.objects.filter(purpose=purpose, is_active=True).first() + system = stored.system_text if stored else default_system + template = stored.user_template if stored else default_template + return f"{system}\n\n{template.format(**values)}" + + def _artifact_root(self, story: StoryProject) -> Path: + configured = story.artifact_root.strip() + return Path(configured) if configured else Path(settings.BASE_DIR) / "artifacts" / "stories" / story.slug + + def _partial_path(self, revision: ChapterRevision) -> Path: + root = self._artifact_root(revision.chapter.story) + return root / "drafts" / f"chapter-{revision.chapter.number:02d}-r{revision.revision}.partial.md" + + def _scene_partial_path(self, revision: ChapterRevision, scene_number: int) -> Path: + path = self._partial_path(revision) + return path.with_name(f"{path.stem}.scene-{scene_number:02d}.partial.md") + + def _scene_path(self, revision: ChapterRevision, scene_number: int) -> Path: + path = self._partial_path(revision) + return path.with_name(f"{path.stem}.scene-{scene_number:02d}.md") + + def _style_excerpt(self, revision: ChapterRevision, words: int = 350) -> str: + style_revision_id = (revision.generation_metadata or {}).get("style_revision_id") + if not style_revision_id: + return "" + prose = ( + ChapterRevision.objects.filter(id=style_revision_id, chapter=revision.chapter) + .values_list("prose", flat=True) + .first() + or "" + ) + return " ".join(prose.split()[:words]) + + def _tail(self, prose: str, words: int = 900) -> str: + return " ".join(prose.split()[-words:]) diff --git a/control_plane/authoring/sources.py b/control_plane/authoring/sources.py new file mode 100644 index 0000000..aede129 --- /dev/null +++ b/control_plane/authoring/sources.py @@ -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), + ) diff --git a/control_plane/authoring/standalone_scenes.py b/control_plane/authoring/standalone_scenes.py new file mode 100644 index 0000000..55eea4e --- /dev/null +++ b/control_plane/authoring/standalone_scenes.py @@ -0,0 +1,1229 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from django.conf import settings +from django.db import transaction +from django.utils import timezone +from django.utils.text import slugify + +from control_plane.authoring.models import ( + DocumentAuthority, + DocumentType, + PromptVersion, + SceneContextCitation, + SceneDraftStatus, + SceneIdeation, + StandaloneScene, + Work, + text_sha256, +) +from control_plane.authoring.prompts import ( + SCENE_IDEA_TYPES, + SCENE_IDEATION_COMPACT_TEMPLATE, + SCENE_IDEATION_SYSTEM, + SCENE_IDEATION_TEMPLATE, + STANDALONE_SCENE_PLAN_SYSTEM, + STANDALONE_SCENE_PLAN_TEMPLATE, + STANDALONE_SCENE_PROSE_SYSTEM, + STANDALONE_SCENE_PROSE_TEMPLATE, + STANDALONE_SCENE_REVIEW_SYSTEM, + STANDALONE_SCENE_REVIEW_TEMPLATE, +) +from control_plane.authoring.scene_context import build_scene_context_pack +from control_plane.authoring.sources import register_source +from control_plane.authoring.state_management import ( + build_contract_requirements, + evidence_is_present, + json_sha256, +) +from control_plane.authoring.streaming import ResumableDraftWriter, atomic_write_text, word_count +from model_router.providers import extract_json_object +from model_router.router import ModelCapability, ModelRequestContract, ModelRouter + + +def render_authoring_prompt( + purpose: str, + default_system: str, + default_template: str, + **values: Any, +) -> tuple[str, str | None]: + stored = PromptVersion.objects.filter(purpose=purpose, is_active=True).first() + system = stored.system_text if stored else default_system + template = stored.user_template if stored else default_template + return f"{system}\n\n{template.format(**values)}", str(stored.id) if stored else None + + +class SceneIdeationService: + def __init__(self, router: ModelRouter) -> None: + self.router = router + + def propose( + self, + *, + work: Work, + target_book: str, + focus: str = "", + candidate_count: int = 10, + scene_types: list[str] | None = None, + authorities: list[str] | None = None, + pinned_document_keys: list[str] | None = None, + governing_document_keys: list[str] | None = None, + detail_level: str = "full", + model_hint: str | None = None, + book_state=None, + ) -> SceneIdeation: + candidate_count = int(candidate_count) + if not 1 <= candidate_count <= 12: + raise ValueError("candidate_count must be between 1 and 12") + target_book = target_book.strip() + if not target_book: + raise ValueError("target_book is required") + if len(target_book) > 160: + raise ValueError("target_book must be 160 characters or fewer") + requested_scene_types = list(dict.fromkeys(scene_types or SCENE_IDEA_TYPES)) + invalid_scene_types = sorted(set(requested_scene_types) - set(SCENE_IDEA_TYPES)) + if invalid_scene_types: + raise ValueError("unsupported scene types: " + ", ".join(invalid_scene_types)) + detail_level = detail_level.strip().lower() + if detail_level not in {"full", "compact"}: + raise ValueError("detail_level must be full or compact") + authorities = authorities or [DocumentAuthority.CANON, DocumentAuthority.PLANNING] + if book_state: + if book_state.work_id != work.id: + raise ValueError("book state must belong to the ideation work") + if book_state.status != "approved": + raise ValueError("book state must be approved before ideation") + focus = focus.strip() + query = f"{target_book} {focus}".strip() or ( + f"{work.title} unresolved choices open threads relationship pressure " + "character agency promises boundaries consequences" + ) + pack, _ranked = build_scene_context_pack( + work=work, + query=query, + authorities=authorities, + pinned_document_keys=pinned_document_keys, + governing_document_keys=governing_document_keys, + limit=32, + max_chars=150000, + ) + if book_state: + book_packet = { + "book_state_id": str(book_state.id), + "version": book_state.version, + "sha256": book_state.sha256, + "content": book_state.content, + } + pack["rendered_context"] = ( + "APPROVED BOOK STATE PLANNING CONTRACT\n" + + json.dumps(book_packet, ensure_ascii=False, indent=2) + + "\n\n" + + pack["rendered_context"] + ) + pack["book_state_id"] = str(book_state.id) + pack["book_state_sha256"] = book_state.sha256 + pack["sha256"] = json_sha256( + {key: value for key, value in pack.items() if key != "sha256"} + ) + prompt, prompt_version_id = render_authoring_prompt( + "SCENE_IDEATION", + SCENE_IDEATION_SYSTEM, + ( + SCENE_IDEATION_COMPACT_TEMPLATE + if detail_level == "compact" + else SCENE_IDEATION_TEMPLATE + ), + candidate_count=candidate_count, + work_title=work.title, + target_book=target_book, + focus=focus or "Open scan for unspent, evidence-backed scene opportunities.", + context=pack["rendered_context"], + scene_types=json.dumps( + { + key: SCENE_IDEA_TYPES[key] + for key in requested_scene_types + }, + ensure_ascii=False, + indent=2, + ), + ) + story = getattr(work, "story_project", None) + response = self.router.complete( + ModelRequestContract( + purpose=ModelCapability.STORY_PLANNING, + prompt=prompt, + model_hint=model_hint, + token_budget=( + max(3000, min(8000, candidate_count * 700)) + if detail_level == "compact" + else max(5000, min(12000, candidate_count * 1200)) + ), + project=story.project if story else None, + ) + ) + candidates = self._normalize_candidates( + extract_json_object(response.content), + candidate_count=candidate_count, + citation_ids={item["id"] for item in pack["citations"]}, + allowed_scene_types=set(requested_scene_types), + compact=detail_level == "compact", + ) + return SceneIdeation.objects.create( + work=work, + book_state=book_state, + target_book=target_book, + focus=focus, + requested_scene_types=requested_scene_types, + candidate_count=candidate_count, + authorities=authorities, + pinned_document_keys=pinned_document_keys or [], + context_pack=pack, + context_pack_sha256=pack["sha256"], + candidates=candidates, + generation_metadata={ + "model": response.model, + "prompt_version_id": prompt_version_id, + "prompt_sha256": text_sha256(prompt), + "response_sha256": text_sha256(response.content), + "context_pack_sha256": pack["sha256"], + "detail_level": detail_level, + }, + ) + + @transaction.atomic + def select_candidate( + self, + ideation: SceneIdeation, + *, + candidate_id: str, + target_words: int | None = None, + book_chapter_key: str | None = None, + ) -> tuple[StandaloneScene, bool]: + ideation = SceneIdeation.objects.select_for_update().select_related("work").get( + id=ideation.id + ) + candidates = list(ideation.candidates or []) + candidate = next( + (item for item in candidates if item.get("candidate_id") == candidate_id), + None, + ) + if candidate is None: + raise ValueError("idea candidate not found") + selected_scene_id = candidate.get("selected_scene_id") + if selected_scene_id: + return StandaloneScene.objects.get(id=selected_scene_id), False + if ideation.book_state_id and not str(book_chapter_key or "").strip(): + raise ValueError("book_chapter_key is required for book-state ideation selection") + constraints = list(candidate.get("constraints") or []) + scope_fit = str(candidate.get("scope_fit") or "").strip() + if ideation.target_book and scope_fit: + constraints.append(f"Placement scope: {ideation.target_book}. {scope_fit}") + scene = StandaloneSceneService(self.router).create( + work=ideation.work, + title=candidate["title"], + brief=candidate["brief"], + target_words=int(target_words or candidate.get("target_words") or 1800), + constraints=constraints, + forbidden_events=candidate.get("forbidden_events") or [], + boundary_constraints=candidate.get("boundary_constraints") or [], + book_state=ideation.book_state, + book_chapter_key=book_chapter_key, + ) + candidate["selected_scene_id"] = str(scene.id) + ideation.candidates = candidates + ideation.save(update_fields=["candidates", "updated_at"]) + return scene, True + + @staticmethod + def _normalize_candidates( + data: dict[str, Any], + *, + candidate_count: int, + citation_ids: set[str], + allowed_scene_types: set[str] | None = None, + compact: bool = False, + ) -> list[dict[str, Any]]: + allowed_scene_types = allowed_scene_types or set(SCENE_IDEA_TYPES) + raw_candidates = data.get("candidates") + if not isinstance(raw_candidates, list) or len(raw_candidates) != candidate_count: + raise ValueError(f"ideation must return exactly {candidate_count} candidates") + normalized = [] + for index, raw in enumerate(raw_candidates, start=1): + if not isinstance(raw, dict): + raise ValueError("each idea candidate must be an object") + title = str(raw.get("title") or "").strip() + brief = str(raw.get("brief") or "").strip() + purpose = str(raw.get("purpose") or "").strip() + opportunity = str(raw.get("opportunity") or "").strip() + scene_type = str(raw.get("scene_type") or "").strip() + scope_fit = str(raw.get("scope_fit") or "").strip() + type_fit = str(raw.get("type_fit") or "").strip() + raw_citations = raw.get("citations") + if not isinstance(raw_citations, list): + raise ValueError("each idea candidate citations value must be a list") + citations = list( + dict.fromkeys(str(value).strip() for value in raw_citations if str(value).strip()) + ) + invalid_citations = sorted(set(citations) - citation_ids) + if not title or not brief or not opportunity or (not compact and not purpose): + raise ValueError( + "each idea candidate requires title, brief, opportunity, " + "and full-detail purpose" + ) + if scene_type not in allowed_scene_types: + raise ValueError( + "each idea candidate requires a requested scene_type: " + + ", ".join(sorted(allowed_scene_types)) + ) + if not compact and (not scope_fit or not type_fit): + raise ValueError("each idea candidate requires scope_fit and type_fit") + if not citations or invalid_citations: + raise ValueError("each idea candidate must use only supplied citation IDs") + future_opportunities = _string_list(raw.get("future_opportunities")) + if not future_opportunities: + raise ValueError("each idea candidate requires future_opportunities") + candidate = { + "candidate_id": f"idea-{index:02d}", + "title": title, + "brief": brief, + "scene_type": scene_type, + "citations": citations, + "opportunity": opportunity, + "future_opportunities": future_opportunities, + } + if not compact: + proposed_words = int(raw.get("target_words") or 1800) + candidate.update( + { + "purpose": purpose, + "placement": str(raw.get("placement") or "").strip(), + "pov_character": str(raw.get("pov_character") or "").strip(), + "type_fit": type_fit, + "scope_fit": scope_fit, + "prerequisites": _string_list(raw.get("prerequisites")), + "target_words": min(10000, max(300, proposed_words)), + "constraints": _string_list(raw.get("constraints")), + "forbidden_events": _string_list(raw.get("forbidden_events")), + "boundary_constraints": _string_list(raw.get("boundary_constraints")), + "continuity_questions": _string_list(raw.get("continuity_questions")), + "risks": _string_list(raw.get("risks")), + } + ) + normalized.append(candidate) + required_distinct = min(candidate_count, len(allowed_scene_types)) + distinct_types = {candidate["scene_type"] for candidate in normalized} + if len(distinct_types) < required_distinct: + raise ValueError( + f"ideation requires {required_distinct} distinct scene types; " + f"received {len(distinct_types)}" + ) + return normalized + + +def _string_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + +def render_scene_ideation_markdown(ideation: SceneIdeation) -> str: + context_pack = ideation.context_pack or {} + candidates = ideation.candidates or [] + metadata = ideation.generation_metadata or {} + selected = [ + (candidate.get("candidate_id"), candidate.get("selected_scene_id")) + for candidate in candidates + if candidate.get("selected_scene_id") + ] + lines = [ + f"# Scene Ideas: {_single_line(ideation.work.title)}", + "", + "## Review Status", + "", + ] + if selected: + for candidate_id, scene_id in selected: + lines.append(f"- `{candidate_id}` selected as scene `{scene_id}`.") + else: + lines.extend( + [ + "- No candidate has been selected.", + "- No scene was created by this proposal.", + ] + ) + lines.extend( + [ + "", + "## Proposal Record", + "", + f"- Ideation ID: `{ideation.id}`", + f"- Created: `{ideation.created_at.isoformat()}`", + f"- Series: `{ideation.work.series.slug}`", + f"- Work: `{ideation.work.slug}`", + f"- Target book: `{ideation.target_book or 'not recorded'}`", + f"- Candidate count: `{ideation.candidate_count}`", + f"- Requested scene types: {_code_list(ideation.requested_scene_types)}", + f"- Governing documents: {_code_list(context_pack.get('governing_document_keys') or [])}", + f"- Authorities: {_code_list(ideation.authorities)}", + f"- Focus: {_single_line(ideation.focus) or '(open scan)'}", + f"- Context pack SHA-256: `{ideation.context_pack_sha256}`", + f"- Model: `{_single_line(metadata.get('model')) or 'unknown'}`", + f"- Prompt version ID: `{metadata.get('prompt_version_id') or 'default'}`", + f"- Prompt SHA-256: `{metadata.get('prompt_sha256') or ''}`", + f"- Response SHA-256: `{metadata.get('response_sha256') or ''}`", + ] + ) + if ideation.pinned_document_keys: + lines.extend(["", "### Pinned Documents", ""]) + _append_markdown_list(lines, ideation.pinned_document_keys) + + for candidate in candidates: + candidate_id = _single_line(candidate.get("candidate_id")) + title = _single_line(candidate.get("title")) + lines.extend( + [ + "", + "---", + "", + f"## {candidate_id}: {title}", + "", + f"**POV:** {_single_line(candidate.get('pov_character')) or 'Unspecified'} ", + f"**Scene type:** `{candidate.get('scene_type') or 'not recorded'}` ", + f"**Target:** {candidate.get('target_words') or 1800:,} words ", + f"**Selected scene:** `{candidate.get('selected_scene_id') or 'not selected'}`", + ] + ) + _append_markdown_paragraph(lines, "Brief", candidate.get("brief")) + _append_markdown_paragraph(lines, "Purpose", candidate.get("purpose")) + _append_markdown_paragraph(lines, "Placement", candidate.get("placement")) + _append_markdown_paragraph(lines, "Book Scope Fit", candidate.get("scope_fit")) + _append_markdown_section(lines, "Prerequisites", candidate.get("prerequisites")) + _append_markdown_paragraph(lines, "Scene Type Fit", candidate.get("type_fit")) + _append_markdown_paragraph(lines, "Opportunity Spent", candidate.get("opportunity")) + _append_markdown_section( + lines, + "Future Opportunities Created", + candidate.get("future_opportunities"), + empty="Not recorded; this proposal predates future-opportunity tracking.", + ) + _append_markdown_section(lines, "Constraints", candidate.get("constraints")) + _append_markdown_section(lines, "Forbidden Events", candidate.get("forbidden_events")) + _append_markdown_section( + lines, "Boundary Constraints", candidate.get("boundary_constraints") + ) + _append_markdown_section( + lines, "Continuity Questions", candidate.get("continuity_questions") + ) + _append_markdown_section(lines, "Risks", candidate.get("risks")) + _append_markdown_section(lines, "Citations", candidate.get("citations"), code=True) + + lines.extend(["", "---", "", "## Frozen Citation Index", ""]) + for citation in context_pack.get("citations") or []: + citation_id = _single_line(citation.get("id")) + document_key = _single_line(citation.get("document_key")) + start_line = citation.get("start_line") + end_line = citation.get("end_line") + line_range = str(start_line) if start_line == end_line else f"{start_line}-{end_line}" + lines.extend( + [ + f"### {citation_id}", + "", + f"- Document: `{document_key}`", + f"- Title: {_single_line(citation.get('document_title'))}", + f"- Version: `{citation.get('document_version')}`", + f"- Authority: `{citation.get('authority')}`", + f"- Lines: `{line_range}`", + f"- Passage SHA-256: `{citation.get('sha256')}`", + f"- Retrieval score: `{citation.get('score')}`", + f"- Retrieval reason: {_single_line(citation.get('reason'))}", + "", + ] + ) + return "\n".join(lines).rstrip() + "\n" + + +def render_scene_ideation_compact_markdown(ideation: SceneIdeation) -> str: + lines = [ + f"# Scene Ideas: {_single_line(ideation.work.title)}", + "", + f"- Ideation ID: `{ideation.id}`", + f"- Target book: `{ideation.target_book or 'not recorded'}`", + f"- Focus: {_single_line(ideation.focus) or '(open scan)'}", + ] + for candidate in ideation.candidates or []: + lines.extend( + [ + "", + "---", + "", + ( + f"## {_single_line(candidate.get('candidate_id'))}: " + f"{_single_line(candidate.get('title'))}" + ), + ] + ) + _append_markdown_paragraph(lines, "Brief", candidate.get("brief")) + _append_markdown_paragraph(lines, "Opportunity Spent", candidate.get("opportunity")) + _append_markdown_section( + lines, + "Future Opportunities Created", + candidate.get("future_opportunities"), + ) + lines.extend( + [ + "", + f"### Evaluation: {_single_line(candidate.get('evaluation'))}".rstrip(), + "", + f"### Feedback: {_single_line(candidate.get('feedback'))}".rstrip(), + ] + ) + return "\n".join(lines).rstrip() + "\n" + + +def export_scene_ideation_markdown( + ideation: SceneIdeation, output: Path, *, compact: bool = False +) -> Path: + content = ( + render_scene_ideation_compact_markdown(ideation) + if compact + else render_scene_ideation_markdown(ideation) + ) + atomic_write_text(output, content) + return output + + +def _single_line(value: Any) -> str: + return " ".join(str(value or "").splitlines()).strip() + + +def _code_list(values: list[Any]) -> str: + return ", ".join(f"`{_single_line(value)}`" for value in values) or "(none)" + + +def _append_markdown_paragraph(lines: list[str], heading: str, value: Any) -> None: + text = str(value or "").strip() + if text: + lines.extend(["", f"### {heading}", "", text]) + + +def _append_markdown_section( + lines: list[str], + heading: str, + values: Any, + *, + code: bool = False, + empty: str = "", +) -> None: + items = _string_list(values) + if not items and not empty: + return + lines.extend(["", f"### {heading}", ""]) + _append_markdown_list(lines, items or [empty], code=code) + + +def _append_markdown_list(lines: list[str], values: list[Any], *, code: bool = False) -> None: + for value in values: + text = str(value).strip() + if not text: + continue + if code: + text = f"`{_single_line(text)}`" + else: + text = text.replace("\n", "\n ") + lines.append(f"- {text}") + + +class StandaloneSceneService: + def __init__(self, router: ModelRouter) -> None: + self.router = router + self.writer = ResumableDraftWriter(router) + + @transaction.atomic + def create( + self, + *, + work: Work, + title: str, + brief: str, + target_words: int = 1800, + constraints: list[str] | None = None, + forbidden_events: list[str] | None = None, + boundary_constraints: list[str] | None = None, + book_state=None, + book_chapter_key: str | None = None, + ) -> StandaloneScene: + work = Work.objects.select_for_update().get(pk=work.pk) + if not 300 <= int(target_words) <= 10000: + raise ValueError("target_words must be between 300 and 10000") + chapter_key = str(book_chapter_key or "").strip() + if bool(book_state) != bool(chapter_key): + raise ValueError("book_state and book_chapter_key must be provided together") + if book_state: + if book_state.work_id != work.id: + raise ValueError("book state must belong to the scene work") + if str(book_state.status).upper() != "APPROVED": + raise ValueError("book state must be approved before binding a scene") + latest_run = book_state.runs.order_by("-created_at").first() + if latest_run and latest_run.status == "complete": + raise ValueError("start a new book run before revising a completed chapter") + self._book_chapter(book_state.content or {}, chapter_key) + scene_key = slugify(title)[:200] or "scene" + lineage = StandaloneScene.objects.filter(work=work, scene_key=scene_key) + if book_state: + chapter_scenes = StandaloneScene.objects.filter( + work=work, + book_state=book_state, + book_chapter_key=chapter_key, + ) + other_lineage = chapter_scenes.exclude(scene_key=scene_key).exists() + if other_lineage: + raise ValueError("book state chapter already has a different scene lineage") + lineage = lineage.filter(book_state=book_state, book_chapter_key=chapter_key) + else: + lineage = lineage.filter(book_state__isnull=True) + latest = lineage.order_by("-revision").first() + story = getattr(work, "story_project", None) + return StandaloneScene.objects.create( + work=work, + story=story, + parent=latest, + scene_key=scene_key, + revision=(latest.revision + 1) if latest else 1, + title=title.strip(), + brief=brief.strip(), + target_words=int(target_words), + constraints=constraints or [], + forbidden_events=forbidden_events or [], + boundary_constraints=boundary_constraints or [], + book_state=book_state, + book_chapter_key=chapter_key, + ) + + def plan( + self, + scene: StandaloneScene, + *, + authorities: list[str] | None = None, + pinned_document_keys: list[str] | None = None, + model_hint: str | None = None, + ) -> StandaloneScene: + if scene.status not in {SceneDraftStatus.PLANNING, SceneDraftStatus.PLAN_REVIEW}: + raise ValueError(f"scene cannot be planned from status {scene.status}") + self.prepare_context( + scene, + authorities=authorities, + pinned_document_keys=pinned_document_keys, + ) + pack = scene.context_pack + prompt, prompt_version_id = render_authoring_prompt( + "STANDALONE_SCENE_PLAN", + STANDALONE_SCENE_PLAN_SYSTEM, + STANDALONE_SCENE_PLAN_TEMPLATE, + title=scene.title, + brief=scene.brief, + context=pack["rendered_context"], + constraints=json.dumps(scene.constraints, ensure_ascii=False, indent=2), + forbidden_events=json.dumps(scene.forbidden_events, ensure_ascii=False, indent=2), + boundary_constraints=json.dumps( + scene.boundary_constraints, ensure_ascii=False, indent=2 + ), + target_words=scene.target_words, + ) + response = self.router.complete( + ModelRequestContract( + purpose=ModelCapability.STORY_PLANNING, + prompt=prompt, + model_hint=model_hint, + token_budget=5000, + project=scene.story.project if scene.story else None, + ) + ) + plan = self._normalize_plan(extract_json_object(response.content), scene) + requirements = self._requirements(plan) + scene.plan = plan + scene.contract_requirements = requirements + scene.status = SceneDraftStatus.PLAN_REVIEW + scene.failure_reason = "" + metadata = dict(scene.generation_metadata or {}) + metadata["planning"] = { + "model": response.model, + "prompt_version_id": prompt_version_id, + "prompt_sha256": text_sha256(prompt), + "response_sha256": text_sha256(response.content), + "context_pack_sha256": pack["sha256"], + **self._book_state_metadata(scene), + } + scene.generation_metadata = metadata + scene.save() + return scene + + def prepare_context( + self, + scene: StandaloneScene, + *, + authorities: list[str] | None = None, + pinned_document_keys: list[str] | None = None, + ) -> StandaloneScene: + if scene.status not in {SceneDraftStatus.PLANNING, SceneDraftStatus.PLAN_REVIEW}: + raise ValueError(f"scene context cannot be changed from status {scene.status}") + query = "\n".join( + [scene.title, scene.brief, *scene.constraints, *scene.boundary_constraints] + ) + pack, ranked = build_scene_context_pack( + work=scene.work, + query=query, + authorities=authorities, + pinned_document_keys=pinned_document_keys, + ) + if scene.book_state_id: + packet = self._book_planning_packet(scene) + pack["rendered_context"] = f"{packet}\n\n{pack['rendered_context']}" + pack.update(self._book_state_metadata(scene)) + pack["sha256"] = json_sha256( + {key: value for key, value in pack.items() if key != "sha256"} + ) + with transaction.atomic(): + scene.context_query = query + scene.context_pack = pack + scene.context_pack_sha256 = pack["sha256"] + scene.save( + update_fields=[ + "context_query", + "context_pack", + "context_pack_sha256", + "updated_at", + ] + ) + scene.context_citations.all().delete() + SceneContextCitation.objects.bulk_create( + [ + SceneContextCitation( + scene=scene, + passage=item.passage, + rank=index, + score=item.score, + reason=item.reason, + ) + for index, item in enumerate(ranked, start=1) + ] + ) + return scene + + def approve_plan(self, scene: StandaloneScene) -> StandaloneScene: + if scene.status != SceneDraftStatus.PLAN_REVIEW or not scene.plan: + raise ValueError("scene has no plan awaiting approval") + scene.status = SceneDraftStatus.READY + scene.plan_approved_at = timezone.now() + scene.save(update_fields=["status", "plan_approved_at", "updated_at"]) + return scene + + def write( + self, + scene: StandaloneScene, + *, + model_hint: str | None = None, + max_attempts: int = 2, + ) -> StandaloneScene: + if scene.status not in {SceneDraftStatus.READY, SceneDraftStatus.FAILED}: + raise ValueError(f"scene cannot be written from status {scene.status}") + if not scene.plan_approved_at: + raise ValueError("scene plan must be approved before writing") + prompt, prompt_version_id = render_authoring_prompt( + "STANDALONE_SCENE_PROSE", + STANDALONE_SCENE_PROSE_SYSTEM, + STANDALONE_SCENE_PROSE_TEMPLATE, + title=scene.title, + brief=scene.brief, + context=(scene.context_pack or {}).get("rendered_context") + or "(No approved source context.)", + plan=json.dumps(scene.plan, ensure_ascii=False, indent=2), + requirements=json.dumps(scene.contract_requirements, ensure_ascii=False, indent=2), + target_words=scene.target_words, + ) + partial_path, artifact_path, _review_path = self._artifact_paths(scene) + scene.status = SceneDraftStatus.DRAFTING + scene.partial_artifact_uri = str(partial_path) + scene.failure_reason = "" + scene.save(update_fields=["status", "partial_artifact_uri", "failure_reason", "updated_at"]) + try: + result = self.writer.generate( + request=ModelRequestContract( + purpose=ModelCapability.STORY_PROSE, + prompt=prompt, + model_hint=model_hint, + token_budget=max(4000, int(scene.target_words * 2.2)), + project=scene.story.project if scene.story else None, + ), + partial_path=partial_path, + minimum_words=max(250, int(scene.target_words * 0.6)), + maximum_words=max(600, int(scene.target_words * 1.8)), + completion_marker="[[END_OF_SCENE]]", + max_attempts=max(1, int(max_attempts)), + ) + except Exception as exc: + scene.status = SceneDraftStatus.FAILED + scene.failure_reason = str(exc) + scene.save(update_fields=["status", "failure_reason", "updated_at"]) + raise + prose = self._scene_document(scene.title, result.text) + atomic_write_text(artifact_path, prose + "\n") + scene.prose = prose + scene.artifact_uri = str(artifact_path) + scene.status = SceneDraftStatus.DRAFT_REVIEW + metadata = dict(scene.generation_metadata or {}) + metadata["prose"] = { + "model_hint": model_hint, + "routed_model": model_hint or self.router.route(ModelCapability.STORY_PROSE), + "prompt_version_id": prompt_version_id, + "prompt_sha256": text_sha256(prompt), + "attempts": result.attempts, + "resumed": result.resumed, + "word_count": word_count(prose), + **self._book_state_metadata(scene), + } + scene.generation_metadata = metadata + scene.save() + return scene + + def review(self, scene: StandaloneScene, *, model_hint: str | None = None) -> StandaloneScene: + if scene.status != SceneDraftStatus.DRAFT_REVIEW or not scene.prose: + raise ValueError("scene has no completed draft to review") + prose = self._scene_document(scene.title, scene.prose) + if prose != scene.prose: + _partial_path, artifact_path, _review_path = self._artifact_paths(scene) + atomic_write_text(artifact_path, prose + "\n") + scene.prose = prose + scene.review = {} + scene.save( + update_fields=[ + "prose", + "word_count", + "sha256", + "review", + "updated_at", + ] + ) + prompt, prompt_version_id = render_authoring_prompt( + "STANDALONE_SCENE_REVIEW", + STANDALONE_SCENE_REVIEW_SYSTEM, + STANDALONE_SCENE_REVIEW_TEMPLATE, + context=(scene.context_pack or {}).get("rendered_context") + or "(No approved source context.)", + plan=json.dumps(scene.plan, ensure_ascii=False, indent=2), + requirements=json.dumps(scene.contract_requirements, ensure_ascii=False, indent=2), + prose=scene.prose, + ) + response = self.router.complete( + ModelRequestContract( + purpose=ModelCapability.STORY_REVIEW, + prompt=prompt, + model_hint=model_hint, + token_budget=5000, + project=scene.story.project if scene.story else None, + ) + ) + review = self._normalize_review( + extract_json_object(response.content), scene.contract_requirements, scene.prose + ) + _partial_path, _artifact_path, review_path = self._artifact_paths(scene) + atomic_write_text(review_path, json.dumps(review, ensure_ascii=False, indent=2) + "\n") + scene.review = review + scene.review_artifact_uri = str(review_path) + metadata = dict(scene.generation_metadata or {}) + metadata["review"] = { + "model": response.model, + "prompt_version_id": prompt_version_id, + "prompt_sha256": text_sha256(prompt), + "response_sha256": text_sha256(response.content), + **self._book_state_metadata(scene), + } + scene.generation_metadata = metadata + scene.save( + update_fields=[ + "review", + "review_artifact_uri", + "generation_metadata", + "updated_at", + ] + ) + return scene + + @staticmethod + def _book_chapters(content: dict[str, Any]) -> list[dict[str, Any]]: + chapters: list[dict[str, Any]] = [] + + def append_chapters(value: Any) -> None: + if isinstance(value, dict): + for key, chapter in value.items(): + if isinstance(chapter, dict): + chapters.append({"key": str(chapter.get("key") or key), **chapter}) + elif isinstance(value, list): + chapters.extend(chapter for chapter in value if isinstance(chapter, dict)) + + append_chapters(content.get("chapters")) + acts = content.get("acts") or [] + if isinstance(acts, dict): + acts = acts.values() + for act in acts: + if isinstance(act, dict): + append_chapters(act.get("chapters")) + return chapters + + @classmethod + def _book_chapter( + cls, content: dict[str, Any], chapter_key: str + ) -> tuple[dict[str, Any], list[dict[str, Any]], int]: + chapters = cls._book_chapters(content) + for index, chapter in enumerate(chapters): + key = str( + chapter.get("key") + or chapter.get("chapter_key") + or chapter.get("id") + or "" + ) + if key == chapter_key: + return chapter, chapters, index + raise ValueError(f"book state chapter not found: {chapter_key}") + + @classmethod + def _book_planning_packet(cls, scene: StandaloneScene) -> str: + state = scene.book_state + content = state.content or {} + chapter, chapters, index = cls._book_chapter(content, scene.book_chapter_key) + prior = chapters[index - 1] if index else {} + following = chapters[index + 1] if index + 1 < len(chapters) else {} + packet = { + "book_state": { + "id": str(state.id), + "version": state.version, + "sha256": state.sha256, + }, + "book_constraints": content.get("constraints") + or content.get("book_constraints") + or content.get("global_constraints") + or [], + "book_forbidden_events": content.get("forbidden_events") + or content.get("book_forbidden_events") + or [], + "current_chapter": chapter, + "prior_chapter_ending": chapter.get("prior_ending") + or prior.get("ending_state") + or prior.get("ending") + or prior.get("final_state") + or "", + "next_chapter_purpose": chapter.get("next_purpose") + or following.get("purpose") + or "", + "relevant_arcs": cls._relevant_book_items( + content.get("arcs"), + chapter.get("arcs") or chapter.get("arc_keys") or chapter.get("arc_ids"), + scene.book_chapter_key, + ), + "relevant_threads": cls._relevant_book_items( + content.get("threads") or content.get("plot_threads"), + chapter.get("threads") + or chapter.get("thread_keys") + or chapter.get("thread_ids"), + scene.book_chapter_key, + ), + "continuity_facts": chapter.get("continuity_facts") + or cls._relevant_continuity(content, index), + } + return "PLANNING BOOK STATE PACKET (approved constraints)\n" + json.dumps( + packet, ensure_ascii=False, indent=2 + ) + + @staticmethod + def _relevant_book_items(items: Any, references: Any, chapter_key: str) -> list[Any]: + if isinstance(references, dict): + references = [references] + elif isinstance(references, str): + references = [references] + references = references or [] + if references and all(isinstance(item, dict) for item in references): + return list(references) + reference_keys = {str(value) for value in references} + if isinstance(items, dict): + values = [ + {"key": key, **value} + for key, value in items.items() + if isinstance(value, dict) + ] + elif isinstance(items, list): + values = items + else: + values = [] + if not values: + return list(references) + if not reference_keys: + relevant = [ + item + for item in values + if chapter_key + in { + str(value) + for value in ( + item.get("chapters") or item.get("chapter_keys") or [] + ) + } + ] + return relevant or values + return [ + item + for item in values + if str( + item.get("key") + or item.get("id") + or item.get("arc_id") + or item.get("thread_id") + ) + in reference_keys + ] + + @classmethod + def _relevant_continuity( + cls, content: dict[str, Any], chapter_index: int + ) -> list[dict[str, Any]]: + chapter_positions = { + str(chapter.get("chapter_key") or chapter.get("key") or ""): index + for index, chapter in enumerate(cls._book_chapters(content)) + } + relevant = [] + for fact in content.get("continuity") or content.get("continuity_facts") or []: + if not isinstance(fact, dict): + continue + established = chapter_positions.get(str(fact.get("established_in") or "")) + resolved = chapter_positions.get(str(fact.get("resolved_in") or "")) + if established is None or established > chapter_index: + continue + if fact.get("resolved_in") and (resolved is None or resolved < chapter_index): + continue + relevant.append(fact) + return relevant + + @staticmethod + def _book_state_metadata(scene: StandaloneScene) -> dict[str, Any]: + if not scene.book_state_id: + return {} + state = scene.book_state + return { + "book_state_id": str(state.id), + "book_state_version": state.version, + "book_state_sha256": state.sha256, + "book_chapter_key": scene.book_chapter_key, + } + + @staticmethod + def _scene_document(title: str, prose: str) -> str: + body = prose.strip() + first_line = body.partition("\n")[0].lstrip("# ").strip() + if first_line == title.strip(): + return body + return f"# {title.strip()}\n\n{body}" + + def approve( + self, + scene: StandaloneScene, + *, + actor: str, + force: bool = False, + ) -> StandaloneScene: + if scene.status != SceneDraftStatus.DRAFT_REVIEW or not scene.prose: + raise ValueError("scene has no completed draft awaiting approval") + if not scene.review: + raise ValueError("scene must be reviewed before approval") + if not scene.review.get("passed") and not force: + raise ValueError("scene review has blocking issues") + artifact_path = Path(scene.artifact_uri) + root = self._artifact_root(scene) + source = register_source( + work=scene.work, + path=artifact_path, + root=root, + authority=DocumentAuthority.PROVISIONAL, + document_type=DocumentType.SCENE, + ) + source_document = scene.work.source_documents.get(logical_key=source.logical_key) + source_version = source_document.versions.get(version=source.version) + scene.status = SceneDraftStatus.APPROVED + scene.approved_at = timezone.now() + scene.approved_by = actor.strip() + scene.source_version = source_version + metadata = dict(scene.generation_metadata or {}) + metadata["approval"] = { + "actor": scene.approved_by, + "forced": bool(force), + "source_version_id": str(source_version.id), + } + scene.generation_metadata = metadata + scene.save( + update_fields=[ + "status", + "approved_at", + "approved_by", + "source_version", + "generation_metadata", + "updated_at", + ] + ) + return scene + + def reject(self, scene: StandaloneScene, *, actor: str) -> StandaloneScene: + if scene.status == SceneDraftStatus.APPROVED: + raise ValueError( + "approved scenes cannot be rejected in place; create a superseding revision" + ) + scene.status = SceneDraftStatus.REJECTED + scene.approved_by = actor.strip() + scene.save(update_fields=["status", "approved_by", "updated_at"]) + return scene + + def _normalize_plan(self, data: dict[str, Any], scene: StandaloneScene) -> dict[str, Any]: + beats = [] + for raw in data.get("beats") or []: + if isinstance(raw, str): + raw = {"text": raw, "required": False} + text = str(raw.get("text") or "").strip() + if text: + beats.append({"text": text, "required": bool(raw.get("required"))}) + if not beats: + raise ValueError("scene plan must contain at least one beat") + plan = { + "purpose": str(data.get("purpose") or "").strip(), + "pov_character": str(data.get("pov_character") or "").strip(), + "tense": str(data.get("tense") or "past").strip(), + "location": str(data.get("location") or "").strip(), + "time_context": str(data.get("time_context") or "").strip(), + "present": list(data.get("present") or []), + "target_words": scene.target_words, + "beats": beats[:12], + "exact_values": list(data.get("exact_values") or []), + "constraints": list( + dict.fromkeys([*scene.constraints, *(data.get("constraints") or [])]) + ), + "forbidden_events": list( + dict.fromkeys([*scene.forbidden_events, *(data.get("forbidden_events") or [])]) + ), + "ending_state": str(data.get("ending_state") or "").strip(), + "final_image": str(data.get("final_image") or "").strip(), + "boundary_constraints": list( + dict.fromkeys( + [*scene.boundary_constraints, *(data.get("boundary_constraints") or [])] + ) + ), + "continuity_questions": list(data.get("continuity_questions") or []), + } + if not plan["ending_state"]: + raise ValueError("scene plan must define ending_state") + return plan + + def _requirements(self, plan: dict[str, Any]) -> list[dict[str, Any]]: + contract = { + "scenes": [ + { + "number": 1, + "beats": plan["beats"], + "ending_state": plan["ending_state"], + } + ], + "exact_values": plan["exact_values"], + "forbidden_events": plan["forbidden_events"], + "chapter_constraints": plan["constraints"], + "boundary_constraints": plan["boundary_constraints"], + "final_image": plan["final_image"], + } + return build_contract_requirements(contract, max_required_per_scene=None) + + def _normalize_review( + self, + data: dict[str, Any], + requirements: list[dict[str, Any]], + prose: str, + ) -> dict[str, Any]: + expected = {item["id"]: item for item in requirements} + results_by_id = { + str(item.get("requirement_id") or ""): item + for item in data.get("requirement_results") or [] + if isinstance(item, dict) + } + requirement_results = [] + blocking_failure = False + for requirement_id, requirement in expected.items(): + raw = results_by_id.get(requirement_id) or { + "requirement_id": requirement_id, + "status": "UNVERIFIABLE", + "evidence_quote": "", + "details": "Reviewer omitted this frozen requirement.", + } + status = str(raw.get("status") or "UNVERIFIABLE").upper() + evidence = str(raw.get("evidence_quote") or "") + evidence_valid = bool(evidence and evidence_is_present(prose, evidence)) + if requirement.get("blocking") and status != "HIT": + blocking_failure = True + requirement_results.append( + { + "requirement_id": requirement_id, + "status": status, + "evidence_quote": evidence, + "evidence_valid": evidence_valid, + "details": str(raw.get("details") or ""), + } + ) + findings = [] + for raw in data.get("findings") or []: + if not isinstance(raw, dict): + continue + finding = dict(raw) + evidence = str(finding.get("evidence_quote") or "") + finding["evidence_valid"] = bool(evidence and evidence_is_present(prose, evidence)) + severity = str(finding.get("severity") or "MEDIUM").upper() + finding["severity"] = severity + if severity in {"HIGH", "CRITICAL"}: + blocking_failure = True + findings.append(finding) + proposed_changes = [] + for raw in data.get("proposed_changes") or []: + if not isinstance(raw, dict): + continue + change = dict(raw) + evidence = str(change.get("evidence_quote") or "") + change["evidence_valid"] = bool(evidence and evidence_is_present(prose, evidence)) + proposed_changes.append(change) + return { + "schema_version": 1, + "passed": bool(data.get("passed")) and not blocking_failure, + "requirement_results": requirement_results, + "findings": findings, + "observed_state": data.get("observed_state") or {}, + "proposed_changes": proposed_changes, + } + + def _artifact_root(self, scene: StandaloneScene) -> Path: + if scene.story and scene.story.artifact_root.strip(): + return Path(scene.story.artifact_root) + return ( + Path(settings.BASE_DIR) + / "artifacts" + / "stories" + / scene.work.series.slug + / scene.work.slug + ) + + def _artifact_paths(self, scene: StandaloneScene) -> tuple[Path, Path, Path]: + directory = self._artifact_root(scene) / "scenes" + if scene.book_state_id: + directory = ( + directory + / f"book-state-v{scene.book_state.version:04d}-{scene.book_state_id}" + / scene.book_chapter_key + ) + directory /= scene.scene_key + stem = f"r{scene.revision:02d}" + return ( + directory / f"{stem}.partial.md", + directory / f"{stem}.md", + directory / f"{stem}.review.json", + ) diff --git a/control_plane/authoring/state.py b/control_plane/authoring/state.py new file mode 100644 index 0000000..aba6773 --- /dev/null +++ b/control_plane/authoring/state.py @@ -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 diff --git a/control_plane/authoring/state_management.py b/control_plane/authoring/state_management.py new file mode 100644 index 0000000..c879c52 --- /dev/null +++ b/control_plane/authoring/state_management.py @@ -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) diff --git a/control_plane/authoring/streaming.py b/control_plane/authoring/streaming.py new file mode 100644 index 0000000..8fc5e1d --- /dev/null +++ b/control_plane/authoring/streaming.py @@ -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\n" + + partial + + "\n" + ) + 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() diff --git a/control_plane/authoring/views.py b/control_plane/authoring/views.py new file mode 100644 index 0000000..38c01df --- /dev/null +++ b/control_plane/authoring/views.py @@ -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)) diff --git a/control_plane/authoring/workflow.py b/control_plane/authoring/workflow.py new file mode 100644 index 0000000..035aaeb --- /dev/null +++ b/control_plane/authoring/workflow.py @@ -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) diff --git a/control_plane/projects/views.py b/control_plane/projects/views.py index 8a32265..0b431e4 100644 --- a/control_plane/projects/views.py +++ b/control_plane/projects/views.py @@ -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 = "" diff --git a/control_plane/resources/management/commands/seed_spark_resources.py b/control_plane/resources/management/commands/seed_spark_resources.py index 441096d..3b7ab95 100644 --- a/control_plane/resources/management/commands/seed_spark_resources.py +++ b/control_plane/resources/management/commands/seed_spark_resources.py @@ -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"), diff --git a/docs/story_authoring_workflow.md b/docs/story_authoring_workflow.md new file mode 100644 index 0000000..05cbadc --- /dev/null +++ b/docs/story_authoring_workflow.md @@ -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__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 +python manage.py story_state show --revision +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 +python manage.py fiction_book review --id --level continuity --model luna +python manage.py fiction_book impact --id +python manage.py fiction_book approve --id --actor daniel +python manage.py fiction_book start-run --id +python manage.py fiction_book sync-run --run-id +python manage.py fiction_book review-run --run-id --model luna +``` + +The equivalent API is `GET|POST /api/authoring/book-states/`, `GET +/api/authoring/book-states//`, and `POST /api/authoring/book-states//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 +python manage.py fiction_ideas export --id --output /path/to/scene-ideas.md +python manage.py fiction_ideas select --id --candidate-id idea-02 +``` + +Pass `--book-state ` 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 \ + --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 +python manage.py fiction_scene write --id +python manage.py fiction_scene review --id +python manage.py fiction_scene approve --id --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//` +- `POST /api/authoring/ideas//actions/` with action `select` +- `GET|POST /api/authoring/scenes/` +- `GET /api/authoring/scenes//` +- `POST /api/authoring/scenes//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. diff --git a/graph/bootstrap.py b/graph/bootstrap.py index b694d14..ac3b555 100644 --- a/graph/bootstrap.py +++ b/graph/bootstrap.py @@ -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()) diff --git a/graph/story_authoring.py b/graph/story_authoring.py new file mode 100644 index 0000000..ac5f4fd --- /dev/null +++ b/graph/story_authoring.py @@ -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 diff --git a/model_router/policy.py b/model_router/policy.py index 3217958..a33ed2f 100644 --- a/model_router/policy.py +++ b/model_router/policy.py @@ -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", } diff --git a/model_router/providers.py b/model_router/providers.py index fb29856..08cb3ca 100644 --- a/model_router/providers.py +++ b/model_router/providers.py @@ -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" diff --git a/model_router/router.py b/model_router/router.py index abeb26b..965e3e2 100644 --- a/model_router/router.py +++ b/model_router/router.py @@ -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(): diff --git a/pyproject.toml b/pyproject.toml index 42451e9..07afea7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/templates/control_plane/approvals.html b/templates/control_plane/approvals.html index dfdff0c..1690602 100644 --- a/templates/control_plane/approvals.html +++ b/templates/control_plane/approvals.html @@ -1,2 +1,2 @@ {% extends "control_plane/base.html" %} -{% block content %}

    Approvals

    Durable approval inbox for paused graph decisions.

    {% for approval in approvals %}{% empty %}{% endfor %}
    SourceProjectReasonEvidenceRequestedActions
    {{ approval.graph_run.execution_graph_version.graph.name }} v{{ approval.graph_run.execution_graph_version.version }}{{ approval.graph_run.project.name|default:"-" }}{{ approval.reason }}
    {{ approval.payload }}
    {{ approval.created_at }}
    {% csrf_token %}
    No pending approvals.
    {% endblock %} +{% block content %}

    Approvals

    Durable approval inbox for paused graph decisions.

    {% for approval in approvals %}{% empty %}{% endfor %}
    SourceProjectReasonEvidenceRequestedActions
    {{ approval.graph_run.execution_graph_version.graph.name }} v{{ approval.graph_run.execution_graph_version.version }}{{ approval.graph_run.project.name|default:"-" }}{{ approval.reason }}
    {{ approval.payload }}
    {{ approval.created_at }}
    {% csrf_token %}{% if approval.graph_run.execution_graph_version.graph.name == "story_authoring" %}{% endif %}
    No pending approvals.
    {% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py index 36d8172..3fcf397 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_book_authoring_state.py b/tests/test_book_authoring_state.py new file mode 100644 index 0000000..098ae75 --- /dev/null +++ b/tests/test_book_authoring_state.py @@ -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) diff --git a/tests/test_model_router_providers.py b/tests/test_model_router_providers.py index f0f2166..e8ac285 100644 --- a/tests/test_model_router_providers.py +++ b/tests/test_model_router_providers.py @@ -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" diff --git a/tests/test_scene_ideation.py b/tests/test_scene_ideation.py new file mode 100644 index 0000000..2ea83f6 --- /dev/null +++ b/tests/test_scene_ideation.py @@ -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", + ] diff --git a/tests/test_standalone_scene_api.py b/tests/test_standalone_scene_api.py new file mode 100644 index 0000000..01e1f35 --- /dev/null +++ b/tests/test_standalone_scene_api.py @@ -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" diff --git a/tests/test_standalone_scenes.py b/tests/test_standalone_scenes.py new file mode 100644 index 0000000..a7db683 --- /dev/null +++ b/tests/test_standalone_scenes.py @@ -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() diff --git a/tests/test_story_authoring_workflow.py b/tests/test_story_authoring_workflow.py new file mode 100644 index 0000000..fb8f0a5 --- /dev/null +++ b/tests/test_story_authoring_workflow.py @@ -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.", + ) diff --git a/tests/test_story_sources.py b/tests/test_story_sources.py new file mode 100644 index 0000000..49f5ee6 --- /dev/null +++ b/tests/test_story_sources.py @@ -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() diff --git a/uv.lock b/uv.lock index a885819..3175143 100644 --- a/uv.lock +++ b/uv.lock @@ -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"