Add durable fiction authoring workflows
This commit is contained in:
parent
203bc7917a
commit
829a0361d4
65 changed files with 13934 additions and 25 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ INSTALLED_APPS = [
|
|||
"control_plane.secrets",
|
||||
"control_plane.knowledge",
|
||||
"control_plane.verification",
|
||||
"control_plane.authoring",
|
||||
"graph",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ from __future__ import annotations
|
|||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
|
||||
from control_plane.projects import views
|
||||
from control_plane.authoring import views as authoring_views
|
||||
from control_plane.model_studio import views as model_studio_views
|
||||
from control_plane.projects import views
|
||||
from control_plane.trading_studio import views as trading_studio_views
|
||||
|
||||
urlpatterns = [
|
||||
|
|
@ -40,6 +41,39 @@ urlpatterns = [
|
|||
path("trading-studio/<uuid:project_id>/", trading_studio_views.trading_studio_project, name="trading_studio_project"),
|
||||
path("approvals/", views.approvals, name="approvals"),
|
||||
path("approvals/<int:approval_id>/action/", views.approval_action, name="approval_action"),
|
||||
path("api/authoring/book-states/", authoring_views.book_states, name="book_states"),
|
||||
path(
|
||||
"api/authoring/book-states/<uuid:state_id>/",
|
||||
authoring_views.book_state_detail,
|
||||
name="book_state_detail",
|
||||
),
|
||||
path(
|
||||
"api/authoring/book-states/<uuid:state_id>/actions/",
|
||||
authoring_views.book_state_action,
|
||||
name="book_state_action",
|
||||
),
|
||||
path("api/authoring/ideas/", authoring_views.scene_ideas, name="scene_ideas"),
|
||||
path(
|
||||
"api/authoring/ideas/<uuid:idea_id>/",
|
||||
authoring_views.scene_idea_detail,
|
||||
name="scene_idea_detail",
|
||||
),
|
||||
path(
|
||||
"api/authoring/ideas/<uuid:idea_id>/actions/",
|
||||
authoring_views.scene_idea_action,
|
||||
name="scene_idea_action",
|
||||
),
|
||||
path("api/authoring/scenes/", authoring_views.standalone_scenes, name="standalone_scenes"),
|
||||
path(
|
||||
"api/authoring/scenes/<uuid:scene_id>/",
|
||||
authoring_views.standalone_scene_detail,
|
||||
name="standalone_scene_detail",
|
||||
),
|
||||
path(
|
||||
"api/authoring/scenes/<uuid:scene_id>/actions/",
|
||||
authoring_views.standalone_scene_action,
|
||||
name="standalone_scene_action",
|
||||
),
|
||||
path("activity/", views.activity, name="activity"),
|
||||
path("admin/", admin.site.urls),
|
||||
]
|
||||
|
|
|
|||
0
control_plane/authoring/__init__.py
Normal file
0
control_plane/authoring/__init__.py
Normal file
186
control_plane/authoring/admin.py
Normal file
186
control_plane/authoring/admin.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from control_plane.authoring.models import (
|
||||
BookRun,
|
||||
BookStateVersion,
|
||||
SceneContextCitation,
|
||||
SceneIdeation,
|
||||
Series,
|
||||
SourceDocument,
|
||||
SourceDocumentVersion,
|
||||
SourcePassage,
|
||||
StandaloneScene,
|
||||
Work,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Series)
|
||||
class SeriesAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "slug", "updated_at")
|
||||
search_fields = ("title", "slug")
|
||||
|
||||
|
||||
@admin.register(Work)
|
||||
class WorkAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"title",
|
||||
"series",
|
||||
"work_type",
|
||||
"sequence",
|
||||
"current_book_state",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("work_type", "series")
|
||||
search_fields = ("title", "slug", "series__title")
|
||||
|
||||
|
||||
class SourceDocumentVersionInline(admin.TabularInline):
|
||||
model = SourceDocumentVersion
|
||||
fields = ("version", "authority", "source_path", "source_sha256", "created_at")
|
||||
readonly_fields = fields
|
||||
extra = 0
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@admin.register(SourceDocument)
|
||||
class SourceDocumentAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "work", "document_type", "logical_key", "updated_at")
|
||||
list_filter = ("document_type", "work__series", "work")
|
||||
search_fields = ("title", "logical_key", "work__title")
|
||||
inlines = (SourceDocumentVersionInline,)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return tuple(field.name for field in self.model._meta.fields)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(SourceDocumentVersion)
|
||||
class SourceDocumentVersionAdmin(admin.ModelAdmin):
|
||||
list_display = ("document", "version", "authority", "byte_size", "created_at")
|
||||
list_filter = ("authority", "document__document_type", "document__work")
|
||||
search_fields = ("document__title", "document__logical_key", "source_path", "source_sha256")
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return tuple(field.name for field in self.model._meta.fields)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(SourcePassage)
|
||||
class SourcePassageAdmin(admin.ModelAdmin):
|
||||
list_display = ("document_version", "ordinal", "start_line", "end_line", "sha256")
|
||||
search_fields = ("content", "document_version__document__logical_key")
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return tuple(field.name for field in self.model._meta.fields)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
class SceneContextCitationInline(admin.TabularInline):
|
||||
model = SceneContextCitation
|
||||
fields = ("rank", "passage", "score", "reason")
|
||||
readonly_fields = fields
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(StandaloneScene)
|
||||
class StandaloneSceneAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"title",
|
||||
"work",
|
||||
"book_state",
|
||||
"book_chapter_key",
|
||||
"revision",
|
||||
"status",
|
||||
"target_words",
|
||||
"word_count",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("status", "work__series", "work")
|
||||
search_fields = ("title", "scene_key", "brief", "prose")
|
||||
inlines = (SceneContextCitationInline,)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return tuple(field.name for field in self.model._meta.fields)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(SceneIdeation)
|
||||
class SceneIdeationAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"work",
|
||||
"book_state",
|
||||
"target_book",
|
||||
"candidate_count",
|
||||
"context_pack_sha256",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("work__series", "work")
|
||||
search_fields = ("target_book", "focus", "work__title")
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return tuple(field.name for field in self.model._meta.fields)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(BookStateVersion)
|
||||
class BookStateVersionAdmin(admin.ModelAdmin):
|
||||
list_display = ("work", "version", "status", "sha256", "created_at")
|
||||
list_filter = ("status", "work__series", "work")
|
||||
search_fields = ("work__title", "sha256", "approved_by")
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return tuple(field.name for field in self.model._meta.fields)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(BookRun)
|
||||
class BookRunAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"book_state",
|
||||
"status",
|
||||
"current_chapter_key",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("status", "book_state__work")
|
||||
search_fields = ("book_state__work__title", "current_chapter_key", "failure_reason")
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return tuple(field.name for field in self.model._meta.fields)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
8
control_plane/authoring/apps.py
Normal file
8
control_plane/authoring/apps.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthoringConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "control_plane.authoring"
|
||||
1265
control_plane/authoring/book_state.py
Normal file
1265
control_plane/authoring/book_state.py
Normal file
File diff suppressed because it is too large
Load diff
24
control_plane/authoring/checkpoints.py
Normal file
24
control_plane/authoring/checkpoints.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
@contextmanager
|
||||
def open_story_checkpointer() -> Iterator[object]:
|
||||
database_url = os.environ.get("DATABASE_URL", "")
|
||||
if database_url.startswith(("postgres://", "postgresql://")):
|
||||
try:
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"Spark story workflows require langgraph-checkpoint-postgres; install project dependencies"
|
||||
) from exc
|
||||
with PostgresSaver.from_conn_string(database_url) as saver:
|
||||
saver.setup()
|
||||
yield saver
|
||||
return
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
yield MemorySaver()
|
||||
88
control_plane/authoring/epub.py
Normal file
88
control_plane/authoring/epub.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def write_epub(*, title: str, series: str, chapters: list[dict[str, str]], destination: Path) -> Path:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest = [
|
||||
'<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
|
||||
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>',
|
||||
'<item id="css" href="style.css" media-type="text/css"/>',
|
||||
'<item id="title" href="title.xhtml" media-type="application/xhtml+xml"/>',
|
||||
]
|
||||
spine = ['<itemref idref="title"/>']
|
||||
navigation = []
|
||||
ncx = []
|
||||
with zipfile.ZipFile(destination, "w") as archive:
|
||||
archive.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED)
|
||||
archive.writestr(
|
||||
"META-INF/container.xml",
|
||||
'<?xml version="1.0"?><container version="1.0" '
|
||||
'xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles>'
|
||||
'<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>'
|
||||
"</rootfiles></container>",
|
||||
)
|
||||
archive.writestr(
|
||||
"OEBPS/style.css",
|
||||
"body{font-family:serif;line-height:1.45;margin:5%}h1{text-align:center}"
|
||||
"p{text-indent:1.2em;margin:0 0 .35em}.first{text-indent:0}.title{text-align:center;margin-top:30%}",
|
||||
)
|
||||
archive.writestr(
|
||||
"OEBPS/title.xhtml",
|
||||
_xhtml(title, f'<div class="title"><h1>{html.escape(title)}</h1><p>{html.escape(series)}</p></div>'),
|
||||
)
|
||||
for index, chapter in enumerate(chapters, start=1):
|
||||
filename = f"chapter-{index}.xhtml"
|
||||
item_id = f"chapter-{index}"
|
||||
chapter_title = chapter.get("title") or f"Chapter {index}"
|
||||
manifest.append(
|
||||
f'<item id="{item_id}" href="{filename}" media-type="application/xhtml+xml"/>'
|
||||
)
|
||||
spine.append(f'<itemref idref="{item_id}"/>')
|
||||
navigation.append(
|
||||
f'<li><a href="{filename}">{html.escape(chapter_title)}</a></li>'
|
||||
)
|
||||
ncx.append(
|
||||
f'<navPoint id="n{index}" playOrder="{index}"><navLabel><text>{html.escape(chapter_title)}</text></navLabel>'
|
||||
f'<content src="{filename}"/></navPoint>'
|
||||
)
|
||||
paragraphs = []
|
||||
for paragraph_index, paragraph in enumerate(
|
||||
part.strip() for part in re.split(r"\n\s*\n", chapter.get("content", "")) if part.strip()
|
||||
):
|
||||
class_name = ' class="first"' if paragraph_index == 0 else ""
|
||||
paragraphs.append(f"<p{class_name}>{html.escape(paragraph)}</p>")
|
||||
archive.writestr(
|
||||
f"OEBPS/{filename}",
|
||||
_xhtml(chapter_title, f"<h1>{html.escape(chapter_title)}</h1>{''.join(paragraphs)}"),
|
||||
)
|
||||
archive.writestr(
|
||||
"OEBPS/nav.xhtml",
|
||||
_xhtml(title, f'<nav xmlns:epub="http://www.idpf.org/2007/ops" epub:type="toc"><ol>{"".join(navigation)}</ol></nav>'),
|
||||
)
|
||||
archive.writestr(
|
||||
"OEBPS/toc.ncx",
|
||||
f'<?xml version="1.0"?><ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">'
|
||||
f"<docTitle><text>{html.escape(title)}</text></docTitle><navMap>{''.join(ncx)}</navMap></ncx>",
|
||||
)
|
||||
archive.writestr(
|
||||
"OEBPS/content.opf",
|
||||
f'<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid" version="3.0">'
|
||||
f'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="bookid">artifex-{html.escape(title)}</dc:identifier>'
|
||||
f"<dc:title>{html.escape(title)}</dc:title><dc:language>en</dc:language></metadata>"
|
||||
f'<manifest>{"".join(manifest)}</manifest><spine toc="ncx">{"".join(spine)}</spine></package>',
|
||||
)
|
||||
return destination
|
||||
|
||||
|
||||
def _xhtml(title: str, body: str) -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<html xmlns="http://www.w3.org/1999/xhtml"><head>'
|
||||
f"<title>{html.escape(title)}</title><link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"/>"
|
||||
f"</head><body>{body}</body></html>"
|
||||
)
|
||||
0
control_plane/authoring/management/__init__.py
Normal file
0
control_plane/authoring/management/__init__.py
Normal file
0
control_plane/authoring/management/commands/__init__.py
Normal file
0
control_plane/authoring/management/commands/__init__.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db.models import Max
|
||||
from django.utils import timezone
|
||||
|
||||
from control_plane.authoring.models import ChapterRevision, RevisionStatus
|
||||
from control_plane.authoring.services import DjangoStoryWorkflowServices
|
||||
from control_plane.resources.models import ModelRequest
|
||||
from model_router.providers import providers_from_resources
|
||||
from model_router.router import ModelRouter
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate an isolated prose candidate from an existing chapter revision."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument("--source-revision")
|
||||
parser.add_argument("--review-revision")
|
||||
parser.add_argument("--model", default="qwen")
|
||||
parser.add_argument("--review", action="store_true")
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
if options["review_revision"]:
|
||||
self._review_existing(options["review_revision"])
|
||||
return
|
||||
if not options["source_revision"]:
|
||||
raise CommandError("provide --source-revision or --review-revision")
|
||||
source = ChapterRevision.objects.select_related("chapter__story__project").get(
|
||||
id=options["source_revision"]
|
||||
)
|
||||
model = str(options["model"]).strip().lower()
|
||||
os.environ["ARTIFEX_STORY_PROSE_MODEL"] = model
|
||||
bible = source.chapter.story.bible_versions.filter(
|
||||
approved_at__isnull=False
|
||||
).latest("version")
|
||||
outline = source.chapter.story.outline_versions.filter(
|
||||
approved_at__isnull=False
|
||||
).latest("version")
|
||||
next_number = (
|
||||
source.chapter.revisions.aggregate(value=Max("revision"))["value"] or 0
|
||||
) + 1
|
||||
candidate = ChapterRevision.objects.create(
|
||||
chapter=source.chapter,
|
||||
revision=next_number,
|
||||
status=RevisionStatus.DRAFT,
|
||||
parent=source,
|
||||
source_revision=source.source_revision,
|
||||
story_bible=bible,
|
||||
outline=outline,
|
||||
scene_plan=source.scene_plan,
|
||||
graph_thread_id=f"benchmark-{model}-{source.id}",
|
||||
generation_metadata={
|
||||
"benchmark": True,
|
||||
"benchmark_model": model,
|
||||
"benchmark_source_revision": str(source.id),
|
||||
},
|
||||
)
|
||||
providers = providers_from_resources()
|
||||
provider = providers.get(model)
|
||||
if provider is not None and provider.provider_name == "local_inference":
|
||||
config = dict(provider.resource.config)
|
||||
config["temperature"] = 0.7
|
||||
config["extra_body"] = {
|
||||
**dict(config.get("extra_body") or {}),
|
||||
"top_p": 0.8,
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
}
|
||||
provider.resource.config = config
|
||||
router = ModelRouter(providers, persist_requests=True)
|
||||
services = DjangoStoryWorkflowServices(router)
|
||||
state = {"revision_id": str(candidate.id)}
|
||||
services.build_context(state)
|
||||
request_started = timezone.now()
|
||||
started = time.monotonic()
|
||||
services.draft_chapter(state)
|
||||
prose_seconds = time.monotonic() - started
|
||||
review_ids: list[str] = []
|
||||
if options["review"]:
|
||||
services.extract_continuity(state)
|
||||
for review_kind in ["continuity", "character", "pacing"]:
|
||||
review_ids.extend(services.review_chapter(state, review_kind))
|
||||
candidate.refresh_from_db()
|
||||
prose_request = (
|
||||
ModelRequest.objects.filter(
|
||||
project=source.chapter.story.project,
|
||||
logical_role="STORY_PROSE",
|
||||
model_resource__provider=provider.provider_name,
|
||||
created_at__gte=request_started,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
result = {
|
||||
"candidate_revision_id": str(candidate.id),
|
||||
"candidate_revision": candidate.revision,
|
||||
"source_revision_id": str(source.id),
|
||||
"model": prose_request.model if prose_request else model,
|
||||
"artifact_uri": candidate.artifact_uri,
|
||||
"word_count": candidate.word_count,
|
||||
"prose_seconds": round(prose_seconds, 2),
|
||||
"prompt_tokens": prose_request.prompt_tokens if prose_request else None,
|
||||
"completion_tokens": prose_request.completion_tokens if prose_request else None,
|
||||
"finding_ids": review_ids,
|
||||
}
|
||||
self.stdout.write(json.dumps(result, indent=2))
|
||||
|
||||
def _review_existing(self, revision_id: str) -> None:
|
||||
candidate = ChapterRevision.objects.select_related("chapter__story__project").get(
|
||||
id=revision_id
|
||||
)
|
||||
services = DjangoStoryWorkflowServices(
|
||||
ModelRouter(providers_from_resources(), persist_requests=True)
|
||||
)
|
||||
state = {"revision_id": str(candidate.id)}
|
||||
started = time.monotonic()
|
||||
services.extract_continuity(state)
|
||||
finding_ids: list[str] = []
|
||||
for review_kind in ["continuity", "character", "pacing"]:
|
||||
finding_ids.extend(services.review_chapter(state, review_kind))
|
||||
findings = list(
|
||||
candidate.findings.filter(id__in=finding_ids).values(
|
||||
"review_kind",
|
||||
"severity",
|
||||
"category",
|
||||
"location",
|
||||
"description",
|
||||
"suggested_revision",
|
||||
)
|
||||
)
|
||||
self.stdout.write(
|
||||
json.dumps(
|
||||
{
|
||||
"candidate_revision_id": str(candidate.id),
|
||||
"review_seconds": round(time.monotonic() - started, 2),
|
||||
"findings": findings,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
217
control_plane/authoring/management/commands/fiction_book.py
Normal file
217
control_plane/authoring/management/commands/fiction_book.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from control_plane.authoring.book_state import BookStateService
|
||||
from control_plane.authoring.models import BookRun, BookStateVersion, Work
|
||||
from model_router.providers import providers_from_resources
|
||||
from model_router.router import ModelRouter
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create, review, approve, and run versioned fiction book state."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument(
|
||||
"action",
|
||||
choices=[
|
||||
"create",
|
||||
"show",
|
||||
"validate",
|
||||
"review",
|
||||
"approve",
|
||||
"reject",
|
||||
"revise",
|
||||
"impact",
|
||||
"start-run",
|
||||
"sync-run",
|
||||
"review-run",
|
||||
],
|
||||
)
|
||||
parser.add_argument("--id")
|
||||
parser.add_argument("--run-id")
|
||||
parser.add_argument("--series-slug")
|
||||
parser.add_argument("--work-slug")
|
||||
parser.add_argument("--input", type=Path)
|
||||
parser.add_argument("--level")
|
||||
parser.add_argument("--model")
|
||||
parser.add_argument("--actor", default="management_command")
|
||||
parser.add_argument("--notes", default="")
|
||||
parser.add_argument("--force", action="store_true")
|
||||
parser.add_argument("--policy", type=Path)
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
try:
|
||||
service = BookStateService(
|
||||
ModelRouter(providers_from_resources(), persist_requests=True)
|
||||
)
|
||||
action = options["action"]
|
||||
if action == "create":
|
||||
state = service.create(
|
||||
work=self._work(options),
|
||||
content=self._read_content(options, action),
|
||||
actor=options["actor"],
|
||||
)
|
||||
self._write(self._state_payload(state))
|
||||
return
|
||||
if action in {"sync-run", "review-run"}:
|
||||
run = self._run(options)
|
||||
if action == "sync-run":
|
||||
service.sync_run(run)
|
||||
else:
|
||||
service.review_run(run, model_hint=options.get("model"))
|
||||
run.refresh_from_db()
|
||||
self._write(self._run_payload(run))
|
||||
return
|
||||
|
||||
state = self._state(options)
|
||||
if action == "validate":
|
||||
service.validate(state)
|
||||
elif action == "review":
|
||||
level = str(options.get("level") or "").strip()
|
||||
if not level:
|
||||
raise CommandError("review requires --level")
|
||||
service.review(state, level=level, model_hint=options.get("model"))
|
||||
elif action == "approve":
|
||||
service.approve(
|
||||
state,
|
||||
actor=options["actor"],
|
||||
force=options["force"],
|
||||
notes=options["notes"],
|
||||
)
|
||||
elif action == "reject":
|
||||
service.reject(state, actor=options["actor"], notes=options["notes"])
|
||||
elif action == "revise":
|
||||
revised = service.revise(
|
||||
state,
|
||||
content=self._read_content(options, action),
|
||||
actor=options["actor"],
|
||||
)
|
||||
self._write(self._state_payload(revised))
|
||||
return
|
||||
elif action == "impact":
|
||||
self._write({"book_state_id": str(state.id), "impact": service.impact(state)})
|
||||
return
|
||||
elif action == "start-run":
|
||||
run = service.start_run(state, policy=self._read_policy(options))
|
||||
self._write(self._run_payload(run))
|
||||
return
|
||||
elif action != "show":
|
||||
raise CommandError(f"unsupported action: {action}")
|
||||
state.refresh_from_db()
|
||||
self._write(self._state_payload(state))
|
||||
except CommandError:
|
||||
raise
|
||||
except (OSError, RuntimeError, TypeError, ValueError, ValidationError) as exc:
|
||||
raise CommandError(str(exc)) from exc
|
||||
|
||||
@staticmethod
|
||||
def _read_content(options: dict, action: str) -> dict[str, Any]:
|
||||
path: Path | None = options.get("input")
|
||||
if path is None:
|
||||
raise CommandError(f"{action} requires --input")
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise CommandError("input must contain a JSON object")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _work(options: dict) -> Work:
|
||||
if not options.get("series_slug") or not options.get("work_slug"):
|
||||
raise CommandError("create requires --series-slug and --work-slug")
|
||||
work = Work.objects.filter(
|
||||
series__slug=options["series_slug"], slug=options["work_slug"]
|
||||
).first()
|
||||
if work is None:
|
||||
raise CommandError("work not found; register sources first")
|
||||
return work
|
||||
|
||||
@staticmethod
|
||||
def _read_policy(options: dict) -> dict[str, Any] | None:
|
||||
path: Path | None = options.get("policy")
|
||||
if path is None:
|
||||
return None
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise CommandError("policy must contain a JSON object")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _state(options: dict) -> BookStateVersion:
|
||||
if not options.get("id"):
|
||||
raise CommandError(f"{options['action']} requires --id")
|
||||
state = (
|
||||
BookStateVersion.objects.select_related("work__series", "parent")
|
||||
.filter(id=options["id"])
|
||||
.first()
|
||||
)
|
||||
if state is None:
|
||||
raise CommandError("book state not found")
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def _run(options: dict) -> BookRun:
|
||||
if not options.get("run_id"):
|
||||
raise CommandError("sync-run requires --run-id")
|
||||
run = BookRun.objects.filter(id=options["run_id"]).first()
|
||||
if run is None:
|
||||
raise CommandError("book run not found")
|
||||
return run
|
||||
|
||||
def _write(self, payload: dict[str, Any]) -> None:
|
||||
self.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
|
||||
@staticmethod
|
||||
def _state_payload(state: BookStateVersion) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(state.id),
|
||||
"series": state.work.series.slug,
|
||||
"work": state.work.slug,
|
||||
"parent_id": str(state.parent_id) if state.parent_id else None,
|
||||
"version": state.version,
|
||||
"status": state.status,
|
||||
"content": state.content,
|
||||
"sha256": state.sha256,
|
||||
"validation": state.validation,
|
||||
"reviews": state.reviews,
|
||||
"change_summary": state.change_summary,
|
||||
"context_pack": getattr(state, "context_pack", {}),
|
||||
"context_pack_sha256": getattr(state, "context_pack_sha256", ""),
|
||||
"artifact_uri": getattr(state, "artifact_uri", ""),
|
||||
"json_artifact_uri": getattr(state, "json_artifact_uri", ""),
|
||||
"markdown_artifact_uri": getattr(state, "markdown_artifact_uri", ""),
|
||||
"approved_at": state.approved_at.isoformat() if state.approved_at else None,
|
||||
"approved_by": state.approved_by,
|
||||
"approval_notes": getattr(state, "approval_notes", ""),
|
||||
"generation_metadata": getattr(state, "generation_metadata", {}),
|
||||
"created_by": state.created_by,
|
||||
"approval_forced": state.approval_forced,
|
||||
"rejected_at": state.rejected_at.isoformat() if state.rejected_at else None,
|
||||
"rejected_by": state.rejected_by,
|
||||
"rejection_notes": state.rejection_notes,
|
||||
"created_at": state.created_at.isoformat(),
|
||||
"updated_at": state.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _run_payload(run: BookRun) -> dict[str, Any]:
|
||||
state_id = getattr(run, "state_id", None) or getattr(run, "book_state_id", None)
|
||||
return {
|
||||
"id": str(run.id),
|
||||
"book_state_id": str(state_id) if state_id else None,
|
||||
"status": run.status,
|
||||
"policy": getattr(run, "policy", {}),
|
||||
"reviews": run.reviews,
|
||||
"current_chapter_key": getattr(run, "current_chapter_key", ""),
|
||||
"progress": getattr(run, "progress", {}),
|
||||
"failure_reason": getattr(run, "failure_reason", ""),
|
||||
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
||||
"created_at": run.created_at.isoformat(),
|
||||
"updated_at": run.updated_at.isoformat(),
|
||||
}
|
||||
160
control_plane/authoring/management/commands/fiction_ideas.py
Normal file
160
control_plane/authoring/management/commands/fiction_ideas.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from control_plane.authoring.models import BookStateVersion, DocumentAuthority, SceneIdeation, Work
|
||||
from control_plane.authoring.prompts import SCENE_IDEA_TYPES
|
||||
from control_plane.authoring.standalone_scenes import (
|
||||
SceneIdeationService,
|
||||
export_scene_ideation_markdown,
|
||||
)
|
||||
from model_router.providers import providers_from_resources
|
||||
from model_router.router import ModelRouter
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Propose cited scene ideas and select one into the standalone scene workflow."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument("action", choices=["propose", "show", "export", "select"])
|
||||
parser.add_argument("--id")
|
||||
parser.add_argument("--series-slug")
|
||||
parser.add_argument("--work-slug")
|
||||
parser.add_argument("--target-book")
|
||||
parser.add_argument("--book-state")
|
||||
parser.add_argument("--chapter-key")
|
||||
parser.add_argument("--focus", default="")
|
||||
parser.add_argument("--candidate-count", type=int, default=10)
|
||||
parser.add_argument(
|
||||
"--scene-type",
|
||||
action="append",
|
||||
choices=SCENE_IDEA_TYPES,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-authority",
|
||||
action="append",
|
||||
choices=DocumentAuthority.values,
|
||||
)
|
||||
parser.add_argument("--pin-document", action="append", default=[])
|
||||
parser.add_argument("--governing-document", action="append", default=[])
|
||||
parser.add_argument("--candidate-id")
|
||||
parser.add_argument("--target-words", type=int)
|
||||
parser.add_argument("--model")
|
||||
parser.add_argument("--output", type=Path)
|
||||
parser.add_argument("--compact", action="store_true")
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
action = options["action"]
|
||||
try:
|
||||
if action == "propose":
|
||||
service = self._service()
|
||||
work = self._work(options)
|
||||
idea = service.propose(
|
||||
work=work,
|
||||
target_book=str(options.get("target_book") or ""),
|
||||
focus=options["focus"],
|
||||
candidate_count=options["candidate_count"],
|
||||
scene_types=options["scene_type"],
|
||||
authorities=options["include_authority"],
|
||||
pinned_document_keys=options["pin_document"],
|
||||
governing_document_keys=options["governing_document"],
|
||||
detail_level="compact" if options["compact"] else "full",
|
||||
model_hint=options["model"],
|
||||
book_state=self._book_state(options),
|
||||
)
|
||||
self._write_idea(idea)
|
||||
return
|
||||
idea = self._idea(options)
|
||||
if action == "export":
|
||||
output = options.get("output")
|
||||
if output is None:
|
||||
raise CommandError("export requires --output")
|
||||
export_scene_ideation_markdown(idea, output, compact=options["compact"])
|
||||
self.stdout.write(str(output))
|
||||
return
|
||||
if action == "select":
|
||||
service = self._service()
|
||||
candidate_id = str(options.get("candidate_id") or "").strip()
|
||||
if not candidate_id:
|
||||
raise CommandError("select requires --candidate-id")
|
||||
scene, created = service.select_candidate(
|
||||
idea,
|
||||
candidate_id=candidate_id,
|
||||
target_words=options["target_words"],
|
||||
book_chapter_key=options.get("chapter_key"),
|
||||
)
|
||||
idea.refresh_from_db()
|
||||
self.stdout.write(
|
||||
json.dumps(
|
||||
{
|
||||
"created": created,
|
||||
"scene_id": str(scene.id),
|
||||
"scene_status": scene.status,
|
||||
"scene_title": scene.title,
|
||||
"idea": self._payload(idea),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return
|
||||
self._write_idea(idea)
|
||||
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
||||
raise CommandError(str(exc)) from exc
|
||||
|
||||
@staticmethod
|
||||
def _service() -> SceneIdeationService:
|
||||
return SceneIdeationService(ModelRouter(providers_from_resources(), persist_requests=True))
|
||||
|
||||
def _work(self, options: dict) -> Work:
|
||||
if not options.get("series_slug") or not options.get("work_slug"):
|
||||
raise CommandError("propose requires --series-slug and --work-slug")
|
||||
work = Work.objects.filter(
|
||||
series__slug=options["series_slug"], slug=options["work_slug"]
|
||||
).first()
|
||||
if work is None:
|
||||
raise CommandError("work not found; register sources first")
|
||||
return work
|
||||
|
||||
def _idea(self, options: dict) -> SceneIdeation:
|
||||
if not options.get("id"):
|
||||
raise CommandError(f"{options['action']} requires --id")
|
||||
idea = SceneIdeation.objects.select_related("work__series").filter(id=options["id"]).first()
|
||||
if idea is None:
|
||||
raise CommandError("scene ideation not found")
|
||||
return idea
|
||||
|
||||
@staticmethod
|
||||
def _book_state(options: dict) -> BookStateVersion | None:
|
||||
state_id = options.get("book_state")
|
||||
if not state_id:
|
||||
return None
|
||||
state = BookStateVersion.objects.filter(id=state_id).first()
|
||||
if state is None:
|
||||
raise CommandError("book state not found")
|
||||
return state
|
||||
|
||||
def _write_idea(self, idea: SceneIdeation) -> None:
|
||||
self.stdout.write(json.dumps(self._payload(idea), ensure_ascii=False, indent=2))
|
||||
|
||||
@staticmethod
|
||||
def _payload(idea: SceneIdeation) -> dict:
|
||||
return {
|
||||
"id": str(idea.id),
|
||||
"series": idea.work.series.slug,
|
||||
"work": idea.work.slug,
|
||||
"book_state_id": str(idea.book_state_id) if idea.book_state_id else None,
|
||||
"target_book": idea.target_book,
|
||||
"requested_scene_types": idea.requested_scene_types,
|
||||
"focus": idea.focus,
|
||||
"authorities": idea.authorities,
|
||||
"context_pack_sha256": idea.context_pack_sha256,
|
||||
"governing_document_keys": (idea.context_pack or {}).get("governing_document_keys")
|
||||
or [],
|
||||
"citations": (idea.context_pack or {}).get("citations") or [],
|
||||
"candidates": idea.candidates,
|
||||
"generation_metadata": idea.generation_metadata,
|
||||
}
|
||||
203
control_plane/authoring/management/commands/fiction_scene.py
Normal file
203
control_plane/authoring/management/commands/fiction_scene.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from control_plane.authoring.models import (
|
||||
BookStateVersion,
|
||||
DocumentAuthority,
|
||||
StandaloneScene,
|
||||
Work,
|
||||
)
|
||||
from control_plane.authoring.standalone_scenes import StandaloneSceneService
|
||||
from model_router.providers import providers_from_resources
|
||||
from model_router.router import ModelRouter
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Plan, write, review, and approve resumable standalone fiction scenes."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument(
|
||||
"action",
|
||||
choices=[
|
||||
"create",
|
||||
"context",
|
||||
"plan",
|
||||
"approve-plan",
|
||||
"write",
|
||||
"review",
|
||||
"approve",
|
||||
"reject",
|
||||
"run",
|
||||
"show",
|
||||
],
|
||||
)
|
||||
parser.add_argument("--id")
|
||||
parser.add_argument("--series-slug")
|
||||
parser.add_argument("--work-slug")
|
||||
parser.add_argument("--title")
|
||||
parser.add_argument("--brief", type=Path)
|
||||
parser.add_argument("--target-words", type=int, default=1800)
|
||||
parser.add_argument("--constraint", action="append", default=[])
|
||||
parser.add_argument("--forbid", action="append", default=[])
|
||||
parser.add_argument("--boundary", action="append", default=[])
|
||||
parser.add_argument("--book-state")
|
||||
parser.add_argument("--chapter-key")
|
||||
parser.add_argument(
|
||||
"--include-authority",
|
||||
action="append",
|
||||
choices=DocumentAuthority.values,
|
||||
)
|
||||
parser.add_argument("--pin-document", action="append", default=[])
|
||||
parser.add_argument("--model")
|
||||
parser.add_argument("--max-attempts", type=int, default=2)
|
||||
parser.add_argument("--auto-approve-plan", action="store_true")
|
||||
parser.add_argument("--actor", default="management_command")
|
||||
parser.add_argument("--force", action="store_true")
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
service = StandaloneSceneService(
|
||||
ModelRouter(providers_from_resources(), persist_requests=True)
|
||||
)
|
||||
action = options["action"]
|
||||
if action in {"create", "run"}:
|
||||
scene = self._create(service, options)
|
||||
if action == "create":
|
||||
self._write_scene_summary(scene)
|
||||
return
|
||||
scene = service.plan(
|
||||
scene,
|
||||
authorities=options["include_authority"],
|
||||
pinned_document_keys=options["pin_document"],
|
||||
model_hint=options["model"],
|
||||
)
|
||||
if not options["auto_approve_plan"]:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Scene {scene.id} is awaiting plan review. Run fiction_scene approve-plan."
|
||||
)
|
||||
)
|
||||
self._write_scene_summary(scene)
|
||||
return
|
||||
service.approve_plan(scene)
|
||||
service.write(
|
||||
scene,
|
||||
model_hint=options["model"],
|
||||
max_attempts=options["max_attempts"],
|
||||
)
|
||||
service.review(scene, model_hint=options["model"])
|
||||
self._write_scene_summary(scene)
|
||||
return
|
||||
|
||||
scene = self._scene(options)
|
||||
try:
|
||||
if action == "plan":
|
||||
service.plan(
|
||||
scene,
|
||||
authorities=options["include_authority"],
|
||||
pinned_document_keys=options["pin_document"],
|
||||
model_hint=options["model"],
|
||||
)
|
||||
elif action == "context":
|
||||
service.prepare_context(
|
||||
scene,
|
||||
authorities=options["include_authority"],
|
||||
pinned_document_keys=options["pin_document"],
|
||||
)
|
||||
elif action == "approve-plan":
|
||||
service.approve_plan(scene)
|
||||
elif action == "write":
|
||||
service.write(
|
||||
scene,
|
||||
model_hint=options["model"],
|
||||
max_attempts=options["max_attempts"],
|
||||
)
|
||||
elif action == "review":
|
||||
service.review(scene, model_hint=options["model"])
|
||||
elif action == "approve":
|
||||
service.approve(scene, actor=options["actor"], force=options["force"])
|
||||
elif action == "reject":
|
||||
service.reject(scene, actor=options["actor"])
|
||||
elif action != "show":
|
||||
raise CommandError(f"unsupported action: {action}")
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
raise CommandError(str(exc)) from exc
|
||||
scene.refresh_from_db()
|
||||
self._write_scene_summary(scene)
|
||||
|
||||
def _create(self, service: StandaloneSceneService, options: dict) -> StandaloneScene:
|
||||
required = ["series_slug", "work_slug", "title", "brief"]
|
||||
missing = [name for name in required if not options.get(name)]
|
||||
if missing:
|
||||
raise CommandError(
|
||||
f"{options['action']} requires "
|
||||
+ ", ".join(f"--{name.replace('_', '-')}" for name in missing)
|
||||
)
|
||||
work = Work.objects.filter(
|
||||
series__slug=options["series_slug"], slug=options["work_slug"]
|
||||
).first()
|
||||
if work is None:
|
||||
raise CommandError("work not found; register sources or import the story first")
|
||||
brief_path: Path = options["brief"]
|
||||
if not brief_path.exists():
|
||||
raise CommandError(f"brief does not exist: {brief_path}")
|
||||
book_state = None
|
||||
if options.get("book_state"):
|
||||
try:
|
||||
book_state = BookStateVersion.objects.filter(
|
||||
id=options["book_state"]
|
||||
).first()
|
||||
except ValidationError as exc:
|
||||
raise CommandError(str(exc)) from exc
|
||||
if book_state is None:
|
||||
raise CommandError("book state not found")
|
||||
try:
|
||||
return service.create(
|
||||
work=work,
|
||||
title=options["title"],
|
||||
brief=brief_path.read_text(encoding="utf-8"),
|
||||
target_words=options["target_words"],
|
||||
constraints=options["constraint"],
|
||||
forbidden_events=options["forbid"],
|
||||
boundary_constraints=options["boundary"],
|
||||
book_state=book_state,
|
||||
book_chapter_key=options.get("chapter_key"),
|
||||
)
|
||||
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
||||
raise CommandError(str(exc)) from exc
|
||||
|
||||
def _scene(self, options: dict) -> StandaloneScene:
|
||||
if not options.get("id"):
|
||||
raise CommandError(f"{options['action']} requires --id")
|
||||
scene = StandaloneScene.objects.select_related(
|
||||
"work__series", "story__project", "book_state"
|
||||
).filter(id=options["id"]).first()
|
||||
if scene is None:
|
||||
raise CommandError("scene not found")
|
||||
return scene
|
||||
|
||||
def _write_scene_summary(self, scene: StandaloneScene) -> None:
|
||||
payload = {
|
||||
"id": str(scene.id),
|
||||
"title": scene.title,
|
||||
"scene_key": scene.scene_key,
|
||||
"revision": scene.revision,
|
||||
"status": scene.status,
|
||||
"target_words": scene.target_words,
|
||||
"word_count": scene.word_count,
|
||||
"context_citations": scene.context_citations.count(),
|
||||
"citations": (scene.context_pack or {}).get("citations") or [],
|
||||
"context_pack_sha256": scene.context_pack_sha256,
|
||||
"plan": scene.plan,
|
||||
"review": scene.review,
|
||||
"artifact_uri": scene.artifact_uri,
|
||||
"review_artifact_uri": scene.review_artifact_uri,
|
||||
"book_state_id": str(scene.book_state_id) if scene.book_state_id else None,
|
||||
"chapter_key": scene.book_chapter_key,
|
||||
"failure_reason": scene.failure_reason,
|
||||
}
|
||||
self.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
126
control_plane/authoring/management/commands/story_book_run.py
Normal file
126
control_plane/authoring/management/commands/story_book_run.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from control_plane.authoring.models import ChapterRevision, StoryProject
|
||||
from graph.models import GraphRun, GraphRunStatus
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run planned story chapters sequentially until completion or a blocking finding."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument("--slug", required=True)
|
||||
parser.add_argument("--from-chapter", type=int, required=True)
|
||||
parser.add_argument("--through-chapter", type=int, required=True)
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
story = StoryProject.objects.get(slug=options["slug"])
|
||||
for number in range(options["from_chapter"], options["through_chapter"] + 1):
|
||||
chapter = story.chapters.get(number=number)
|
||||
if chapter.current_revision_id and chapter.status == "APPROVED":
|
||||
self.stdout.write(f"chapter={number} already approved")
|
||||
continue
|
||||
|
||||
self.stdout.write(f"chapter={number} starting", ending="\n")
|
||||
call_command(
|
||||
"story_workflow",
|
||||
"start",
|
||||
slug=story.slug,
|
||||
chapter=number,
|
||||
fresh=True,
|
||||
supersede_active=True,
|
||||
)
|
||||
graph_run = (
|
||||
GraphRun.objects.filter(project=story.project)
|
||||
.order_by("-started_at", "-id")
|
||||
.first()
|
||||
)
|
||||
if graph_run is None or graph_run.current_node != "approve_plan":
|
||||
raise CommandError(f"chapter {number} did not reach plan approval")
|
||||
|
||||
call_command(
|
||||
"story_workflow",
|
||||
"resume",
|
||||
graph_run=graph_run.id,
|
||||
decision="approve",
|
||||
)
|
||||
graph_run.refresh_from_db()
|
||||
if graph_run.status != GraphRunStatus.PAUSED or graph_run.current_node != "approve_chapter":
|
||||
raise CommandError(f"chapter {number} did not reach chapter approval")
|
||||
|
||||
revision = ChapterRevision.objects.get(
|
||||
id=graph_run.metadata["current_revision_id"]
|
||||
)
|
||||
document = revision.state_document
|
||||
blocking = revision.findings.filter(
|
||||
status="OPEN", severity__in=["HIGH", "CRITICAL"]
|
||||
).count()
|
||||
if document.verdict != "PASS" and self._repair_evidence(revision):
|
||||
call_command(
|
||||
"story_candidate_step",
|
||||
"audit",
|
||||
chapter=number,
|
||||
revision=revision.revision,
|
||||
)
|
||||
revision.refresh_from_db()
|
||||
document.refresh_from_db()
|
||||
blocking = revision.findings.filter(
|
||||
status="OPEN", severity__in=["HIGH", "CRITICAL"]
|
||||
).count()
|
||||
if document.status != "VALIDATED" or document.verdict != "PASS" or blocking:
|
||||
raise CommandError(
|
||||
f"chapter {number} blocked: revision={revision.revision} "
|
||||
f"state={document.status}/{document.verdict} findings={blocking} "
|
||||
f"graph_run={graph_run.id}"
|
||||
)
|
||||
|
||||
call_command(
|
||||
"story_workflow",
|
||||
"resume",
|
||||
graph_run=graph_run.id,
|
||||
decision="approve",
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"chapter={number} committed revision={revision.revision} "
|
||||
f"graph_run={graph_run.id}"
|
||||
)
|
||||
)
|
||||
|
||||
def _repair_evidence(self, revision: ChapterRevision) -> bool:
|
||||
findings = revision.findings.filter(
|
||||
status="OPEN", severity__in=["HIGH", "CRITICAL"], review_kind="state_contract"
|
||||
)
|
||||
sequences = []
|
||||
for finding in findings:
|
||||
match = re.search(r"State change (\d+)", finding.description)
|
||||
if match is None or "no exact supporting quotation" not in finding.description:
|
||||
return False
|
||||
sequences.append(int(match.group(1)))
|
||||
if not sequences:
|
||||
return False
|
||||
|
||||
lines = [line.strip() for line in revision.prose.splitlines() if line.strip()]
|
||||
for sequence in sequences:
|
||||
change = revision.state_document.changes.get(sequence=sequence)
|
||||
best = max(
|
||||
lines,
|
||||
key=lambda line: SequenceMatcher(None, change.evidence_quote, line).ratio(),
|
||||
)
|
||||
score = SequenceMatcher(None, change.evidence_quote, best).ratio()
|
||||
if score < 0.45:
|
||||
return False
|
||||
call_command(
|
||||
"story_candidate_step",
|
||||
"correct-evidence",
|
||||
chapter=revision.chapter.number,
|
||||
revision=revision.revision,
|
||||
change_sequence=sequence,
|
||||
evidence=best,
|
||||
)
|
||||
return True
|
||||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
|
|
@ -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}"
|
||||
)
|
||||
)
|
||||
147
control_plane/authoring/management/commands/story_scene_probe.py
Normal file
147
control_plane/authoring/management/commands/story_scene_probe.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from control_plane.authoring.models import Chapter, ChapterRevision
|
||||
from control_plane.authoring.prompts import DEFAULT_SCENE_DRAFT_SYSTEM, DEFAULT_SCENE_DRAFT_TEMPLATE
|
||||
from control_plane.authoring.services import DjangoStoryWorkflowServices, scene_draft_packet
|
||||
from control_plane.authoring.streaming import atomic_write_text, word_count
|
||||
from model_router.providers import providers_from_resources
|
||||
from model_router.router import ModelCapability, ModelRequestContract, ModelRouter
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Render or generate exactly one isolated story scene with no retries."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument("--revision", type=int, required=True)
|
||||
parser.add_argument("--scene", type=int, required=True)
|
||||
parser.add_argument("--style-revision", type=int)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
choices=["qwen", "luna", "sol", "terra", "gpt54", "gpt55"],
|
||||
default="qwen",
|
||||
)
|
||||
parser.add_argument("--thinking-budget", type=int, default=0)
|
||||
parser.add_argument("--label", default="probe")
|
||||
parser.add_argument("--generate", action="store_true")
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
revision = (
|
||||
ChapterRevision.objects.select_related("chapter__story__project", "context_snapshot")
|
||||
.filter(revision=options["revision"], chapter__number=2)
|
||||
.first()
|
||||
)
|
||||
if revision is None:
|
||||
raise CommandError("revision not found")
|
||||
style = None
|
||||
if options["style_revision"]:
|
||||
style = ChapterRevision.objects.filter(
|
||||
chapter=revision.chapter, revision=options["style_revision"]
|
||||
).first()
|
||||
if style is None or not style.prose:
|
||||
raise CommandError("style revision not found or empty")
|
||||
previous = (
|
||||
Chapter.objects.select_related("current_revision")
|
||||
.filter(story=revision.chapter.story, number=revision.chapter.number - 1)
|
||||
.first()
|
||||
)
|
||||
if previous is None or previous.current_revision is None or not previous.current_revision.prose:
|
||||
raise CommandError("approved previous chapter is unavailable")
|
||||
scene = next(
|
||||
(item for item in revision.scene_plan.get("scenes") or [] if int(item.get("number") or 0) == options["scene"]),
|
||||
None,
|
||||
)
|
||||
if scene is None:
|
||||
raise CommandError("scene not found")
|
||||
packet = scene_draft_packet(revision.scene_plan, scene)
|
||||
context = revision.context_snapshot.content
|
||||
draft_context = {
|
||||
"chapter": context["chapter"],
|
||||
"structured_canon": context["structured_canon"],
|
||||
"previous_chapter_tail": " ".join(context["previous_chapter_tail"].split()[-350:]),
|
||||
}
|
||||
services = DjangoStoryWorkflowServices(ModelRouter({}))
|
||||
prompt = services._render_prompt(
|
||||
"STORY_SCENE_PROSE",
|
||||
DEFAULT_SCENE_DRAFT_SYSTEM,
|
||||
DEFAULT_SCENE_DRAFT_TEMPLATE,
|
||||
chapter_number=revision.chapter.number,
|
||||
chapter_title=revision.chapter.title,
|
||||
scene_number=options["scene"],
|
||||
context=json.dumps(draft_context, ensure_ascii=False, indent=2),
|
||||
scene_plan=json.dumps(packet["chapter_scope"], ensure_ascii=False, indent=2),
|
||||
scene=json.dumps(packet["scene"], ensure_ascii=False, indent=2),
|
||||
source_chapter=previous.current_revision.prose,
|
||||
style_excerpt=(" ".join(style.prose.split()[:350]) if style else "[none]"),
|
||||
previous_tail="[chapter opening]",
|
||||
target_words=packet["target_words"],
|
||||
boundary_constraints=(
|
||||
"Do not decide to sell the waystone. Do not introduce a buyer, bid, price, deduction, sale term, "
|
||||
"or payment. Those belong to later scenes. End with the sealed transfer beginning."
|
||||
),
|
||||
)
|
||||
self.stdout.write(
|
||||
f"scene={options['scene']} required_beats={len(packet['scene']['beats'])} "
|
||||
f"target_words={packet['target_words']} prompt_chars={len(prompt)} "
|
||||
f"estimated_tokens={len(prompt) // 4}"
|
||||
)
|
||||
self.stdout.write(json.dumps(packet["scene"], ensure_ascii=False, indent=2))
|
||||
if not options["generate"]:
|
||||
return
|
||||
providers = providers_from_resources()
|
||||
model = options["model"]
|
||||
provider = providers.get("luna" if model in {"gpt54", "gpt55"} else model)
|
||||
if provider is None:
|
||||
raise CommandError(f"{model} provider is unavailable")
|
||||
if model in {"gpt54", "gpt55"}:
|
||||
config = dict(provider.resource.config)
|
||||
model_name = "gpt-5.4" if model == "gpt54" else "gpt-5.5"
|
||||
config["command"] = f"/home/daniel/.opencode/bin/opencode run --model openai/{model_name}"
|
||||
provider.resource.config = config
|
||||
provider.resource.config["timeout_seconds"] = 180
|
||||
if model == "qwen":
|
||||
provider.resource.config["retry_attempts"] = 1
|
||||
if options["thinking_budget"]:
|
||||
extra_body = dict(provider.resource.config.get("extra_body") or {})
|
||||
chat_kwargs = dict(extra_body.get("chat_template_kwargs") or {})
|
||||
chat_kwargs["enable_thinking"] = True
|
||||
extra_body["chat_template_kwargs"] = chat_kwargs
|
||||
provider.resource.config["extra_body"] = extra_body
|
||||
router = ModelRouter({model: provider})
|
||||
output = Path(revision.chapter.story.artifact_root) / "probes" / (
|
||||
f"chapter-{revision.chapter.number:02d}-r{revision.revision}-scene-{options['scene']:02d}-"
|
||||
f"{options['label']}.partial.md"
|
||||
)
|
||||
if output.exists():
|
||||
raise CommandError(f"probe artifact already exists: {output}")
|
||||
started = time.monotonic()
|
||||
response = router.complete(
|
||||
ModelRequestContract(
|
||||
purpose=ModelCapability.STORY_PROSE,
|
||||
prompt=prompt,
|
||||
model_hint=model,
|
||||
token_budget=4000 + max(0, options["thinking_budget"]),
|
||||
project=revision.chapter.story.project,
|
||||
)
|
||||
)
|
||||
prose, marker, _ = response.content.partition("[[END_OF_SCENE]]")
|
||||
prose = prose.strip()
|
||||
words = word_count(prose)
|
||||
if not marker:
|
||||
raise CommandError("model response omitted [[END_OF_SCENE]]")
|
||||
if words < max(500, int(packet["target_words"] * 0.6)):
|
||||
raise CommandError(f"scene is too short: {words} words")
|
||||
if words > max(2500, int(packet["target_words"] * 1.8)):
|
||||
raise CommandError(f"scene is too long: {words} words")
|
||||
atomic_write_text(output, prose)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"completed model={model} elapsed_seconds={time.monotonic() - started:.1f} "
|
||||
f"words={words} artifact={output}"
|
||||
)
|
||||
)
|
||||
90
control_plane/authoring/management/commands/story_sources.py
Normal file
90
control_plane/authoring/management/commands/story_sources.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from control_plane.authoring.models import (
|
||||
DocumentAuthority,
|
||||
DocumentType,
|
||||
Series,
|
||||
Work,
|
||||
WorkType,
|
||||
)
|
||||
from control_plane.authoring.sources import discover_source_paths, inspect_source, register_source
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Register immutable, authority-labelled story source documents and passages."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument("action", choices=["register"])
|
||||
parser.add_argument("--root", type=Path, required=True)
|
||||
parser.add_argument("--series-slug", required=True)
|
||||
parser.add_argument("--series-title", required=True)
|
||||
parser.add_argument("--work-slug", required=True)
|
||||
parser.add_argument("--work-title", required=True)
|
||||
parser.add_argument("--work-type", choices=WorkType.values, default=WorkType.BOOK)
|
||||
parser.add_argument("--authority", choices=DocumentAuthority.values, required=True)
|
||||
parser.add_argument(
|
||||
"--document-type", choices=DocumentType.values, default=DocumentType.OTHER
|
||||
)
|
||||
parser.add_argument("--include-glob", action="append", default=[])
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
root: Path = options["root"]
|
||||
if not root.exists():
|
||||
raise CommandError(f"source root does not exist: {root}")
|
||||
paths = discover_source_paths(root, options["include_glob"])
|
||||
if not paths:
|
||||
raise CommandError(f"no supported UTF-8 source files found under {root}")
|
||||
|
||||
if options["dry_run"]:
|
||||
for path in paths:
|
||||
result = inspect_source(path, root)
|
||||
self.stdout.write(
|
||||
f"DRY-RUN {result.logical_key} sha256={result.source_sha256} "
|
||||
f"passages={result.passage_count} authority={options['authority']}"
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Discovered {len(paths)} source files; no changes made.")
|
||||
)
|
||||
return
|
||||
|
||||
series, _ = Series.objects.get_or_create(
|
||||
slug=options["series_slug"], defaults={"title": options["series_title"]}
|
||||
)
|
||||
work, _ = Work.objects.get_or_create(
|
||||
series=series,
|
||||
slug=options["work_slug"],
|
||||
defaults={
|
||||
"title": options["work_title"],
|
||||
"work_type": options["work_type"],
|
||||
},
|
||||
)
|
||||
counts = {"created": 0, "versioned": 0, "unchanged": 0}
|
||||
for path in paths:
|
||||
try:
|
||||
result = register_source(
|
||||
work=work,
|
||||
path=path,
|
||||
root=root,
|
||||
authority=options["authority"],
|
||||
document_type=options["document_type"],
|
||||
)
|
||||
except UnicodeDecodeError as exc:
|
||||
raise CommandError(f"source is not valid UTF-8: {path}") from exc
|
||||
except ValueError as exc:
|
||||
raise CommandError(str(exc)) from exc
|
||||
counts[result.status] += 1
|
||||
self.stdout.write(
|
||||
f"{result.status.upper()} {result.logical_key} v{result.version} "
|
||||
f"passages={result.passage_count}"
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Registered {len(paths)} files: {counts['created']} created, "
|
||||
f"{counts['versioned']} versioned, {counts['unchanged']} unchanged."
|
||||
)
|
||||
)
|
||||
109
control_plane/authoring/management/commands/story_state.py
Normal file
109
control_plane/authoring/management/commands/story_state.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from control_plane.authoring.models import ChapterRevision, ChapterStateDocument, StateChange
|
||||
from control_plane.authoring.services import DjangoStoryWorkflowServices
|
||||
from graph.models import GraphApproval, GraphApprovalStatus
|
||||
from model_router.providers import providers_from_resources
|
||||
from model_router.router import ModelRouter
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Build, inspect, or query the immutable story state ledger."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument("action", choices=["build", "show", "history"])
|
||||
parser.add_argument("--revision")
|
||||
parser.add_argument("--slug")
|
||||
parser.add_argument("--entity")
|
||||
parser.add_argument("--reuse-extraction", action="store_true")
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
if options["action"] == "history":
|
||||
self._history(options)
|
||||
return
|
||||
if not options["revision"]:
|
||||
raise CommandError("build and show require --revision")
|
||||
revision = ChapterRevision.objects.select_related("chapter__story").get(
|
||||
id=options["revision"]
|
||||
)
|
||||
if options["action"] == "show":
|
||||
self._show(revision)
|
||||
return
|
||||
services = DjangoStoryWorkflowServices(
|
||||
ModelRouter(providers_from_resources(), persist_requests=True)
|
||||
)
|
||||
state = {
|
||||
"revision_id": str(revision.id),
|
||||
"story_id": str(revision.chapter.story_id),
|
||||
"context_snapshot_id": str(revision.context_snapshot_id or ""),
|
||||
}
|
||||
if not options["reuse_extraction"]:
|
||||
services.extract_continuity(state)
|
||||
elif not ChapterStateDocument.objects.filter(revision=revision).exists():
|
||||
raise CommandError("--reuse-extraction requested but no state document exists")
|
||||
result = services.judge_state_contract(state)
|
||||
payload = services.state_approval_payload(state)
|
||||
approval = GraphApproval.objects.filter(
|
||||
reason=f"STORY_CHAPTER_APPROVAL:{revision.id}",
|
||||
status=GraphApprovalStatus.PENDING,
|
||||
).first()
|
||||
if approval is not None:
|
||||
approval.payload = {**approval.payload, **payload}
|
||||
approval.save(update_fields=["payload", "updated_at"])
|
||||
self.stdout.write(json.dumps({**result, **payload}, ensure_ascii=False, indent=2))
|
||||
|
||||
def _show(self, revision: ChapterRevision) -> None:
|
||||
document = ChapterStateDocument.objects.get(revision=revision)
|
||||
self.stdout.write(
|
||||
json.dumps(
|
||||
{
|
||||
"id": str(document.id),
|
||||
"status": document.status,
|
||||
"verdict": document.verdict,
|
||||
"coverage": document.coverage,
|
||||
"observed_state": document.observed_state,
|
||||
"proposed_delta": document.proposed_delta,
|
||||
"json_artifact_uri": document.json_artifact_uri,
|
||||
"markdown_artifact_uri": document.markdown_artifact_uri,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
def _history(self, options: dict) -> None:
|
||||
if not options.get("slug") or not options.get("entity"):
|
||||
raise CommandError("history requires --slug and --entity")
|
||||
changes = StateChange.objects.filter(
|
||||
story__slug=options["slug"],
|
||||
entity__entity_key=options["entity"],
|
||||
status="COMMITTED",
|
||||
).select_related("revision__chapter", "related_entity")
|
||||
self.stdout.write(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"chapter": change.effective_chapter,
|
||||
"revision_id": str(change.revision_id),
|
||||
"sequence": change.sequence,
|
||||
"change_type": change.change_type,
|
||||
"predicate": change.predicate,
|
||||
"operation": change.operation,
|
||||
"previous_value": change.previous_value,
|
||||
"new_value": change.new_value,
|
||||
"related_entity": (
|
||||
change.related_entity.entity_key if change.related_entity else None
|
||||
),
|
||||
"evidence_quote": change.evidence_quote,
|
||||
"evidence_location": change.evidence_location,
|
||||
}
|
||||
for change in changes.order_by("effective_chapter", "sequence")
|
||||
],
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
|
@ -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']}"))
|
||||
313
control_plane/authoring/management/commands/story_workflow.py
Normal file
313
control_plane/authoring/management/commands/story_workflow.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db.models import Max
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from control_plane.authoring.checkpoints import open_story_checkpointer
|
||||
from control_plane.authoring.models import (
|
||||
CanonSnapshot,
|
||||
Chapter,
|
||||
ChapterContract,
|
||||
ChapterRevision,
|
||||
ChapterStateDocument,
|
||||
ChapterStatus,
|
||||
OutlineVersion,
|
||||
RevisionStatus,
|
||||
Series,
|
||||
StateDocumentStatus,
|
||||
StoryBibleVersion,
|
||||
StoryProject,
|
||||
StoryStatus,
|
||||
Work,
|
||||
text_sha256,
|
||||
)
|
||||
from control_plane.authoring.runner import StoryWorkflowRunner
|
||||
from control_plane.authoring.services import DjangoStoryWorkflowServices
|
||||
from control_plane.authoring.workflow import build_story_workflow
|
||||
from control_plane.projects.models import Project
|
||||
from graph.models import GraphApprovalStatus, GraphRun, GraphRunStatus
|
||||
from model_router.providers import providers_from_resources
|
||||
from model_router.router import ModelRouter
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Import, start, or resume a checkpointed story-authoring workflow."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument("action", choices=["import", "start", "resume"])
|
||||
parser.add_argument("--slug")
|
||||
parser.add_argument("--title")
|
||||
parser.add_argument("--series", default="")
|
||||
parser.add_argument("--brief", type=Path)
|
||||
parser.add_argument("--plan", type=Path)
|
||||
parser.add_argument("--source-dir", type=Path)
|
||||
parser.add_argument("--source", type=Path)
|
||||
parser.add_argument("--artifact-root", type=Path)
|
||||
parser.add_argument("--locked-through", type=int, default=1)
|
||||
parser.add_argument("--chapter", type=int)
|
||||
parser.add_argument("--graph-run", type=int)
|
||||
parser.add_argument(
|
||||
"--decision", choices=["approve", "request_revision", "reject", "retry"]
|
||||
)
|
||||
parser.add_argument("--notes", default="")
|
||||
parser.add_argument("--fresh", action="store_true")
|
||||
parser.add_argument("--supersede-active", action="store_true")
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
action = options["action"]
|
||||
if action == "import":
|
||||
self._import(options)
|
||||
elif action == "start":
|
||||
self._start(options)
|
||||
else:
|
||||
self._resume(options)
|
||||
|
||||
def _import(self, options: dict) -> None:
|
||||
required = ["slug", "title", "brief", "plan"]
|
||||
missing = [name for name in required if not options.get(name)]
|
||||
if missing:
|
||||
raise CommandError("import requires " + ", ".join(f"--{name}" for name in missing))
|
||||
brief_path: Path = options["brief"]
|
||||
plan_path: Path = options["plan"]
|
||||
if not brief_path.exists() or not plan_path.exists():
|
||||
raise CommandError("brief or plan path does not exist")
|
||||
brief = brief_path.read_text(encoding="utf-8")
|
||||
plan = json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
project, _ = Project.objects.get_or_create(
|
||||
name=options["title"],
|
||||
defaults={
|
||||
"project_type": "STORY",
|
||||
"goal": f"Write and revise {options['title']}",
|
||||
"status": "READY",
|
||||
},
|
||||
)
|
||||
series_title = options["series"] or options["title"]
|
||||
series_slug = slugify(series_title)
|
||||
series, _ = Series.objects.get_or_create(
|
||||
slug=series_slug, defaults={"title": series_title}
|
||||
)
|
||||
work, _ = Work.objects.get_or_create(
|
||||
series=series,
|
||||
slug=options["slug"],
|
||||
defaults={"title": options["title"]},
|
||||
)
|
||||
story, _ = StoryProject.objects.update_or_create(
|
||||
slug=options["slug"],
|
||||
defaults={
|
||||
"project": project,
|
||||
"work": work,
|
||||
"title": options["title"],
|
||||
"series": options["series"],
|
||||
"status": StoryStatus.REVISING,
|
||||
"artifact_root": str(options.get("artifact_root") or ""),
|
||||
},
|
||||
)
|
||||
bible_version = (story.bible_versions.aggregate(value=Max("version"))["value"] or 0) + 1
|
||||
bible = StoryBibleVersion.objects.create(
|
||||
story=story, version=bible_version, content=brief, approved_at=timezone.now()
|
||||
)
|
||||
outline_version = (story.outline_versions.aggregate(value=Max("version"))["value"] or 0) + 1
|
||||
outline = OutlineVersion.objects.create(
|
||||
story=story, version=outline_version, content=plan, approved_at=timezone.now()
|
||||
)
|
||||
for item in plan.get("chapters") or []:
|
||||
Chapter.objects.update_or_create(
|
||||
story=story,
|
||||
number=int(item["number"]),
|
||||
defaults={"title": item["title"]},
|
||||
)
|
||||
if options.get("source_dir"):
|
||||
self._import_locked_chapters(
|
||||
story,
|
||||
bible,
|
||||
outline,
|
||||
options["source_dir"],
|
||||
int(options["locked_through"]),
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Imported {story.title}: bible v{bible.version}, outline v{outline.version}, "
|
||||
f"{story.chapters.count()} chapters"
|
||||
)
|
||||
)
|
||||
|
||||
def _import_locked_chapters(
|
||||
self,
|
||||
story: StoryProject,
|
||||
bible: StoryBibleVersion,
|
||||
outline: OutlineVersion,
|
||||
source_dir: Path,
|
||||
locked_through: int,
|
||||
) -> None:
|
||||
for number in range(1, locked_through + 1):
|
||||
matches = sorted(source_dir.glob(f"*-chapter-{number:02d}-*.md"))
|
||||
matches = [path for path in matches if ".partial." not in path.name]
|
||||
if not matches:
|
||||
raise CommandError(f"no canonical source found for Chapter {number} in {source_dir}")
|
||||
chapter = story.chapters.get(number=number)
|
||||
prose = matches[0].read_text(encoding="utf-8")
|
||||
state_path = next(iter(sorted(source_dir.glob(f"*-chapter-{number:02d}.state.json"))), None)
|
||||
continuity = (
|
||||
json.loads(state_path.read_text(encoding="utf-8")) if state_path else {}
|
||||
)
|
||||
revision_number = (
|
||||
chapter.revisions.aggregate(value=Max("revision"))["value"] or 0
|
||||
) + 1
|
||||
revision = ChapterRevision.objects.create(
|
||||
chapter=chapter,
|
||||
revision=revision_number,
|
||||
status=RevisionStatus.APPROVED,
|
||||
story_bible=bible,
|
||||
outline=outline,
|
||||
prose=prose,
|
||||
continuity_state=continuity,
|
||||
artifact_uri=str(matches[0]),
|
||||
approved_at=timezone.now(),
|
||||
)
|
||||
chapter.current_revision = revision
|
||||
chapter.status = ChapterStatus.APPROVED
|
||||
chapter.save(update_fields=["current_revision", "status", "updated_at"])
|
||||
canonical = json.dumps(
|
||||
continuity, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
CanonSnapshot.objects.create(
|
||||
story=story,
|
||||
through_chapter=number,
|
||||
version=(story.canon_snapshots.aggregate(value=Max("version"))["value"] or 0)
|
||||
+ 1,
|
||||
state=continuity,
|
||||
source_revision=revision,
|
||||
sha256=text_sha256(canonical),
|
||||
)
|
||||
contract = ChapterContract.objects.create(
|
||||
revision=revision,
|
||||
requirements=[],
|
||||
scene_plan_sha256=text_sha256("{}"),
|
||||
approved_at=revision.approved_at,
|
||||
)
|
||||
ChapterStateDocument.objects.create(
|
||||
revision=revision,
|
||||
contract=contract,
|
||||
status=StateDocumentStatus.COMMITTED,
|
||||
start_state={},
|
||||
observed_state=continuity,
|
||||
proposed_delta=[],
|
||||
coverage={"requirements": [], "counts": {}},
|
||||
verdict="PASS",
|
||||
sha256=text_sha256(canonical),
|
||||
model_metadata={"imported_baseline": True},
|
||||
validated_at=revision.approved_at,
|
||||
committed_at=revision.approved_at,
|
||||
)
|
||||
|
||||
def _start(self, options: dict) -> None:
|
||||
if not options.get("slug") or not options.get("chapter"):
|
||||
raise CommandError("start requires --slug and --chapter")
|
||||
story = StoryProject.objects.get(slug=options["slug"])
|
||||
chapter = story.chapters.get(number=options["chapter"])
|
||||
if options.get("fresh") and options.get("source"):
|
||||
raise CommandError("--fresh cannot be combined with --source")
|
||||
if options.get("supersede_active"):
|
||||
self._supersede_active_runs(story, chapter)
|
||||
bible = story.bible_versions.filter(approved_at__isnull=False).order_by("-version").first()
|
||||
outline = story.outline_versions.filter(approved_at__isnull=False).order_by("-version").first()
|
||||
if bible is None or outline is None:
|
||||
raise CommandError("story needs approved bible and outline versions")
|
||||
source_revision = None
|
||||
if options.get("source"):
|
||||
source_path: Path = options["source"]
|
||||
source_revision = ChapterRevision.objects.create(
|
||||
chapter=chapter,
|
||||
revision=(chapter.revisions.aggregate(value=Max("revision"))["value"] or 0) + 1,
|
||||
status=RevisionStatus.SOURCE,
|
||||
story_bible=bible,
|
||||
outline=outline,
|
||||
prose=source_path.read_text(encoding="utf-8"),
|
||||
artifact_uri=str(source_path),
|
||||
)
|
||||
revision = ChapterRevision.objects.create(
|
||||
chapter=chapter,
|
||||
revision=(chapter.revisions.aggregate(value=Max("revision"))["value"] or 0) + 1,
|
||||
status=RevisionStatus.DRAFT,
|
||||
source_revision=source_revision,
|
||||
story_bible=bible,
|
||||
outline=outline,
|
||||
generation_metadata={
|
||||
"fresh_run": bool(options.get("fresh")),
|
||||
"pinned_bible_version": bible.version,
|
||||
"pinned_outline_version": outline.version,
|
||||
"pinned_prior_canon_id": str(
|
||||
(
|
||||
CanonSnapshot.objects.filter(
|
||||
story=story, through_chapter__lt=chapter.number
|
||||
)
|
||||
.order_by("-through_chapter", "-version")
|
||||
.values_list("id", flat=True)
|
||||
.first()
|
||||
)
|
||||
or ""
|
||||
),
|
||||
},
|
||||
)
|
||||
with open_story_checkpointer() as saver:
|
||||
services = DjangoStoryWorkflowServices(
|
||||
ModelRouter(providers_from_resources(), persist_requests=True)
|
||||
)
|
||||
runner = StoryWorkflowRunner(build_story_workflow(services, saver))
|
||||
graph_run = runner.start(revision)
|
||||
self.stdout.write(
|
||||
f"Graph run {graph_run.id}: {graph_run.status} at {graph_run.current_node}"
|
||||
)
|
||||
|
||||
def _supersede_active_runs(self, story: StoryProject, chapter: Chapter) -> None:
|
||||
active = GraphRun.objects.filter(
|
||||
project=story.project,
|
||||
execution_graph_version__graph__name="story_authoring",
|
||||
status__in=[GraphRunStatus.RUNNING, GraphRunStatus.PAUSED, GraphRunStatus.FAILED],
|
||||
)
|
||||
for graph_run in active:
|
||||
revision_id = graph_run.metadata.get("current_revision_id") or graph_run.metadata.get(
|
||||
"revision_id"
|
||||
)
|
||||
revision = ChapterRevision.objects.filter(id=revision_id).first()
|
||||
if revision is None or revision.chapter_id != chapter.id:
|
||||
continue
|
||||
graph_run.status = GraphRunStatus.CANCELLED
|
||||
graph_run.current_node = "superseded"
|
||||
graph_run.failure_reason = "SUPERSEDED_BY_FRESH_STORY_RUN"
|
||||
graph_run.completed_at = timezone.now()
|
||||
graph_run.save(
|
||||
update_fields=[
|
||||
"status", "current_node", "failure_reason", "completed_at", "updated_at"
|
||||
]
|
||||
)
|
||||
graph_run.approvals.filter(status=GraphApprovalStatus.PENDING).update(
|
||||
status=GraphApprovalStatus.REJECTED,
|
||||
decided_by="supersede_active",
|
||||
decided_at=timezone.now(),
|
||||
)
|
||||
|
||||
def _resume(self, options: dict) -> None:
|
||||
if not options.get("graph_run") or not options.get("decision"):
|
||||
raise CommandError("resume requires --graph-run and --decision")
|
||||
with open_story_checkpointer() as saver:
|
||||
services = DjangoStoryWorkflowServices(
|
||||
ModelRouter(providers_from_resources(), persist_requests=True)
|
||||
)
|
||||
runner = StoryWorkflowRunner(build_story_workflow(services, saver))
|
||||
graph_run = runner.resume(
|
||||
options["graph_run"],
|
||||
{
|
||||
"action": options["decision"],
|
||||
"notes": options["notes"],
|
||||
"actor": "management_command",
|
||||
},
|
||||
)
|
||||
self.stdout.write(
|
||||
f"Graph run {graph_run.id}: {graph_run.status} at {graph_run.current_node}"
|
||||
)
|
||||
234
control_plane/authoring/migrations/0001_initial.py
Normal file
234
control_plane/authoring/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
# Generated by Django 5.2.16 on 2026-08-21 06:10
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('projects', '0006_roadmap_scenario_lab_v1'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Chapter',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('number', models.PositiveIntegerField()),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('status', models.CharField(choices=[('PLANNED', 'Planned'), ('DRAFTING', 'Drafting'), ('REVIEW', 'Review'), ('APPROVED', 'Approved')], default='PLANNED', max_length=32)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['number'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OutlineVersion',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('version', models.PositiveIntegerField()),
|
||||
('content', models.JSONField(default=dict)),
|
||||
('sha256', models.CharField(blank=True, max_length=64)),
|
||||
('approved_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StoryBibleVersion',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('version', models.PositiveIntegerField()),
|
||||
('content', models.TextField()),
|
||||
('structured_canon', models.JSONField(blank=True, default=dict)),
|
||||
('sha256', models.CharField(blank=True, max_length=64)),
|
||||
('approved_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ChapterRevision',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('revision', models.PositiveIntegerField()),
|
||||
('status', models.CharField(choices=[('SOURCE', 'Source'), ('DRAFT', 'Draft'), ('REVIEW', 'Review'), ('APPROVED', 'Approved'), ('REJECTED', 'Rejected')], default='DRAFT', max_length=32)),
|
||||
('scene_plan', models.JSONField(blank=True, default=dict)),
|
||||
('prose', models.TextField(blank=True)),
|
||||
('continuity_state', models.JSONField(blank=True, default=dict)),
|
||||
('artifact_uri', models.TextField(blank=True)),
|
||||
('word_count', models.PositiveIntegerField(default=0)),
|
||||
('sha256', models.CharField(blank=True, max_length=64)),
|
||||
('graph_thread_id', models.CharField(blank=True, db_index=True, max_length=255)),
|
||||
('generation_metadata', models.JSONField(blank=True, default=dict)),
|
||||
('approved_at', models.DateTimeField(blank=True, null=True)),
|
||||
('chapter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='authoring.chapter')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='authoring.chapterrevision')),
|
||||
('source_revision', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_children', to='authoring.chapterrevision')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chapter',
|
||||
name='current_revision',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='current_for_chapters', to='authoring.chapterrevision'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CanonSnapshot',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('through_chapter', models.PositiveIntegerField()),
|
||||
('version', models.PositiveIntegerField()),
|
||||
('state', models.JSONField(default=dict)),
|
||||
('sha256', models.CharField(max_length=64)),
|
||||
('source_revision', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='committed_canon', to='authoring.chapterrevision')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['version'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EditorialFinding',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('review_kind', models.CharField(max_length=80)),
|
||||
('severity', models.CharField(choices=[('INFO', 'Info'), ('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High'), ('CRITICAL', 'Critical')], default='INFO', max_length=16)),
|
||||
('category', models.CharField(max_length=80)),
|
||||
('location', models.CharField(blank=True, max_length=255)),
|
||||
('description', models.TextField()),
|
||||
('suggested_revision', models.TextField(blank=True)),
|
||||
('evidence', models.JSONField(blank=True, default=dict)),
|
||||
('status', models.CharField(choices=[('OPEN', 'Open'), ('RESOLVED', 'Resolved'), ('ACCEPTED', 'Accepted')], default='OPEN', max_length=16)),
|
||||
('model_metadata', models.JSONField(blank=True, default=dict)),
|
||||
('revision', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='findings', to='authoring.chapterrevision')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GenerationContextSnapshot',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('content', models.JSONField(default=dict)),
|
||||
('sha256', models.CharField(max_length=64)),
|
||||
('chapter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='context_snapshots', to='authoring.chapter')),
|
||||
('prior_canon', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='derived_contexts', to='authoring.canonsnapshot')),
|
||||
('outline', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='authoring.outlineversion')),
|
||||
('story_bible', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='authoring.storybibleversion')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chapterrevision',
|
||||
name='context_snapshot',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='revisions', to='authoring.generationcontextsnapshot'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chapterrevision',
|
||||
name='outline',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='authoring.outlineversion'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PromptVersion',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=160)),
|
||||
('purpose', models.CharField(max_length=80)),
|
||||
('version', models.PositiveIntegerField()),
|
||||
('system_text', models.TextField(blank=True)),
|
||||
('user_template', models.TextField()),
|
||||
('config', models.JSONField(blank=True, default=dict)),
|
||||
('is_active', models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('name', 'version'), name='unique_authoring_prompt_version'), models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('purpose',), name='unique_active_authoring_prompt_purpose')],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chapterrevision',
|
||||
name='story_bible',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='authoring.storybibleversion'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StoryProject',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('series', models.CharField(blank=True, max_length=255)),
|
||||
('slug', models.SlugField(max_length=160, unique=True)),
|
||||
('status', models.CharField(choices=[('PLANNING', 'Planning'), ('REVISING', 'Revising'), ('DRAFTING', 'Drafting'), ('COMPLETE', 'Complete')], default='PLANNING', max_length=32)),
|
||||
('artifact_root', models.TextField(blank=True)),
|
||||
('config', models.JSONField(blank=True, default=dict)),
|
||||
('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='story_project', to='projects.project')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='storybibleversion',
|
||||
name='story',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bible_versions', to='authoring.storyproject'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='outlineversion',
|
||||
name='story',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='outline_versions', to='authoring.storyproject'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='generationcontextsnapshot',
|
||||
name='story',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='context_snapshots', to='authoring.storyproject'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chapter',
|
||||
name='story',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='authoring.storyproject'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='canonsnapshot',
|
||||
name='story',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='canon_snapshots', to='authoring.storyproject'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='chapterrevision',
|
||||
constraint=models.UniqueConstraint(fields=('chapter', 'revision'), name='unique_chapter_revision_number'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='storybibleversion',
|
||||
constraint=models.UniqueConstraint(fields=('story', 'version'), name='unique_story_bible_version'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='outlineversion',
|
||||
constraint=models.UniqueConstraint(fields=('story', 'version'), name='unique_story_outline_version'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='chapter',
|
||||
constraint=models.UniqueConstraint(fields=('story', 'number'), name='unique_story_chapter_number'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='canonsnapshot',
|
||||
constraint=models.UniqueConstraint(fields=('story', 'version'), name='unique_story_canon_version'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
37
control_plane/authoring/migrations/0005_sceneideation.py
Normal file
37
control_plane/authoring/migrations/0005_sceneideation.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 5.2.16 on 2026-08-27 13:38
|
||||
|
||||
import uuid
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('authoring', '0004_standalonescene_scenecontextcitation_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SceneIdeation',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('focus', models.TextField(blank=True)),
|
||||
('candidate_count', models.PositiveSmallIntegerField(default=5, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(8)])),
|
||||
('authorities', models.JSONField(default=list)),
|
||||
('pinned_document_keys', models.JSONField(blank=True, default=list)),
|
||||
('context_pack', models.JSONField(default=dict)),
|
||||
('context_pack_sha256', models.CharField(max_length=64)),
|
||||
('candidates', models.JSONField(default=list)),
|
||||
('generation_metadata', models.JSONField(default=dict)),
|
||||
('work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scene_ideations', to='authoring.work')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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)],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
217
control_plane/authoring/migrations/0009_book_authoring_state.py
Normal file
217
control_plane/authoring/migrations/0009_book_authoring_state.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authoring", "0008_sceneideation_requested_scene_types"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BookStateVersion",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("version", models.PositiveIntegerField()),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("draft", "Draft"),
|
||||
("review", "Review"),
|
||||
("approved", "Approved"),
|
||||
("rejected", "Rejected"),
|
||||
],
|
||||
default="draft",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("content", models.JSONField()),
|
||||
("sha256", models.CharField(db_index=True, max_length=64)),
|
||||
("context_pack", models.JSONField(blank=True, default=dict)),
|
||||
("context_pack_sha256", models.CharField(blank=True, max_length=64)),
|
||||
("validation", models.JSONField(blank=True, default=dict)),
|
||||
("reviews", models.JSONField(blank=True, default=dict)),
|
||||
("change_summary", models.JSONField(blank=True, default=dict)),
|
||||
("generation_metadata", models.JSONField(blank=True, default=dict)),
|
||||
("created_by", models.CharField(blank=True, max_length=160)),
|
||||
("json_artifact_uri", models.TextField(blank=True)),
|
||||
("markdown_artifact_uri", models.TextField(blank=True)),
|
||||
("approved_at", models.DateTimeField(blank=True, null=True)),
|
||||
("approved_by", models.CharField(blank=True, max_length=160)),
|
||||
("approval_notes", models.TextField(blank=True)),
|
||||
("approval_forced", models.BooleanField(default=False)),
|
||||
("rejected_at", models.DateTimeField(blank=True, null=True)),
|
||||
("rejected_by", models.CharField(blank=True, max_length=160)),
|
||||
("rejection_notes", models.TextField(blank=True)),
|
||||
(
|
||||
"parent",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="children",
|
||||
to="authoring.bookstateversion",
|
||||
),
|
||||
),
|
||||
(
|
||||
"work",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="book_state_versions",
|
||||
to="authoring.work",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["work", "version"],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="bookstateversion",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("work", "version"), name="unique_work_book_state_version"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="work",
|
||||
name="current_book_state",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="current_for_works",
|
||||
to="authoring.bookstateversion",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="standalonescene",
|
||||
name="book_chapter_key",
|
||||
field=models.CharField(blank=True, max_length=80),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="standalonescene",
|
||||
name="book_state",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="standalone_scenes",
|
||||
to="authoring.bookstateversion",
|
||||
),
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name="standalonescene",
|
||||
name="unique_work_standalone_scene_revision",
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="standalonescene",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("book_state__isnull", True)),
|
||||
fields=("work", "scene_key", "revision"),
|
||||
name="unique_unbound_scene_revision",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="standalonescene",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("book_state__isnull", False)),
|
||||
fields=(
|
||||
"work",
|
||||
"book_state",
|
||||
"book_chapter_key",
|
||||
"scene_key",
|
||||
"revision",
|
||||
),
|
||||
name="unique_bound_scene_revision",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="standalonescene",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
models.Q(("book_state__isnull", True), ("book_chapter_key", "")),
|
||||
models.Q(
|
||||
("book_state__isnull", False),
|
||||
models.Q(("book_chapter_key", ""), _negated=True),
|
||||
),
|
||||
_connector="OR",
|
||||
),
|
||||
name="scene_book_state_chapter_key_paired",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="sceneideation",
|
||||
name="book_state",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="scene_ideations",
|
||||
to="authoring.bookstateversion",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BookRun",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("running", "Running"),
|
||||
("paused", "Paused"),
|
||||
("review", "Review"),
|
||||
("complete", "Complete"),
|
||||
("failed", "Failed"),
|
||||
("cancelled", "Cancelled"),
|
||||
],
|
||||
db_index=True,
|
||||
default="pending",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("current_chapter_key", models.CharField(blank=True, max_length=80)),
|
||||
("progress", models.JSONField(default=dict)),
|
||||
("policy", models.JSONField(default=dict)),
|
||||
("reviews", models.JSONField(blank=True, default=dict)),
|
||||
("failure_reason", models.TextField(blank=True)),
|
||||
("started_at", models.DateTimeField(blank=True, null=True)),
|
||||
("finished_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"book_state",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="runs",
|
||||
to="authoring.bookstateversion",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
control_plane/authoring/migrations/__init__.py
Normal file
0
control_plane/authoring/migrations/__init__.py
Normal file
959
control_plane/authoring/models.py
Normal file
959
control_plane/authoring/models.py
Normal file
|
|
@ -0,0 +1,959 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
from control_plane.common import TimestampedModel
|
||||
|
||||
|
||||
def text_sha256(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class StoryStatus(models.TextChoices):
|
||||
PLANNING = "PLANNING"
|
||||
REVISING = "REVISING"
|
||||
DRAFTING = "DRAFTING"
|
||||
COMPLETE = "COMPLETE"
|
||||
|
||||
|
||||
class ChapterStatus(models.TextChoices):
|
||||
PLANNED = "PLANNED"
|
||||
DRAFTING = "DRAFTING"
|
||||
REVIEW = "REVIEW"
|
||||
APPROVED = "APPROVED"
|
||||
|
||||
|
||||
class RevisionStatus(models.TextChoices):
|
||||
SOURCE = "SOURCE"
|
||||
DRAFT = "DRAFT"
|
||||
REVIEW = "REVIEW"
|
||||
APPROVED = "APPROVED"
|
||||
REJECTED = "REJECTED"
|
||||
|
||||
|
||||
class FindingSeverity(models.TextChoices):
|
||||
INFO = "INFO"
|
||||
LOW = "LOW"
|
||||
MEDIUM = "MEDIUM"
|
||||
HIGH = "HIGH"
|
||||
CRITICAL = "CRITICAL"
|
||||
|
||||
|
||||
class FindingStatus(models.TextChoices):
|
||||
OPEN = "OPEN"
|
||||
RESOLVED = "RESOLVED"
|
||||
ACCEPTED = "ACCEPTED"
|
||||
|
||||
|
||||
class StateDocumentStatus(models.TextChoices):
|
||||
EXTRACTED = "EXTRACTED"
|
||||
NEEDS_REVISION = "NEEDS_REVISION"
|
||||
VALIDATED = "VALIDATED"
|
||||
COMMITTED = "COMMITTED"
|
||||
|
||||
|
||||
class RequirementStatus(models.TextChoices):
|
||||
HIT = "HIT"
|
||||
PARTIAL = "PARTIAL"
|
||||
MISSED = "MISSED"
|
||||
CONTRADICTED = "CONTRADICTED"
|
||||
UNVERIFIABLE = "UNVERIFIABLE"
|
||||
|
||||
|
||||
class StateChangeStatus(models.TextChoices):
|
||||
PROPOSED = "PROPOSED"
|
||||
VALIDATED = "VALIDATED"
|
||||
COMMITTED = "COMMITTED"
|
||||
REJECTED = "REJECTED"
|
||||
|
||||
|
||||
class StateOperation(models.TextChoices):
|
||||
SET = "SET"
|
||||
ADD = "ADD"
|
||||
REMOVE = "REMOVE"
|
||||
TRANSFER = "TRANSFER"
|
||||
OPEN = "OPEN"
|
||||
CLOSE = "CLOSE"
|
||||
|
||||
|
||||
class WorkType(models.TextChoices):
|
||||
BOOK = "book"
|
||||
SERIES_REFERENCE = "series_reference"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class DocumentType(models.TextChoices):
|
||||
MANUSCRIPT = "manuscript"
|
||||
SCENE = "scene"
|
||||
OUTLINE = "outline"
|
||||
PLANNING = "planning"
|
||||
CANON = "canon"
|
||||
STATE = "state"
|
||||
REFERENCE = "reference"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class DocumentAuthority(models.TextChoices):
|
||||
CANON = "canon"
|
||||
PROVISIONAL = "provisional"
|
||||
PLANNING = "planning"
|
||||
SUPERSEDED = "superseded"
|
||||
REJECTED = "rejected"
|
||||
NONCANON_EXPERIMENT = "noncanon_experiment"
|
||||
|
||||
|
||||
class SceneDraftStatus(models.TextChoices):
|
||||
PLANNING = "planning"
|
||||
PLAN_REVIEW = "plan_review"
|
||||
READY = "ready"
|
||||
DRAFTING = "drafting"
|
||||
DRAFT_REVIEW = "draft_review"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class BookStateStatus(models.TextChoices):
|
||||
DRAFT = "draft"
|
||||
REVIEW = "review"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class BookRunStatus(models.TextChoices):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
PAUSED = "paused"
|
||||
REVIEW = "review"
|
||||
COMPLETE = "complete"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class Series(TimestampedModel):
|
||||
title = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=160, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "series"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
|
||||
|
||||
class Work(TimestampedModel):
|
||||
series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name="works")
|
||||
title = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=160)
|
||||
work_type = models.CharField(
|
||||
max_length=32, choices=WorkType.choices, default=WorkType.BOOK
|
||||
)
|
||||
sequence = models.PositiveIntegerField(null=True, blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
current_book_state = models.ForeignKey(
|
||||
"BookStateVersion",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="current_for_works",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["sequence", "title"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["series", "slug"], name="unique_series_work_slug")
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
|
||||
|
||||
class StoryProject(TimestampedModel):
|
||||
project = models.OneToOneField(
|
||||
"projects.Project", on_delete=models.CASCADE, related_name="story_project"
|
||||
)
|
||||
work = models.OneToOneField(
|
||||
Work,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="story_project",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
title = models.CharField(max_length=255)
|
||||
series = models.CharField(max_length=255, blank=True)
|
||||
slug = models.SlugField(max_length=160, unique=True)
|
||||
status = models.CharField(
|
||||
max_length=32, choices=StoryStatus.choices, default=StoryStatus.PLANNING
|
||||
)
|
||||
artifact_root = models.TextField(blank=True)
|
||||
config = models.JSONField(default=dict, blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
|
||||
|
||||
class SourceDocument(TimestampedModel):
|
||||
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="source_documents")
|
||||
logical_key = models.CharField(max_length=500)
|
||||
title = models.CharField(max_length=500)
|
||||
document_type = models.CharField(
|
||||
max_length=32, choices=DocumentType.choices, default=DocumentType.OTHER
|
||||
)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["logical_key"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["work", "logical_key"], name="unique_work_source_document_key"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
|
||||
|
||||
class SourceDocumentVersion(TimestampedModel):
|
||||
document = models.ForeignKey(SourceDocument, on_delete=models.CASCADE, related_name="versions")
|
||||
version = models.PositiveIntegerField()
|
||||
authority = models.CharField(
|
||||
max_length=32,
|
||||
choices=DocumentAuthority.choices,
|
||||
default=DocumentAuthority.PROVISIONAL,
|
||||
db_index=True,
|
||||
)
|
||||
source_path = models.TextField()
|
||||
content = models.TextField()
|
||||
source_sha256 = models.CharField(max_length=64, db_index=True)
|
||||
byte_size = models.PositiveBigIntegerField()
|
||||
encoding = models.CharField(max_length=40, default="utf-8")
|
||||
supersedes = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="superseded_by",
|
||||
)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["document", "version"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["document", "version"], name="unique_source_document_version"
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.document.title} v{self.version}"
|
||||
|
||||
def save(self, *args: object, **kwargs: object) -> None:
|
||||
if not self._state.adding:
|
||||
original = SourceDocumentVersion.objects.get(pk=self.pk)
|
||||
immutable_fields = (
|
||||
"document_id",
|
||||
"version",
|
||||
"authority",
|
||||
"source_path",
|
||||
"content",
|
||||
"source_sha256",
|
||||
"byte_size",
|
||||
"encoding",
|
||||
"supersedes_id",
|
||||
"metadata",
|
||||
)
|
||||
changed = any(
|
||||
getattr(self, field) != getattr(original, field) for field in immutable_fields
|
||||
)
|
||||
if changed:
|
||||
raise ValueError(
|
||||
"source document versions are immutable; create a superseding version"
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class SourcePassage(TimestampedModel):
|
||||
document_version = models.ForeignKey(
|
||||
SourceDocumentVersion, on_delete=models.CASCADE, related_name="passages"
|
||||
)
|
||||
ordinal = models.PositiveIntegerField()
|
||||
start_line = models.PositiveIntegerField()
|
||||
end_line = models.PositiveIntegerField()
|
||||
start_char = models.PositiveBigIntegerField()
|
||||
end_char = models.PositiveBigIntegerField()
|
||||
content = models.TextField()
|
||||
sha256 = models.CharField(max_length=64, db_index=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["document_version", "ordinal"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["document_version", "ordinal"],
|
||||
name="unique_source_document_passage_ordinal",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=Q(end_line__gte=models.F("start_line")),
|
||||
name="source_passage_line_range_valid",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=Q(end_char__gte=models.F("start_char")),
|
||||
name="source_passage_char_range_valid",
|
||||
),
|
||||
]
|
||||
|
||||
def save(self, *args: object, **kwargs: object) -> None:
|
||||
if not self._state.adding:
|
||||
original = SourcePassage.objects.get(pk=self.pk)
|
||||
immutable_fields = (
|
||||
"document_version_id",
|
||||
"ordinal",
|
||||
"start_line",
|
||||
"end_line",
|
||||
"start_char",
|
||||
"end_char",
|
||||
"content",
|
||||
"sha256",
|
||||
"metadata",
|
||||
)
|
||||
changed = any(
|
||||
getattr(self, field) != getattr(original, field) for field in immutable_fields
|
||||
)
|
||||
if changed:
|
||||
raise ValueError("source passages are immutable with their document version")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class BookStateVersion(TimestampedModel):
|
||||
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="book_state_versions")
|
||||
parent = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="children",
|
||||
)
|
||||
version = models.PositiveIntegerField()
|
||||
status = models.CharField(
|
||||
max_length=16, choices=BookStateStatus.choices, default=BookStateStatus.DRAFT
|
||||
)
|
||||
content = models.JSONField()
|
||||
sha256 = models.CharField(max_length=64, db_index=True)
|
||||
context_pack = models.JSONField(default=dict, blank=True)
|
||||
context_pack_sha256 = models.CharField(max_length=64, blank=True)
|
||||
validation = models.JSONField(default=dict, blank=True)
|
||||
reviews = models.JSONField(default=dict, blank=True)
|
||||
change_summary = models.JSONField(default=dict, blank=True)
|
||||
generation_metadata = models.JSONField(default=dict, blank=True)
|
||||
created_by = models.CharField(max_length=160, blank=True)
|
||||
json_artifact_uri = models.TextField(blank=True)
|
||||
markdown_artifact_uri = models.TextField(blank=True)
|
||||
approved_at = models.DateTimeField(null=True, blank=True)
|
||||
approved_by = models.CharField(max_length=160, blank=True)
|
||||
approval_notes = models.TextField(blank=True)
|
||||
approval_forced = models.BooleanField(default=False)
|
||||
rejected_at = models.DateTimeField(null=True, blank=True)
|
||||
rejected_by = models.CharField(max_length=160, blank=True)
|
||||
rejection_notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["work", "version"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["work", "version"], name="unique_work_book_state_version"
|
||||
)
|
||||
]
|
||||
|
||||
def save(self, *args: object, **kwargs: object) -> None:
|
||||
if self._state.adding:
|
||||
canonical = json.dumps(
|
||||
self.content, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
self.sha256 = text_sha256(canonical)
|
||||
if self.context_pack:
|
||||
canonical_context = json.dumps(
|
||||
self.context_pack,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
self.context_pack_sha256 = text_sha256(canonical_context)
|
||||
else:
|
||||
original = BookStateVersion.objects.get(pk=self.pk)
|
||||
immutable_fields = (
|
||||
"work_id",
|
||||
"parent_id",
|
||||
"version",
|
||||
"content",
|
||||
"sha256",
|
||||
"context_pack",
|
||||
"context_pack_sha256",
|
||||
"generation_metadata",
|
||||
"created_by",
|
||||
)
|
||||
if original.status == BookStateStatus.APPROVED:
|
||||
immutable_fields += (
|
||||
"status",
|
||||
"reviews",
|
||||
"validation",
|
||||
"change_summary",
|
||||
"approved_at",
|
||||
"approved_by",
|
||||
"approval_notes",
|
||||
"approval_forced",
|
||||
"rejected_at",
|
||||
"rejected_by",
|
||||
"rejection_notes",
|
||||
"json_artifact_uri",
|
||||
"markdown_artifact_uri",
|
||||
)
|
||||
if any(
|
||||
getattr(self, field) != getattr(original, field) for field in immutable_fields
|
||||
):
|
||||
raise ValueError(
|
||||
"book state versions are immutable; create a child version"
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.work} v{self.version}"
|
||||
|
||||
|
||||
class StandaloneScene(TimestampedModel):
|
||||
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="standalone_scenes")
|
||||
story = models.ForeignKey(
|
||||
StoryProject,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="standalone_scenes",
|
||||
)
|
||||
parent = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="revisions",
|
||||
)
|
||||
source_version = models.ForeignKey(
|
||||
SourceDocumentVersion,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="generated_scenes",
|
||||
)
|
||||
book_state = models.ForeignKey(
|
||||
BookStateVersion,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="standalone_scenes",
|
||||
)
|
||||
book_chapter_key = models.CharField(max_length=80, blank=True)
|
||||
scene_key = models.SlugField(max_length=200)
|
||||
revision = models.PositiveIntegerField(default=1)
|
||||
title = models.CharField(max_length=500)
|
||||
status = models.CharField(
|
||||
max_length=32, choices=SceneDraftStatus.choices, default=SceneDraftStatus.PLANNING
|
||||
)
|
||||
brief = models.TextField()
|
||||
target_words = models.PositiveIntegerField(
|
||||
default=1800,
|
||||
validators=[MinValueValidator(300), MaxValueValidator(10000)],
|
||||
)
|
||||
constraints = models.JSONField(default=list, blank=True)
|
||||
forbidden_events = models.JSONField(default=list, blank=True)
|
||||
boundary_constraints = models.JSONField(default=list, blank=True)
|
||||
context_query = models.TextField(blank=True)
|
||||
context_pack = models.JSONField(default=dict, blank=True)
|
||||
context_pack_sha256 = models.CharField(max_length=64, blank=True)
|
||||
plan = models.JSONField(default=dict, blank=True)
|
||||
contract_requirements = models.JSONField(default=list, blank=True)
|
||||
prose = models.TextField(blank=True)
|
||||
word_count = models.PositiveIntegerField(default=0)
|
||||
sha256 = models.CharField(max_length=64, blank=True)
|
||||
partial_artifact_uri = models.TextField(blank=True)
|
||||
artifact_uri = models.TextField(blank=True)
|
||||
review_artifact_uri = models.TextField(blank=True)
|
||||
review = models.JSONField(default=dict, blank=True)
|
||||
generation_metadata = models.JSONField(default=dict, blank=True)
|
||||
plan_approved_at = models.DateTimeField(null=True, blank=True)
|
||||
approved_at = models.DateTimeField(null=True, blank=True)
|
||||
approved_by = models.CharField(max_length=160, blank=True)
|
||||
failure_reason = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["work", "scene_key", "revision"],
|
||||
condition=Q(book_state__isnull=True),
|
||||
name="unique_unbound_scene_revision",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=[
|
||||
"work",
|
||||
"book_state",
|
||||
"book_chapter_key",
|
||||
"scene_key",
|
||||
"revision",
|
||||
],
|
||||
condition=Q(book_state__isnull=False),
|
||||
name="unique_bound_scene_revision",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=(Q(book_state__isnull=True) & Q(book_chapter_key=""))
|
||||
| (Q(book_state__isnull=False) & ~Q(book_chapter_key="")),
|
||||
name="scene_book_state_chapter_key_paired",
|
||||
),
|
||||
]
|
||||
|
||||
def save(self, *args: object, **kwargs: object) -> None:
|
||||
if not self._state.adding:
|
||||
original = StandaloneScene.objects.get(pk=self.pk)
|
||||
if original.status == SceneDraftStatus.APPROVED:
|
||||
immutable_fields = (
|
||||
"work_id",
|
||||
"story_id",
|
||||
"parent_id",
|
||||
"source_version_id",
|
||||
"book_state_id",
|
||||
"book_chapter_key",
|
||||
"scene_key",
|
||||
"revision",
|
||||
"title",
|
||||
"status",
|
||||
"brief",
|
||||
"target_words",
|
||||
"constraints",
|
||||
"forbidden_events",
|
||||
"boundary_constraints",
|
||||
"context_query",
|
||||
"context_pack",
|
||||
"context_pack_sha256",
|
||||
"plan",
|
||||
"contract_requirements",
|
||||
"prose",
|
||||
"sha256",
|
||||
"artifact_uri",
|
||||
"review",
|
||||
"review_artifact_uri",
|
||||
"generation_metadata",
|
||||
"approved_at",
|
||||
"approved_by",
|
||||
)
|
||||
changed = any(
|
||||
getattr(self, field) != getattr(original, field)
|
||||
for field in immutable_fields
|
||||
)
|
||||
if changed:
|
||||
raise ValueError(
|
||||
"approved standalone scenes are immutable; create a new revision"
|
||||
)
|
||||
if self.prose:
|
||||
import re
|
||||
|
||||
self.word_count = len(re.findall(r"\b\S+\b", self.prose))
|
||||
self.sha256 = text_sha256(self.prose)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.title} r{self.revision}"
|
||||
|
||||
|
||||
class SceneIdeation(TimestampedModel):
|
||||
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="scene_ideations")
|
||||
book_state = models.ForeignKey(
|
||||
BookStateVersion,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="scene_ideations",
|
||||
)
|
||||
target_book = models.CharField(max_length=160, blank=True)
|
||||
focus = models.TextField(blank=True)
|
||||
requested_scene_types = models.JSONField(default=list, blank=True)
|
||||
candidate_count = models.PositiveSmallIntegerField(
|
||||
default=10,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(12)],
|
||||
)
|
||||
authorities = models.JSONField(default=list)
|
||||
pinned_document_keys = models.JSONField(default=list, blank=True)
|
||||
context_pack = models.JSONField(default=dict)
|
||||
context_pack_sha256 = models.CharField(max_length=64)
|
||||
candidates = models.JSONField(default=list)
|
||||
generation_metadata = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.work}: {self.candidate_count} scene ideas"
|
||||
|
||||
|
||||
class BookRun(TimestampedModel):
|
||||
"""Durable orchestration cursor for a book state, not prose state."""
|
||||
|
||||
book_state = models.ForeignKey(
|
||||
BookStateVersion, on_delete=models.PROTECT, related_name="runs"
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=16,
|
||||
choices=BookRunStatus.choices,
|
||||
default=BookRunStatus.PENDING,
|
||||
db_index=True,
|
||||
)
|
||||
current_chapter_key = models.CharField(max_length=80, blank=True)
|
||||
progress = models.JSONField(default=dict)
|
||||
policy = models.JSONField(default=dict)
|
||||
reviews = models.JSONField(default=dict, blank=True)
|
||||
failure_reason = models.TextField(blank=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
finished_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
class SceneContextCitation(TimestampedModel):
|
||||
scene = models.ForeignKey(
|
||||
StandaloneScene, on_delete=models.CASCADE, related_name="context_citations"
|
||||
)
|
||||
passage = models.ForeignKey(
|
||||
SourcePassage, on_delete=models.PROTECT, related_name="scene_citations"
|
||||
)
|
||||
rank = models.PositiveIntegerField()
|
||||
score = models.FloatField(default=0)
|
||||
reason = models.CharField(max_length=255, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["scene", "rank"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["scene", "passage"], name="unique_scene_context_passage"
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["scene", "rank"], name="unique_scene_context_rank"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class StoryBibleVersion(TimestampedModel):
|
||||
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="bible_versions")
|
||||
version = models.PositiveIntegerField()
|
||||
content = models.TextField()
|
||||
structured_canon = models.JSONField(default=dict, blank=True)
|
||||
sha256 = models.CharField(max_length=64, blank=True)
|
||||
approved_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["story", "version"], name="unique_story_bible_version")
|
||||
]
|
||||
|
||||
def save(self, *args: object, **kwargs: object) -> None:
|
||||
self.sha256 = text_sha256(self.content)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class OutlineVersion(TimestampedModel):
|
||||
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="outline_versions")
|
||||
version = models.PositiveIntegerField()
|
||||
content = models.JSONField(default=dict)
|
||||
sha256 = models.CharField(max_length=64, blank=True)
|
||||
approved_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["story", "version"], name="unique_story_outline_version")
|
||||
]
|
||||
|
||||
def save(self, *args: object, **kwargs: object) -> None:
|
||||
import json
|
||||
|
||||
canonical = json.dumps(self.content, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
self.sha256 = text_sha256(canonical)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class PromptVersion(TimestampedModel):
|
||||
name = models.CharField(max_length=160)
|
||||
purpose = models.CharField(max_length=80)
|
||||
version = models.PositiveIntegerField()
|
||||
system_text = models.TextField(blank=True)
|
||||
user_template = models.TextField()
|
||||
config = models.JSONField(default=dict, blank=True)
|
||||
is_active = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["name", "version"], name="unique_authoring_prompt_version"),
|
||||
models.UniqueConstraint(
|
||||
fields=["purpose"],
|
||||
condition=Q(is_active=True),
|
||||
name="unique_active_authoring_prompt_purpose",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class Chapter(TimestampedModel):
|
||||
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="chapters")
|
||||
number = models.PositiveIntegerField()
|
||||
title = models.CharField(max_length=255)
|
||||
status = models.CharField(
|
||||
max_length=32, choices=ChapterStatus.choices, default=ChapterStatus.PLANNED
|
||||
)
|
||||
current_revision = models.ForeignKey(
|
||||
"ChapterRevision",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="current_for_chapters",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["number"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["story", "number"], name="unique_story_chapter_number")
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.story.title} - Chapter {self.number}: {self.title}"
|
||||
|
||||
|
||||
class GenerationContextSnapshot(TimestampedModel):
|
||||
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="context_snapshots")
|
||||
chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="context_snapshots")
|
||||
story_bible = models.ForeignKey(StoryBibleVersion, on_delete=models.PROTECT)
|
||||
outline = models.ForeignKey(OutlineVersion, on_delete=models.PROTECT)
|
||||
prior_canon = models.ForeignKey(
|
||||
"CanonSnapshot", on_delete=models.PROTECT, null=True, blank=True, related_name="derived_contexts"
|
||||
)
|
||||
content = models.JSONField(default=dict)
|
||||
sha256 = models.CharField(max_length=64)
|
||||
|
||||
|
||||
class ChapterRevision(TimestampedModel):
|
||||
chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="revisions")
|
||||
revision = models.PositiveIntegerField()
|
||||
status = models.CharField(
|
||||
max_length=32, choices=RevisionStatus.choices, default=RevisionStatus.DRAFT
|
||||
)
|
||||
parent = models.ForeignKey(
|
||||
"self", on_delete=models.SET_NULL, null=True, blank=True, related_name="children"
|
||||
)
|
||||
source_revision = models.ForeignKey(
|
||||
"self", on_delete=models.SET_NULL, null=True, blank=True, related_name="source_children"
|
||||
)
|
||||
story_bible = models.ForeignKey(StoryBibleVersion, on_delete=models.PROTECT)
|
||||
outline = models.ForeignKey(OutlineVersion, on_delete=models.PROTECT)
|
||||
context_snapshot = models.ForeignKey(
|
||||
GenerationContextSnapshot,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="revisions",
|
||||
)
|
||||
scene_plan = models.JSONField(default=dict, blank=True)
|
||||
prose = models.TextField(blank=True)
|
||||
continuity_state = models.JSONField(default=dict, blank=True)
|
||||
artifact_uri = models.TextField(blank=True)
|
||||
word_count = models.PositiveIntegerField(default=0)
|
||||
sha256 = models.CharField(max_length=64, blank=True)
|
||||
graph_thread_id = models.CharField(max_length=255, blank=True, db_index=True)
|
||||
generation_metadata = models.JSONField(default=dict, blank=True)
|
||||
approved_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["chapter", "revision"], name="unique_chapter_revision_number"
|
||||
)
|
||||
]
|
||||
|
||||
def save(self, *args: object, **kwargs: object) -> None:
|
||||
if self.prose:
|
||||
import re
|
||||
|
||||
self.word_count = len(re.findall(r"\b\S+\b", self.prose))
|
||||
self.sha256 = text_sha256(self.prose)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ChapterContract(TimestampedModel):
|
||||
revision = models.OneToOneField(
|
||||
ChapterRevision, on_delete=models.CASCADE, related_name="contract"
|
||||
)
|
||||
entry_canon = models.ForeignKey(
|
||||
"CanonSnapshot",
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="chapter_contracts",
|
||||
)
|
||||
requirements = models.JSONField(default=list)
|
||||
scene_plan_sha256 = models.CharField(max_length=64)
|
||||
approved_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
class ChapterStateDocument(TimestampedModel):
|
||||
revision = models.OneToOneField(
|
||||
ChapterRevision, on_delete=models.CASCADE, related_name="state_document"
|
||||
)
|
||||
contract = models.ForeignKey(
|
||||
ChapterContract, on_delete=models.PROTECT, related_name="state_documents"
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=32,
|
||||
choices=StateDocumentStatus.choices,
|
||||
default=StateDocumentStatus.EXTRACTED,
|
||||
)
|
||||
start_state = models.JSONField(default=dict)
|
||||
observed_state = models.JSONField(default=dict)
|
||||
proposed_delta = models.JSONField(default=list)
|
||||
coverage = models.JSONField(default=dict)
|
||||
verdict = models.CharField(max_length=32, blank=True)
|
||||
json_artifact_uri = models.TextField(blank=True)
|
||||
markdown_artifact_uri = models.TextField(blank=True)
|
||||
sha256 = models.CharField(max_length=64)
|
||||
model_metadata = models.JSONField(default=dict, blank=True)
|
||||
validated_at = models.DateTimeField(null=True, blank=True)
|
||||
committed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
class StoryEntity(TimestampedModel):
|
||||
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="state_entities")
|
||||
entity_key = models.CharField(max_length=200)
|
||||
kind = models.CharField(max_length=64)
|
||||
canonical_name = models.CharField(max_length=255)
|
||||
aliases = models.JSONField(default=list, blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
first_revision = models.ForeignKey(
|
||||
ChapterRevision,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="introduced_state_entities",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["story", "entity_key"], name="unique_story_state_entity_key"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class StateChange(TimestampedModel):
|
||||
state_document = models.ForeignKey(
|
||||
ChapterStateDocument, on_delete=models.CASCADE, related_name="changes"
|
||||
)
|
||||
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="state_changes")
|
||||
revision = models.ForeignKey(
|
||||
ChapterRevision, on_delete=models.CASCADE, related_name="state_changes"
|
||||
)
|
||||
entity = models.ForeignKey(
|
||||
StoryEntity, on_delete=models.PROTECT, null=True, blank=True, related_name="changes"
|
||||
)
|
||||
related_entity = models.ForeignKey(
|
||||
StoryEntity,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="related_changes",
|
||||
)
|
||||
sequence = models.PositiveIntegerField()
|
||||
change_type = models.CharField(max_length=80)
|
||||
predicate = models.CharField(max_length=200)
|
||||
operation = models.CharField(max_length=16, choices=StateOperation.choices)
|
||||
previous_value = models.JSONField(null=True, blank=True)
|
||||
new_value = models.JSONField(null=True, blank=True)
|
||||
effective_chapter = models.PositiveIntegerField()
|
||||
evidence_quote = models.TextField(blank=True)
|
||||
evidence_location = models.CharField(max_length=255, blank=True)
|
||||
status = models.CharField(
|
||||
max_length=16,
|
||||
choices=StateChangeStatus.choices,
|
||||
default=StateChangeStatus.PROPOSED,
|
||||
)
|
||||
supersedes = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="superseded_by",
|
||||
)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
sha256 = models.CharField(max_length=64)
|
||||
|
||||
class Meta:
|
||||
ordering = ["effective_chapter", "sequence"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["state_document", "sequence"], name="unique_state_change_sequence"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class RequirementCheck(TimestampedModel):
|
||||
state_document = models.ForeignKey(
|
||||
ChapterStateDocument, on_delete=models.CASCADE, related_name="requirement_checks"
|
||||
)
|
||||
requirement_id = models.CharField(max_length=80)
|
||||
requirement_type = models.CharField(max_length=32)
|
||||
requirement_text = models.TextField()
|
||||
status = models.CharField(max_length=24, choices=RequirementStatus.choices)
|
||||
severity = models.CharField(max_length=16, choices=FindingSeverity.choices)
|
||||
evidence_quote = models.TextField(blank=True)
|
||||
evidence_location = models.CharField(max_length=255, blank=True)
|
||||
details = models.TextField(blank=True)
|
||||
model_metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["state_document", "requirement_id"],
|
||||
name="unique_state_document_requirement",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class CanonSnapshot(TimestampedModel):
|
||||
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="canon_snapshots")
|
||||
through_chapter = models.PositiveIntegerField()
|
||||
version = models.PositiveIntegerField()
|
||||
state = models.JSONField(default=dict)
|
||||
source_revision = models.OneToOneField(
|
||||
ChapterRevision, on_delete=models.PROTECT, related_name="committed_canon"
|
||||
)
|
||||
sha256 = models.CharField(max_length=64)
|
||||
|
||||
class Meta:
|
||||
ordering = ["version"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["story", "version"], name="unique_story_canon_version")
|
||||
]
|
||||
|
||||
|
||||
class EditorialFinding(TimestampedModel):
|
||||
revision = models.ForeignKey(ChapterRevision, on_delete=models.CASCADE, related_name="findings")
|
||||
review_kind = models.CharField(max_length=80)
|
||||
severity = models.CharField(
|
||||
max_length=16, choices=FindingSeverity.choices, default=FindingSeverity.INFO
|
||||
)
|
||||
category = models.CharField(max_length=80)
|
||||
location = models.CharField(max_length=255, blank=True)
|
||||
description = models.TextField()
|
||||
suggested_revision = models.TextField(blank=True)
|
||||
evidence = models.JSONField(default=dict, blank=True)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=FindingStatus.choices, default=FindingStatus.OPEN
|
||||
)
|
||||
model_metadata = models.JSONField(default=dict, blank=True)
|
||||
528
control_plane/authoring/prompts.py
Normal file
528
control_plane/authoring/prompts.py
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
from __future__ import annotations
|
||||
|
||||
DEFAULT_PLAN_SYSTEM = """You are a developmental story architect. Return one valid JSON object only.
|
||||
Preserve canon, exact chronology, relationship pacing, character agency, and required chapter beats.
|
||||
Do not move important relationship development into montage."""
|
||||
|
||||
DEFAULT_PLAN_TEMPLATE = """Plan Chapter {chapter_number}: {chapter_title} as fully dramatized scenes.
|
||||
|
||||
Story bible:
|
||||
{story_bible}
|
||||
|
||||
Chapter outline:
|
||||
{chapter_outline}
|
||||
|
||||
Prior canon:
|
||||
{prior_canon}
|
||||
|
||||
Legacy source draft (reference only; it is not canon and may contradict this outline):
|
||||
{source_prose}
|
||||
|
||||
Human revision notes:
|
||||
{human_notes}
|
||||
|
||||
Return atomic requirements. Mark only indispensable story events as required; staging, clothing, speaker choice,
|
||||
incidental props, and optional texture must be required:false.
|
||||
{{"day_start":"", "day_end":"", "target_words":5500, "chapter_constraints":[], "exact_values":[],
|
||||
"forbidden_events":[], "final_image":"", "scenes":[{{"number":1,"purpose":"","location":"",
|
||||
"present":[],"word_budget":1300,"beats":[{{"text":"","required":true}}],"ending_state":""}}], "forbidden_shortcuts":[]}}
|
||||
"""
|
||||
|
||||
DEFAULT_DRAFT_SYSTEM = """Write polished adult progression-fantasy prose in close third person past tense.
|
||||
Return finished chapter prose only. Keep dialogue clean and natural. Dramatize relationship milestones on page.
|
||||
Do not turn slavery into a metaphor for employment, make constrained characters act automatically free,
|
||||
or replace lived behavior with repeated moral speeches. Avoid legal and procedural story engines.
|
||||
End with [[END_OF_CHAPTER]] on its own line."""
|
||||
|
||||
DEFAULT_SCENE_DRAFT_SYSTEM = """Write polished adult progression-fantasy prose in close third person past tense.
|
||||
Return finished scene prose only. Keep dialogue clean and natural. Dramatize relationship milestones on page.
|
||||
Do not turn slavery into a metaphor for employment, make constrained characters act automatically free,
|
||||
or replace lived behavior with repeated moral speeches. Avoid legal and procedural story engines.
|
||||
End with [[END_OF_SCENE]] on its own line."""
|
||||
|
||||
STANDALONE_SCENE_PLAN_SYSTEM = """You are a fiction scene architect. Return one valid JSON object only.
|
||||
Preserve every supplied canon fact and source boundary. Plan a complete dramatized scene, not a synopsis.
|
||||
Do not invent authority for provisional or planning sources, and do not silently resolve contradictions."""
|
||||
|
||||
STANDALONE_SCENE_PLAN_TEMPLATE = """Plan one complete scene titled {title}.
|
||||
|
||||
Scene brief:
|
||||
{brief}
|
||||
|
||||
Cited source context:
|
||||
{context}
|
||||
|
||||
Author constraints:
|
||||
{constraints}
|
||||
|
||||
Forbidden events:
|
||||
{forbidden_events}
|
||||
|
||||
Boundary constraints:
|
||||
{boundary_constraints}
|
||||
|
||||
Target words: {target_words}
|
||||
|
||||
Return strict JSON in this shape:
|
||||
{{"purpose":"","pov_character":"","tense":"past","location":"","time_context":"",
|
||||
"present":[],"target_words":{target_words},"beats":[{{"text":"","required":true}}],
|
||||
"exact_values":[],"constraints":[],"forbidden_events":[],"ending_state":"","final_image":"",
|
||||
"boundary_constraints":[],"continuity_questions":[]}}
|
||||
|
||||
Use 3-8 concrete beats. Mark only indispensable events required:true. Preserve unresolved continuity questions
|
||||
instead of guessing. The ending state and final image must define where the scene stops."""
|
||||
|
||||
SCENE_IDEA_TYPES = {
|
||||
"quiet_connection": "A short, low-stakes character moment whose meaning comes from attention or choice.",
|
||||
"major_turn": "A full dramatic turn that materially changes a goal, relationship, status, or commitment.",
|
||||
"physical_escalation": (
|
||||
"A chosen physical threshold materially advances intimacy, danger, combat, exertion, or vulnerability. "
|
||||
"Routine care, incidental proximity, injury assistance, and helping someone dress or undress do not qualify."
|
||||
),
|
||||
"conflict_pressure": "Opposed wants, values, or tactics create direct pressure without requiring rupture.",
|
||||
"boundary_choice": "A limit, permission, refusal, duty, or autonomy question is tested through action.",
|
||||
"revelation_discovery": "New information or recognition changes what a character understands or can choose.",
|
||||
"aftermath_consequence": "Characters absorb, interpret, or act on the concrete cost of an earlier event.",
|
||||
"competence_task": "Work, craft, training, care, or problem-solving reveals character and changes conditions.",
|
||||
"external_plot_action": "An outside objective, threat, journey, contest, or obstacle drives the scene.",
|
||||
"ensemble_social": "A group, household, team, family, or public setting changes interpersonal dynamics.",
|
||||
}
|
||||
|
||||
SCENE_IDEATION_SYSTEM = """You are a continuity-aware fiction development editor.
|
||||
Return one valid JSON object only. Propose genuinely new standalone scene opportunities grounded in the
|
||||
cited evidence. Preserve each source's authority label: canon is binding, planning is guidance, and
|
||||
provisional material is not established fact. Do not draft prose, silently settle open questions, or
|
||||
repeat an existing scene as a new proposal."""
|
||||
|
||||
SCENE_IDEATION_TEMPLATE = """Propose {candidate_count} distinct standalone scenes for {work_title}.
|
||||
|
||||
Development focus:
|
||||
{focus}
|
||||
|
||||
Target book:
|
||||
{target_book}
|
||||
|
||||
Cited source context:
|
||||
{context}
|
||||
|
||||
Available scene types (choose exactly one primary type per candidate):
|
||||
{scene_types}
|
||||
|
||||
Return strict JSON in this shape:
|
||||
{{"candidates":[{{"title":"","brief":"","purpose":"","placement":"","pov_character":"",
|
||||
"scene_type":"quiet_connection","type_fit":"","scope_fit":"","prerequisites":[],
|
||||
"target_words":1800,"citations":["SRC-01"],
|
||||
"opportunity":"","constraints":[],
|
||||
"future_opportunities":[],"forbidden_events":[],"boundary_constraints":[],
|
||||
"continuity_questions":[],"risks":[]}}]}}
|
||||
|
||||
Return exactly {candidate_count} candidates. Every candidate must cite at least one supplied source ID
|
||||
and explain the unspent story question or opportunity it uses. For each candidate, list 2-4
|
||||
future_opportunities that its ending creates, sharpens, or leaves newly available. These must be
|
||||
consequential later possibilities, not promises, mandatory sequel hooks, or events completed inside
|
||||
the proposed scene. Keep the brief concrete enough for a later scene planner, but preserve uncertain
|
||||
chronology and unresolved continuity as questions. Prefer different dramatic functions, character
|
||||
pairings, pressures, locations, and endings rather than cosmetic variations of one idea. Use distinct
|
||||
scene types until every available type is represented; only then repeat a type.
|
||||
|
||||
The target book is a hard placement boundary. Every event, relationship state, location, role, ability,
|
||||
object, and household condition required by the scene must exist by or during that book. Later-book canon
|
||||
may constrain what the scene cannot resolve, but it cannot supply the scene's premise. If a cited passage
|
||||
describes an event first occurring after the target book, do not use that event as a prerequisite. State
|
||||
all prerequisites and explain scope_fit using supplied evidence. Do not propose a candidate whose scope fit
|
||||
is uncertain; use a different candidate grounded inside the selected book.
|
||||
|
||||
The scene type must describe the scene's actual dramatic change, not its surface activity. Explain type_fit.
|
||||
For physical_escalation, require a deliberate choice that crosses or sharply approaches a meaningful
|
||||
established physical threshold and changes later possibilities. Routine caregiving, medical assistance,
|
||||
incidental touch, bathing, changing clothes, or helping someone dress or undress is insufficient by itself."""
|
||||
|
||||
SCENE_IDEATION_COMPACT_TEMPLATE = """Propose {candidate_count} distinct standalone scenes for
|
||||
{work_title}.
|
||||
|
||||
Development focus:
|
||||
{focus}
|
||||
|
||||
Target book:
|
||||
{target_book}
|
||||
|
||||
Cited source context:
|
||||
{context}
|
||||
|
||||
Available scene types (choose exactly one primary type per candidate):
|
||||
{scene_types}
|
||||
|
||||
Return strict JSON in this compact shape:
|
||||
{{"candidates":[{{"title":"","brief":"","scene_type":"quiet_connection",
|
||||
"citations":["SRC-01"],"opportunity":"","future_opportunities":[]}}]}}
|
||||
|
||||
Return exactly {candidate_count} candidates. Every candidate must cite supplied source IDs, state
|
||||
the existing question or opportunity it spends, and list 2-4 consequential possibilities its
|
||||
ending creates. The target book is a hard premise boundary: later-book evidence may constrain an
|
||||
idea but cannot supply its prerequisite. Obey the complete governing documents and use distinct
|
||||
requested scene types until all are represented; then repeat.
|
||||
The brief must contain the concrete dramatic action and ending change, not planning notes or prose.
|
||||
|
||||
The scene type must describe the actual dramatic change rather than surface activity. A
|
||||
physical_escalation must cross or sharply approach a meaningful established physical threshold,
|
||||
reveal a person-specific independent choice, and change later possibilities. Routine care,
|
||||
incidental touch, generic sensory experiments, clothing assistance, or proving competent consent
|
||||
and stopping does not qualify."""
|
||||
|
||||
STANDALONE_SCENE_PROSE_SYSTEM = """Write polished, immersive fiction in the requested point of view and tense.
|
||||
Return finished scene prose only. Treat cited context as evidence with the authority labels shown. Never promote
|
||||
planning or provisional material into canon merely because it was retrieved. Obey the approved plan, constraints,
|
||||
forbidden events, exact values, and ending boundary. End with [[END_OF_SCENE]] on its own line."""
|
||||
|
||||
STANDALONE_SCENE_PROSE_TEMPLATE = """Write the complete scene: {title}.
|
||||
|
||||
Scene brief:
|
||||
{brief}
|
||||
|
||||
Cited context:
|
||||
{context}
|
||||
|
||||
Approved scene plan:
|
||||
{plan}
|
||||
|
||||
Frozen requirements:
|
||||
{requirements}
|
||||
|
||||
Target {target_words} words. Do not add a scene heading, explain the plan, cite source IDs in prose, summarize
|
||||
later events, or continue beyond the approved ending state and final image.
|
||||
Return prose followed by [[END_OF_SCENE]] on its own line."""
|
||||
|
||||
STANDALONE_SCENE_REVIEW_SYSTEM = """You are a strict fiction continuity editor. Return one valid JSON object only.
|
||||
Use only the supplied cited context, approved plan, frozen requirements, and actual prose. Do not invent repairs.
|
||||
Every reported prose defect must include one exact contiguous quotation from the candidate scene."""
|
||||
|
||||
STANDALONE_SCENE_REVIEW_TEMPLATE = """Review this standalone scene.
|
||||
|
||||
Cited context:
|
||||
{context}
|
||||
|
||||
Approved plan:
|
||||
{plan}
|
||||
|
||||
Frozen requirements:
|
||||
{requirements}
|
||||
|
||||
Candidate scene:
|
||||
{prose}
|
||||
|
||||
Return strict JSON:
|
||||
{{"passed":true,"requirement_results":[{{"requirement_id":"","status":"HIT|PARTIAL|MISSED|CONTRADICTED|UNVERIFIABLE","evidence_quote":"","details":""}}],
|
||||
"findings":[{{"severity":"LOW|MEDIUM|HIGH|CRITICAL","category":"canon|chronology|spatial|object|financial|relationship|knowledge|boundary|logic|prose","evidence_quote":"exact prose substring","description":"","suggested_revision":""}}],
|
||||
"observed_state":{{}},"proposed_changes":[]}}
|
||||
|
||||
Return exactly one result for every requirement ID. HIGH or CRITICAL contradictions, missing required beats,
|
||||
forbidden events, unsupported canon claims, and boundary violations make passed false. A scene may validly have
|
||||
no state changes."""
|
||||
|
||||
DEFAULT_DRAFT_TEMPLATE = """Write Chapter {chapter_number}: {chapter_title}.
|
||||
|
||||
Canon context:
|
||||
{context}
|
||||
|
||||
Approved scene plan:
|
||||
{scene_plan}
|
||||
|
||||
The approved plan is the exclusive event scope for this chapter. Obey every constraint and exact value.
|
||||
Do not add later events, purchases, training, travel, relationship milestones, or hooks after its final scene.
|
||||
Complete every planned scene without skipping important days, then stop at the specified final image.
|
||||
Write 5,000-6,500 words. Use the scene word budgets to fully dramatize rather than summarize events.
|
||||
Return prose followed by [[END_OF_CHAPTER]].
|
||||
"""
|
||||
|
||||
DEFAULT_FULL_CHAPTER_DRAFT_TEMPLATE = """Write the complete Chapter {chapter_number}: {chapter_title}.
|
||||
|
||||
Previous chapter (read-only canon and voice reference; never contradict its events or physical details):
|
||||
{source_chapter}
|
||||
|
||||
Current structured canon:
|
||||
{structured_canon}
|
||||
|
||||
Approved chapter plan:
|
||||
{scene_plan}
|
||||
|
||||
Write the entire chapter as continuous prose without scene headings. Treat the scene divisions as internal
|
||||
structure, not separate stories: transitions must be natural, only the final scene may conclude the chapter,
|
||||
and no scene may repeat an earlier scene's summary or closing thought. Preserve every exact value and stop
|
||||
at the specified final image. Target 5,000-6,500 words.
|
||||
Return prose followed by [[END_OF_CHAPTER]] on its own line.
|
||||
"""
|
||||
|
||||
DEFAULT_SCENE_DRAFT_TEMPLATE = """Write scene {scene_number} of Chapter {chapter_number}: {chapter_title}.
|
||||
|
||||
Canon context:
|
||||
{context}
|
||||
|
||||
Approved chapter plan:
|
||||
{scene_plan}
|
||||
|
||||
Current scene contract:
|
||||
{scene}
|
||||
|
||||
Previous chapter (read-only canon and voice reference; never contradict its events or physical details):
|
||||
{source_chapter}
|
||||
|
||||
Style reference (match its narrative texture, not its events or wording):
|
||||
{style_excerpt}
|
||||
|
||||
Tail of prose immediately before this scene:
|
||||
{previous_tail}
|
||||
|
||||
Write only the current scene, targeting {target_words} words. Fulfill its required beats while preserving
|
||||
chapter-level voice and momentum. Begin with a natural transition from the preceding prose, if any. Do not
|
||||
repeat prior events, add a scene heading, summarize later scenes, or write beyond this scene's ending state.
|
||||
Hard scene boundary: {boundary_constraints}
|
||||
Return prose followed by [[END_OF_SCENE]] on its own line.
|
||||
"""
|
||||
|
||||
DEFAULT_CONTINUITY_TEMPLATE = """Extract the complete chapter state and immutable state changes.
|
||||
Return strict JSON only in this shape:
|
||||
{{"schema_version":2,"through_chapter":{chapter_number},"state_document":{{"timeline":{{}},
|
||||
"scene_end":{{}},"characters":{{}},"inventory":[],"money":[],"relationships":[],
|
||||
"open_threads":[],"promises_and_constraints":[],"reveals":{{}},"chapter_summary":[]}},
|
||||
"changes":[{{"entity_key":"character.corin.vale","entity_kind":"character",
|
||||
"canonical_name":"Corin Vale","change_type":"MONEY_CHANGED","predicate":"finances.balance",
|
||||
"operation":"SET|ADD|REMOVE|TRANSFER|OPEN|CLOSE","previous_value":null,"new_value":null,
|
||||
"related_entity_key":"","evidence_quote":"exact prose substring","evidence_location":""}}],
|
||||
"objective_findings":[{{"severity":"MEDIUM|HIGH|CRITICAL","category":"canon|chronology|exact_value|scene_boundary|premature_knowledge",
|
||||
"location":"","evidence_quote":"one unique exact prose substring","description":"","suggested_revision":"minimal replacement",
|
||||
"objective":true,"exact_patch_suitable":true}}]}}
|
||||
|
||||
Track changes to people, items, locations, organizations, accounts, relationships, promises, injuries,
|
||||
knowledge, ownership, custody, money, magic, and plot threads. Use stable lowercase entity keys.
|
||||
Every change needs an exact quotation from the chapter. Do not invent, repair, or infer unsupported facts.
|
||||
Also report at most eight objective, material defects: contradictions with prior canon, chronology errors,
|
||||
premature knowledge or state changes, wrong exact values, omitted required beats, violated constraints or forbidden
|
||||
events, and writing beyond a planned scene/chapter boundary. Check every required beat, exact value, constraint,
|
||||
forbidden event, forbidden shortcut, and the final image before returning no findings.
|
||||
Do not report subjective prose preferences. Every finding must quote one unique exact prose substring.
|
||||
|
||||
Approved scene plan:
|
||||
{scene_plan}
|
||||
|
||||
Prior canon:
|
||||
{prior_canon}
|
||||
|
||||
Chapter:
|
||||
{prose}
|
||||
"""
|
||||
|
||||
DEFAULT_FINAL_STATE_TEMPLATE = """Extract only the compact final chapter state and immutable state changes.
|
||||
Return strict JSON only in this shape:
|
||||
{{"schema_version":2,"through_chapter":{chapter_number},"state_document":{{"timeline":{{}},
|
||||
"scene_end":{{}},"chapter_summary":[],"open_threads":[]}},
|
||||
"changes":[{{"entity_key":"character.corin.vale","entity_kind":"character",
|
||||
"canonical_name":"Corin Vale","change_type":"STATE_CHANGED","predicate":"state",
|
||||
"operation":"SET|ADD|REMOVE|TRANSFER|OPEN|CLOSE","previous_value":null,"new_value":null,
|
||||
"related_entity_key":"","evidence_quote":"exact prose substring","evidence_location":""}}]}}
|
||||
|
||||
Return only facts changed by this chapter. Every change requires one exact contiguous prose quotation.
|
||||
Copy previous_value exactly from prior canon when that predicate already exists; do not summarize or shorten it.
|
||||
Do not perform editorial review and do not regenerate complete character, inventory, or relationship summaries.
|
||||
|
||||
Approved scene plan:
|
||||
{scene_plan}
|
||||
|
||||
Prior canon:
|
||||
{prior_canon}
|
||||
|
||||
Final chapter:
|
||||
{prose}
|
||||
"""
|
||||
|
||||
DEFAULT_QUALITY_REVIEW_TEMPLATE = """Review this complete chapter before state extraction.
|
||||
Return strict JSON only:
|
||||
{{"findings":[{{"severity":"MEDIUM|HIGH|CRITICAL","category":"chronology|continuity|logic|character|pacing|repetition|prose|contract",
|
||||
"location":"","evidence_quote":"one unique exact chapter substring","description":"",
|
||||
"suggested_revision":"minimal exact replacement for evidence_quote","objective":true,
|
||||
"exact_patch_suitable":true}}]}}
|
||||
|
||||
Report at most six material defects. Check the exact handoff from the previous chapter, chronology, causal logic,
|
||||
character agency and consent, physical condition, inventory, money, repeated thematic explanation, awkward
|
||||
contract-like prose, all required beats and constraints, and the final image. Detect contradictions inside the
|
||||
approved plan as well as contradictions between plan and prose. Do not report taste preferences. A patch is
|
||||
suitable only when replacing one unique local passage can fix the issue without inventing unsupported facts.
|
||||
|
||||
Previous chapter:
|
||||
{previous_chapter}
|
||||
|
||||
Approved plan:
|
||||
{scene_plan}
|
||||
|
||||
Candidate chapter:
|
||||
{prose}
|
||||
"""
|
||||
|
||||
DEFAULT_STATE_JUDGE_TEMPLATE = """Judge the chapter against every frozen contract requirement.
|
||||
Return strict JSON only:
|
||||
{{"requirements":[{{"requirement_id":"", "status":"HIT|PARTIAL|MISSED|CONTRADICTED|UNVERIFIABLE", "evidence_quote":"exact prose substring", "evidence_location":"", "details":""}}],
|
||||
"findings":[{{"severity":"LOW|MEDIUM|HIGH|CRITICAL","category":"","location":"","evidence_quote":"exact prose substring","description":"","suggested_revision":"","objective":true,"exact_patch_suitable":true}}],
|
||||
"missing_state_changes":[{{"entity_key":"","entity_kind":"","canonical_name":"",
|
||||
"change_type":"","predicate":"","operation":"SET|ADD|REMOVE|TRANSFER|OPEN|CLOSE",
|
||||
"previous_value":null,"new_value":null,"related_entity_key":"","evidence_quote":"exact prose substring",
|
||||
"evidence_location":""}}]}}
|
||||
|
||||
Return exactly one result for every requirement ID, in the supplied order. HIT means a positive beat occurred
|
||||
or a prohibition/constraint was obeyed. Every HIT or PARTIAL result needs an exact contiguous quotation from
|
||||
the prose. Mark unsupported claims UNVERIFIABLE. Any PARTIAL, MISSED, CONTRADICTED, or UNVERIFIABLE item
|
||||
is a defect to report. Check exact chronology, arithmetic, ownership, injuries, knowledge, promises, scene
|
||||
boundaries, forbidden events, forbidden montage, and the final image.
|
||||
Also perform one holistic continuity, character, pacing, and prose audit. Return at most eight concrete findings.
|
||||
Objective means a demonstrable canon, logic, continuity, chronology, or scene-execution defect, not a taste
|
||||
preference. Mark exact_patch_suitable only when a small local edit can fix it. LOW style preferences must not
|
||||
be objective.
|
||||
Also compare the extracted state and proposed changes with the prose. Return every material person, item,
|
||||
ownership, custody, money, injury, knowledge, relationship, promise, location, magic, and plot-thread change
|
||||
missing from the proposed delta. Do not repeat changes already present.
|
||||
|
||||
Frozen contract:
|
||||
{contract}
|
||||
|
||||
Prior approved state:
|
||||
{prior_state}
|
||||
|
||||
Extracted chapter state:
|
||||
{observed_state}
|
||||
|
||||
Proposed state changes:
|
||||
{proposed_delta}
|
||||
|
||||
Approved scene plan:
|
||||
{scene_plan}
|
||||
|
||||
Chapter prose:
|
||||
{prose}
|
||||
"""
|
||||
|
||||
DEFAULT_REVIEW_TEMPLATE = """Review this chapter as the {review_kind} editor.
|
||||
Return one JSON object: {{"findings":[{{"severity":"LOW|MEDIUM|HIGH|CRITICAL","category":"","location":"","description":"","suggested_revision":""}}]}}.
|
||||
Report only concrete issues. Check against the supplied canon and scene plan. For character review, verify that
|
||||
power, freedom, consent, and conditioned behavior are shown consistently without making characters meek.
|
||||
For pacing review, reject important days or relationship milestones summarized in montage. For continuity,
|
||||
check chronology, injuries, money, possessions, magic, and prior promises.
|
||||
|
||||
Context:
|
||||
{context}
|
||||
|
||||
Scene plan:
|
||||
{scene_plan}
|
||||
|
||||
Chapter:
|
||||
{prose}
|
||||
"""
|
||||
|
||||
BOOK_STRUCTURE_REVIEW_SYSTEM = """You are a strict developmental fiction editor. Return one valid JSON object
|
||||
only. Judge the supplied approved planning contracts for structural coherence; do not draft prose or invent
|
||||
missing canon. HIGH and CRITICAL findings are blocking."""
|
||||
|
||||
BOOK_STRUCTURE_REVIEW_TEMPLATE = """Perform a {review_level} review of this book state.
|
||||
|
||||
Book state (complete for manuscript review, act slice for act review):
|
||||
{state}
|
||||
|
||||
Return strict JSON:
|
||||
{{"findings":[{{"severity":"INFO|LOW|MEDIUM|HIGH|CRITICAL",
|
||||
"category":"structure|continuity|chronology|character|plot|pacing|contract|canon|logic|relationship|other",
|
||||
"chapter_key":"","description":"","suggested_revision":""}}]}}
|
||||
|
||||
Use only chapter keys present in the supplied state. Report concrete contract defects, causal gaps, impossible
|
||||
dependencies, misplaced reveals, broken arc progression, pacing failures, or contradictory ending states.
|
||||
Return an empty findings list when there are no material defects."""
|
||||
|
||||
BOOK_CONTINUITY_REVIEW_SYSTEM = """You are a strict fiction continuity editor. Return one valid JSON object
|
||||
only. Compare the ordered approved scene packets with the book continuity ledger. Do not rewrite prose or infer
|
||||
facts not established by the supplied material. HIGH and CRITICAL findings are blocking."""
|
||||
|
||||
BOOK_CONTINUITY_REVIEW_TEMPLATE = """Review continuity across these ordered book-state scene placements.
|
||||
|
||||
Continuity ledger and chapter contracts:
|
||||
{state}
|
||||
|
||||
Ordered approved scene packets:
|
||||
{scene_packets}
|
||||
|
||||
Return strict JSON:
|
||||
{{"findings":[{{"severity":"INFO|LOW|MEDIUM|HIGH|CRITICAL",
|
||||
"category":"continuity|chronology|canon|character|relationship|location|object|injury|route|promise|money|logic|other",
|
||||
"chapter_key":"","description":"","suggested_revision":""}}]}}
|
||||
|
||||
Use only supplied chapter keys. Check establishment and resolution order, knowledge, injuries, routes, custody,
|
||||
objects, money, promises, relationships, locations, and scene-to-scene state. Return an empty findings list when
|
||||
there are no material defects."""
|
||||
|
||||
DEFAULT_TARGETED_VERIFICATION_TEMPLATE = """Verify only the supplied findings and contract requirements
|
||||
against the revised chapter. Do not search for or report new issues. Return strict JSON only:
|
||||
{{"finding_results":[{{"finding_id":"","status":"RESOLVED|UNRESOLVED|UNVERIFIABLE","evidence_quote":"exact prose substring","details":""}}],
|
||||
"requirement_results":[{{"requirement_id":"","status":"HIT|PARTIAL|MISSED|CONTRADICTED|UNVERIFIABLE","evidence_quote":"exact prose substring","evidence_location":"","details":""}}]}}
|
||||
|
||||
Findings to verify:
|
||||
{findings}
|
||||
|
||||
Contract requirements to verify:
|
||||
{requirements}
|
||||
|
||||
Changed passages:
|
||||
{changed_passages}
|
||||
|
||||
Revised chapter:
|
||||
{prose}
|
||||
"""
|
||||
|
||||
DEFAULT_REPAIR_PLAN_TEMPLATE = """Create a precise structural repair plan for the chapter.
|
||||
Return strict JSON only. Preserve unaffected scenes and specify exact corrections, required values,
|
||||
chronology, scene boundaries, and the intended final image. Do not write prose.
|
||||
|
||||
Context:
|
||||
{context}
|
||||
|
||||
Approved scene plan:
|
||||
{scene_plan}
|
||||
|
||||
Findings:
|
||||
{findings}
|
||||
|
||||
Current chapter:
|
||||
{prose}
|
||||
"""
|
||||
|
||||
DEFAULT_REVISION_TEMPLATE = """Rewrite the chapter according to the approved scene plan and repair plan.
|
||||
Preserve strong prose, natural dialogue, established scenes, and all unaffected details. Do not mention revision.
|
||||
Return the full revised chapter followed by [[END_OF_CHAPTER]].
|
||||
|
||||
Context:
|
||||
{context}
|
||||
|
||||
Scene plan:
|
||||
{scene_plan}
|
||||
|
||||
Findings:
|
||||
{findings}
|
||||
|
||||
Sol repair plan:
|
||||
{repair_plan}
|
||||
|
||||
Current chapter:
|
||||
{prose}
|
||||
"""
|
||||
|
||||
DEFAULT_PATCH_REVISION_TEMPLATE = """Patch only the passages required by the concrete findings below.
|
||||
Return strict JSON only in this shape:
|
||||
{{"edits":[{{"old_text":"exact unique text copied from the chapter","new_text":"replacement text"}}]}}
|
||||
|
||||
Each old_text must occur exactly once in the original chapter. Edits may not overlap. Keep total touched text
|
||||
under five percent of the chapter. Do not rewrite, summarize, reformat, or return unchanged chapter text.
|
||||
Address only the supplied findings. Do not perform additional polishing.
|
||||
|
||||
Findings:
|
||||
{findings}
|
||||
|
||||
Human notes:
|
||||
{human_notes}
|
||||
|
||||
Current chapter:
|
||||
{prose}
|
||||
"""
|
||||
96
control_plane/authoring/runner.py
Normal file
96
control_plane/authoring/runner.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from control_plane.authoring.models import ChapterRevision
|
||||
from graph.bootstrap import champion_story_authoring_graph_v2
|
||||
from graph.models import GraphRun, GraphRunStatus
|
||||
|
||||
|
||||
class StoryWorkflowRunner:
|
||||
def __init__(self, workflow: object) -> None:
|
||||
self.workflow = workflow
|
||||
|
||||
def start(self, revision: ChapterRevision, *, max_revisions: int = 2) -> GraphRun:
|
||||
version = champion_story_authoring_graph_v2()
|
||||
graph_run = GraphRun.objects.create(
|
||||
execution_graph_version=version,
|
||||
project=revision.chapter.story.project,
|
||||
status=GraphRunStatus.RUNNING,
|
||||
started_at=timezone.now(),
|
||||
current_node="build_context",
|
||||
metadata={
|
||||
"revision_id": str(revision.id),
|
||||
"initial_revision_id": str(revision.id),
|
||||
"current_revision_id": str(revision.id),
|
||||
},
|
||||
)
|
||||
thread_id = f"story:{revision.chapter.story_id}:chapter:{revision.chapter.number}:revision:{revision.id}"
|
||||
revision.graph_thread_id = thread_id
|
||||
revision.save(update_fields=["graph_thread_id", "updated_at"])
|
||||
initial = {
|
||||
"story_id": str(revision.chapter.story_id),
|
||||
"chapter_id": str(revision.chapter_id),
|
||||
"revision_id": str(revision.id),
|
||||
"graph_run_id": graph_run.id,
|
||||
"thread_id": thread_id,
|
||||
"editorial_finding_ids": [],
|
||||
"patch_finding_ids": [],
|
||||
"patch_attempted": False,
|
||||
"patch_status": "not_needed",
|
||||
"verification_status": "not_needed",
|
||||
}
|
||||
return self._invoke(graph_run, initial)
|
||||
|
||||
def resume(self, graph_run_id: int, decision: dict[str, Any]) -> GraphRun:
|
||||
from langgraph.types import Command
|
||||
|
||||
graph_run = GraphRun.objects.get(id=graph_run_id)
|
||||
if graph_run.status == GraphRunStatus.CANCELLED:
|
||||
raise RuntimeError("cancelled story runs cannot be resumed")
|
||||
graph_run.status = GraphRunStatus.RUNNING
|
||||
graph_run.failure_reason = ""
|
||||
graph_run.save(update_fields=["status", "failure_reason", "updated_at"])
|
||||
value = None if decision.get("action") == "retry" else Command(resume=decision)
|
||||
return self._invoke(graph_run, value)
|
||||
|
||||
def _invoke(self, graph_run: GraphRun, value: object) -> GraphRun:
|
||||
thread_id = ChapterRevision.objects.get(
|
||||
id=graph_run.metadata["revision_id"]
|
||||
).graph_thread_id
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
try:
|
||||
self.workflow.invoke(value, config=config)
|
||||
snapshot = self.workflow.get_state(config)
|
||||
except Exception as exc:
|
||||
graph_run.status = GraphRunStatus.FAILED
|
||||
graph_run.failure_reason = str(exc)[:4000]
|
||||
graph_run.completed_at = timezone.now()
|
||||
graph_run.save(
|
||||
update_fields=["status", "failure_reason", "completed_at", "updated_at"]
|
||||
)
|
||||
raise
|
||||
next_nodes = tuple(snapshot.next or ())
|
||||
current_revision_id = str(snapshot.values.get("revision_id") or graph_run.metadata["revision_id"])
|
||||
graph_run.metadata = {
|
||||
**graph_run.metadata,
|
||||
"current_revision_id": current_revision_id,
|
||||
}
|
||||
if next_nodes:
|
||||
graph_run.status = GraphRunStatus.PAUSED
|
||||
graph_run.current_node = str(next_nodes[0])
|
||||
graph_run.failure_reason = "AWAITING_STORY_APPROVAL"
|
||||
graph_run.save(
|
||||
update_fields=["status", "current_node", "failure_reason", "metadata", "updated_at"]
|
||||
)
|
||||
else:
|
||||
graph_run.status = GraphRunStatus.COMPLETE
|
||||
graph_run.current_node = "complete"
|
||||
graph_run.completed_at = timezone.now()
|
||||
graph_run.metadata = {**graph_run.metadata, "final_state": dict(snapshot.values)}
|
||||
graph_run.save(
|
||||
update_fields=["status", "current_node", "completed_at", "metadata", "updated_at"]
|
||||
)
|
||||
return graph_run
|
||||
387
control_plane/authoring/scene_context.py
Normal file
387
control_plane/authoring/scene_context.py
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from control_plane.authoring.models import (
|
||||
DocumentAuthority,
|
||||
SourceDocumentVersion,
|
||||
SourcePassage,
|
||||
Work,
|
||||
WorkType,
|
||||
)
|
||||
from control_plane.authoring.state_management import json_sha256
|
||||
|
||||
STOP_WORDS = {
|
||||
"and",
|
||||
"are",
|
||||
"about",
|
||||
"after",
|
||||
"again",
|
||||
"also",
|
||||
"before",
|
||||
"being",
|
||||
"between",
|
||||
"but",
|
||||
"could",
|
||||
"for",
|
||||
"from",
|
||||
"has",
|
||||
"her",
|
||||
"him",
|
||||
"his",
|
||||
"have",
|
||||
"into",
|
||||
"its",
|
||||
"must",
|
||||
"not",
|
||||
"scene",
|
||||
"she",
|
||||
"should",
|
||||
"that",
|
||||
"the",
|
||||
"their",
|
||||
"them",
|
||||
"then",
|
||||
"there",
|
||||
"they",
|
||||
"this",
|
||||
"through",
|
||||
"what",
|
||||
"when",
|
||||
"where",
|
||||
"which",
|
||||
"while",
|
||||
"with",
|
||||
"would",
|
||||
"was",
|
||||
"were",
|
||||
"write",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RankedPassage:
|
||||
passage: SourcePassage
|
||||
score: float
|
||||
reason: str
|
||||
|
||||
|
||||
def query_terms(query: str, limit: int = 16) -> list[str]:
|
||||
counts: dict[str, int] = {}
|
||||
for token in re.findall(r"[a-zA-Z][a-zA-Z0-9']{2,}", query.lower()):
|
||||
if token in STOP_WORDS:
|
||||
continue
|
||||
counts[token] = counts.get(token, 0) + 1
|
||||
ordered = sorted(
|
||||
counts.items(), key=lambda item: (-item[1], -len(item[0]), item[0])
|
||||
)
|
||||
return [token for token, _count in ordered[:limit]]
|
||||
|
||||
|
||||
def retrieve_scene_passages(
|
||||
*,
|
||||
work: Work,
|
||||
query: str,
|
||||
authorities: list[str] | None = None,
|
||||
pinned_document_keys: list[str] | None = None,
|
||||
limit: int = 24,
|
||||
) -> list[RankedPassage]:
|
||||
authorities = authorities or [DocumentAuthority.CANON]
|
||||
invalid = sorted(set(authorities) - set(DocumentAuthority.values))
|
||||
if invalid:
|
||||
raise ValueError(f"unsupported document authorities: {', '.join(invalid)}")
|
||||
pinned = {value.strip() for value in (pinned_document_keys or []) if value.strip()}
|
||||
terms = query_terms(query)
|
||||
visible_work_ids = list(
|
||||
Work.objects.filter(series=work.series, work_type=WorkType.SERIES_REFERENCE).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
visible_work_ids.append(work.id)
|
||||
base = SourcePassage.objects.select_related(
|
||||
"document_version__document"
|
||||
).filter(
|
||||
document_version__document__work_id__in=visible_work_ids,
|
||||
document_version__authority__in=authorities,
|
||||
document_version__superseded_by__isnull=True,
|
||||
)
|
||||
|
||||
candidates: dict[object, SourcePassage] = {}
|
||||
if terms:
|
||||
term_filter = Q()
|
||||
for term in terms:
|
||||
term_filter |= Q(content__icontains=term)
|
||||
term_filter |= Q(document_version__document__title__icontains=term)
|
||||
for passage in base.filter(term_filter)[:4000]:
|
||||
candidates[passage.id] = passage
|
||||
if pinned:
|
||||
for passage in base.filter(document_version__document__logical_key__in=pinned)[:2000]:
|
||||
candidates[passage.id] = passage
|
||||
if not candidates:
|
||||
for passage in base[:300]:
|
||||
candidates[passage.id] = passage
|
||||
|
||||
ranked: list[RankedPassage] = []
|
||||
lowered_query = query.lower()
|
||||
for passage in candidates.values():
|
||||
document = passage.document_version.document
|
||||
haystack = passage.content.lower()
|
||||
identity = f"{document.logical_key} {document.title}".lower()
|
||||
score = 0.0
|
||||
matched = []
|
||||
for term in terms:
|
||||
occurrences = haystack.count(term)
|
||||
if occurrences:
|
||||
score += 1.0 + min(occurrences, 4) * 0.5
|
||||
matched.append(term)
|
||||
if term in identity:
|
||||
score += 4.0
|
||||
if document.logical_key in pinned:
|
||||
score += 100.0
|
||||
if passage.document_version.authority == DocumentAuthority.CANON:
|
||||
score += 2.0
|
||||
if document.title.lower() in lowered_query:
|
||||
score += 5.0
|
||||
reason = "pinned" if document.logical_key in pinned else "terms: " + ", ".join(matched[:6])
|
||||
ranked.append(RankedPassage(passage=passage, score=score, reason=reason.strip()))
|
||||
ranked.sort(
|
||||
key=lambda item: (
|
||||
-item.score,
|
||||
item.passage.document_version.document.logical_key,
|
||||
item.passage.ordinal,
|
||||
)
|
||||
)
|
||||
requested_limit = max(1, limit)
|
||||
if not pinned:
|
||||
if len(authorities) == 1:
|
||||
return ranked[:requested_limit]
|
||||
return _select_across_authorities(ranked, authorities, requested_limit)
|
||||
|
||||
pinned_ranked = [
|
||||
item
|
||||
for item in ranked
|
||||
if item.passage.document_version.document.logical_key in pinned
|
||||
]
|
||||
other_ranked = [
|
||||
item
|
||||
for item in ranked
|
||||
if item.passage.document_version.document.logical_key not in pinned
|
||||
]
|
||||
if not other_ranked:
|
||||
return ranked[:requested_limit]
|
||||
|
||||
pinned_budget = min(len(pinned_ranked), max(1, requested_limit * 2 // 3))
|
||||
found_pinned_keys = {
|
||||
item.passage.document_version.document.logical_key for item in pinned_ranked
|
||||
}
|
||||
per_document_limit = max(
|
||||
1,
|
||||
(pinned_budget + len(found_pinned_keys) - 1) // max(1, len(found_pinned_keys)),
|
||||
)
|
||||
selected: list[RankedPassage] = []
|
||||
pinned_counts: dict[str, int] = {}
|
||||
for item in pinned_ranked:
|
||||
document_key = item.passage.document_version.document.logical_key
|
||||
if len(selected) >= pinned_budget:
|
||||
break
|
||||
if pinned_counts.get(document_key, 0) >= per_document_limit:
|
||||
continue
|
||||
selected.append(item)
|
||||
pinned_counts[document_key] = pinned_counts.get(document_key, 0) + 1
|
||||
|
||||
selected.extend(
|
||||
_select_across_authorities(
|
||||
other_ranked,
|
||||
authorities,
|
||||
requested_limit - len(selected),
|
||||
)
|
||||
)
|
||||
selected_ids = {item.passage.id for item in selected}
|
||||
for item in ranked:
|
||||
if len(selected) >= requested_limit:
|
||||
break
|
||||
if item.passage.id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.passage.id)
|
||||
return selected
|
||||
|
||||
|
||||
def _select_across_authorities(
|
||||
ranked: list[RankedPassage], authorities: list[str], limit: int
|
||||
) -> list[RankedPassage]:
|
||||
authority_groups = {
|
||||
authority: [
|
||||
item
|
||||
for item in ranked
|
||||
if item.passage.document_version.authority == authority
|
||||
]
|
||||
for authority in authorities
|
||||
}
|
||||
offsets = {authority: 0 for authority in authorities}
|
||||
selected: list[RankedPassage] = []
|
||||
while len(selected) < limit:
|
||||
added = False
|
||||
for authority in authorities:
|
||||
offset = offsets[authority]
|
||||
group = authority_groups[authority]
|
||||
if offset >= len(group):
|
||||
continue
|
||||
selected.append(group[offset])
|
||||
offsets[authority] += 1
|
||||
added = True
|
||||
if len(selected) >= limit:
|
||||
break
|
||||
if not added:
|
||||
break
|
||||
selected_ids = {item.passage.id for item in selected}
|
||||
for item in ranked:
|
||||
if len(selected) >= limit:
|
||||
break
|
||||
if item.passage.id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.passage.id)
|
||||
return selected
|
||||
|
||||
|
||||
def build_scene_context_pack(
|
||||
*,
|
||||
work: Work,
|
||||
query: str,
|
||||
authorities: list[str] | None = None,
|
||||
pinned_document_keys: list[str] | None = None,
|
||||
governing_document_keys: list[str] | None = None,
|
||||
limit: int = 24,
|
||||
max_chars: int = 50000,
|
||||
) -> tuple[dict, list[RankedPassage]]:
|
||||
authorities = authorities or [DocumentAuthority.CANON]
|
||||
governing_keys = list(
|
||||
dict.fromkeys(
|
||||
value.strip() for value in (governing_document_keys or []) if value.strip()
|
||||
)
|
||||
)
|
||||
ranked = retrieve_scene_passages(
|
||||
work=work,
|
||||
query=query,
|
||||
authorities=authorities,
|
||||
pinned_document_keys=pinned_document_keys,
|
||||
limit=limit,
|
||||
)
|
||||
citations = []
|
||||
rendered = []
|
||||
used_chars = 0
|
||||
kept: list[RankedPassage] = []
|
||||
if governing_keys:
|
||||
visible_work_ids = list(
|
||||
Work.objects.filter(
|
||||
series=work.series,
|
||||
work_type=WorkType.SERIES_REFERENCE,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
visible_work_ids.append(work.id)
|
||||
versions = list(
|
||||
SourceDocumentVersion.objects.select_related("document")
|
||||
.filter(
|
||||
document__work_id__in=visible_work_ids,
|
||||
document__logical_key__in=governing_keys,
|
||||
authority__in=authorities,
|
||||
superseded_by__isnull=True,
|
||||
)
|
||||
.order_by("document__logical_key")
|
||||
)
|
||||
versions_by_key: dict[str, list[SourceDocumentVersion]] = {}
|
||||
for version in versions:
|
||||
versions_by_key.setdefault(version.document.logical_key, []).append(version)
|
||||
missing = [key for key in governing_keys if key not in versions_by_key]
|
||||
ambiguous = [key for key, values in versions_by_key.items() if len(values) > 1]
|
||||
if missing:
|
||||
raise ValueError("governing documents not found: " + ", ".join(missing))
|
||||
if ambiguous:
|
||||
raise ValueError("governing document keys are ambiguous: " + ", ".join(ambiguous))
|
||||
for key in governing_keys:
|
||||
version = versions_by_key[key][0]
|
||||
document = version.document
|
||||
label = f"SRC-{len(citations) + 1:02d}"
|
||||
end_line = version.content.count("\n") + 1
|
||||
block = (
|
||||
f"[{label}] authority={version.authority} source={document.logical_key} "
|
||||
f"version={version.version} lines=1-{end_line} scope=governing-document\n"
|
||||
f"{version.content}"
|
||||
)
|
||||
if used_chars + len(block) > max_chars:
|
||||
raise ValueError("governing documents exceed the context character budget")
|
||||
used_chars += len(block)
|
||||
rendered.append(block)
|
||||
citations.append(
|
||||
{
|
||||
"id": label,
|
||||
"kind": "governing_document",
|
||||
"passage_id": None,
|
||||
"document_version_id": str(version.id),
|
||||
"document_key": document.logical_key,
|
||||
"document_title": document.title,
|
||||
"document_version": version.version,
|
||||
"authority": version.authority,
|
||||
"source_path": version.source_path,
|
||||
"start_line": 1,
|
||||
"end_line": end_line,
|
||||
"start_char": 0,
|
||||
"end_char": len(version.content),
|
||||
"sha256": version.source_sha256,
|
||||
"score": None,
|
||||
"reason": "governing document supplied in full",
|
||||
}
|
||||
)
|
||||
governing_set = set(governing_keys)
|
||||
for item in ranked:
|
||||
passage = item.passage
|
||||
version = passage.document_version
|
||||
document = version.document
|
||||
if document.logical_key in governing_set:
|
||||
continue
|
||||
excerpt = passage.content[:2500]
|
||||
label = f"SRC-{len(citations) + 1:02d}"
|
||||
block = (
|
||||
f"[{label}] authority={version.authority} source={document.logical_key} "
|
||||
f"version={version.version} lines={passage.start_line}-{passage.end_line}\n{excerpt}"
|
||||
)
|
||||
if rendered and used_chars + len(block) > max_chars:
|
||||
continue
|
||||
used_chars += len(block)
|
||||
kept.append(item)
|
||||
citations.append(
|
||||
{
|
||||
"id": label,
|
||||
"passage_id": str(passage.id),
|
||||
"document_key": document.logical_key,
|
||||
"document_title": document.title,
|
||||
"document_version": version.version,
|
||||
"authority": version.authority,
|
||||
"source_path": version.source_path,
|
||||
"start_line": passage.start_line,
|
||||
"end_line": passage.end_line,
|
||||
"start_char": passage.start_char,
|
||||
"end_char": passage.end_char,
|
||||
"sha256": passage.sha256,
|
||||
"score": item.score,
|
||||
"reason": item.reason,
|
||||
}
|
||||
)
|
||||
rendered.append(block)
|
||||
pack = {
|
||||
"schema_version": 1,
|
||||
"work_id": str(work.id),
|
||||
"query": query,
|
||||
"authorities": authorities,
|
||||
"governing_document_keys": governing_keys,
|
||||
"citations": citations,
|
||||
"rendered_context": (
|
||||
"\n\n".join(rendered)
|
||||
if rendered
|
||||
else "(No matching approved source passages.)"
|
||||
),
|
||||
}
|
||||
pack["sha256"] = json_sha256(pack)
|
||||
return pack, kept
|
||||
1690
control_plane/authoring/services.py
Normal file
1690
control_plane/authoring/services.py
Normal file
File diff suppressed because it is too large
Load diff
197
control_plane/authoring/sources.py
Normal file
197
control_plane/authoring/sources.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Max
|
||||
|
||||
from control_plane.authoring.models import (
|
||||
DocumentAuthority,
|
||||
DocumentType,
|
||||
SourceDocument,
|
||||
SourceDocumentVersion,
|
||||
SourcePassage,
|
||||
Work,
|
||||
)
|
||||
|
||||
SUPPORTED_SOURCE_SUFFIXES = {".json", ".log", ".md", ".txt"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceRegistrationResult:
|
||||
path: Path
|
||||
logical_key: str
|
||||
status: str
|
||||
source_sha256: str
|
||||
version: int | None = None
|
||||
passage_count: int = 0
|
||||
|
||||
|
||||
def discover_source_paths(root: Path, include_globs: list[str] | None = None) -> list[Path]:
|
||||
root = root.resolve()
|
||||
if root.is_file():
|
||||
supported = root.suffix.lower() in SUPPORTED_SOURCE_SUFFIXES
|
||||
included = not include_globs or any(
|
||||
fnmatch(root.name, pattern) for pattern in include_globs
|
||||
)
|
||||
return [root] if supported and included else []
|
||||
return sorted(
|
||||
path.resolve()
|
||||
for path in root.rglob("*")
|
||||
if path.is_file()
|
||||
and path.suffix.lower() in SUPPORTED_SOURCE_SUFFIXES
|
||||
and (
|
||||
not include_globs
|
||||
or any(fnmatch(path.relative_to(root).as_posix(), pattern) for pattern in include_globs)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def source_logical_key(path: Path, root: Path) -> str:
|
||||
path = path.resolve()
|
||||
root = root.resolve()
|
||||
if root.is_file():
|
||||
return path.name
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
|
||||
def source_title(path: Path, content: str) -> str:
|
||||
if path.suffix.lower() == ".md":
|
||||
match = re.search(r"^#{1,6}\s+(.+?)\s*$", content, flags=re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return path.stem.replace("-", " ").replace("_", " ").strip().title()
|
||||
|
||||
|
||||
def passage_spans(content: str) -> list[dict[str, int | str]]:
|
||||
lines = content.splitlines(keepends=True)
|
||||
if not lines and content:
|
||||
lines = [content]
|
||||
passages: list[dict[str, int | str]] = []
|
||||
block_start_line: int | None = None
|
||||
block_start_char: int | None = None
|
||||
block_end_line = 0
|
||||
block_end_char = 0
|
||||
cursor = 0
|
||||
|
||||
def finish_block() -> None:
|
||||
nonlocal block_start_line, block_start_char
|
||||
if block_start_line is None or block_start_char is None:
|
||||
return
|
||||
passage_content = content[block_start_char:block_end_char]
|
||||
passages.append(
|
||||
{
|
||||
"ordinal": len(passages) + 1,
|
||||
"start_line": block_start_line,
|
||||
"end_line": block_end_line,
|
||||
"start_char": block_start_char,
|
||||
"end_char": block_end_char,
|
||||
"content": passage_content,
|
||||
"sha256": hashlib.sha256(passage_content.encode("utf-8")).hexdigest(),
|
||||
}
|
||||
)
|
||||
block_start_line = None
|
||||
block_start_char = None
|
||||
|
||||
for line_number, line in enumerate(lines, start=1):
|
||||
content_end = cursor + len(line.rstrip("\r\n"))
|
||||
if line.strip():
|
||||
if block_start_line is None:
|
||||
block_start_line = line_number
|
||||
block_start_char = cursor
|
||||
block_end_line = line_number
|
||||
block_end_char = content_end
|
||||
else:
|
||||
finish_block()
|
||||
cursor += len(line)
|
||||
finish_block()
|
||||
return passages
|
||||
|
||||
|
||||
def inspect_source(path: Path, root: Path) -> SourceRegistrationResult:
|
||||
raw = path.read_bytes()
|
||||
content = raw.decode("utf-8")
|
||||
return SourceRegistrationResult(
|
||||
path=path.resolve(),
|
||||
logical_key=source_logical_key(path, root),
|
||||
status="discovered",
|
||||
source_sha256=hashlib.sha256(raw).hexdigest(),
|
||||
passage_count=len(passage_spans(content)),
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def register_source(
|
||||
*,
|
||||
work: Work,
|
||||
path: Path,
|
||||
root: Path,
|
||||
authority: str,
|
||||
document_type: str = DocumentType.OTHER,
|
||||
) -> SourceRegistrationResult:
|
||||
if authority not in DocumentAuthority.values:
|
||||
raise ValueError(f"unsupported document authority: {authority}")
|
||||
if document_type not in DocumentType.values:
|
||||
raise ValueError(f"unsupported document type: {document_type}")
|
||||
|
||||
path = path.resolve()
|
||||
raw = path.read_bytes()
|
||||
content = raw.decode("utf-8")
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
logical_key = source_logical_key(path, root)
|
||||
document, _ = SourceDocument.objects.get_or_create(
|
||||
work=work,
|
||||
logical_key=logical_key,
|
||||
defaults={
|
||||
"title": source_title(path, content),
|
||||
"document_type": document_type,
|
||||
},
|
||||
)
|
||||
if document.document_type != document_type:
|
||||
raise ValueError(
|
||||
f"source {logical_key} is already registered as {document.document_type}, "
|
||||
f"not {document_type}"
|
||||
)
|
||||
latest = document.versions.order_by("-version").first()
|
||||
if (
|
||||
latest
|
||||
and latest.source_sha256 == digest
|
||||
and latest.authority == authority
|
||||
and latest.source_path == str(path)
|
||||
):
|
||||
return SourceRegistrationResult(
|
||||
path=path,
|
||||
logical_key=logical_key,
|
||||
status="unchanged",
|
||||
source_sha256=digest,
|
||||
version=latest.version,
|
||||
passage_count=latest.passages.count(),
|
||||
)
|
||||
|
||||
version_number = (document.versions.aggregate(value=Max("version"))["value"] or 0) + 1
|
||||
version = SourceDocumentVersion.objects.create(
|
||||
document=document,
|
||||
version=version_number,
|
||||
authority=authority,
|
||||
source_path=str(path),
|
||||
content=content,
|
||||
source_sha256=digest,
|
||||
byte_size=len(raw),
|
||||
supersedes=latest,
|
||||
)
|
||||
spans = passage_spans(content)
|
||||
SourcePassage.objects.bulk_create(
|
||||
[SourcePassage(document_version=version, **span) for span in spans]
|
||||
)
|
||||
return SourceRegistrationResult(
|
||||
path=path,
|
||||
logical_key=logical_key,
|
||||
status="created" if latest is None else "versioned",
|
||||
source_sha256=digest,
|
||||
version=version_number,
|
||||
passage_count=len(spans),
|
||||
)
|
||||
1229
control_plane/authoring/standalone_scenes.py
Normal file
1229
control_plane/authoring/standalone_scenes.py
Normal file
File diff suppressed because it is too large
Load diff
28
control_plane/authoring/state.py
Normal file
28
control_plane/authoring/state.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypedDict
|
||||
|
||||
|
||||
class StoryGraphState(TypedDict, total=False):
|
||||
story_id: str
|
||||
chapter_id: str
|
||||
revision_id: str
|
||||
graph_run_id: int
|
||||
thread_id: str
|
||||
context_snapshot_id: str
|
||||
state_document_id: str
|
||||
scene_plan: dict[str, Any]
|
||||
editorial_finding_ids: list[str]
|
||||
patch_finding_ids: list[str]
|
||||
patch_attempted: bool
|
||||
patch_decision: str
|
||||
patch_status: str
|
||||
patch_source_revision_id: str
|
||||
patch_change_ratio: float
|
||||
changed_passages: list[dict[str, Any]]
|
||||
verification_status: str
|
||||
state_judge_status: str
|
||||
approval_action: str
|
||||
human_notes: str
|
||||
canon_snapshot_id: str
|
||||
export_uri: str
|
||||
279
control_plane/authoring/state_management.py
Normal file
279
control_plane/authoring/state_management.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def json_sha256(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_entity_key(kind: str, name: str, supplied: str = "") -> str:
|
||||
value = supplied.strip().lower() or f"{kind}.{name}"
|
||||
value = re.sub(r"[^a-z0-9]+", ".", value).strip(".")
|
||||
return value[:200] or "book.state"
|
||||
|
||||
|
||||
def build_contract_requirements(
|
||||
scene_plan: dict[str, Any], *, max_required_per_scene: int | None = 3
|
||||
) -> list[dict[str, Any]]:
|
||||
requirements: list[dict[str, Any]] = []
|
||||
for scene_index, scene in enumerate(scene_plan.get("scenes") or [], start=1):
|
||||
number = int(scene.get("number") or scene_index)
|
||||
beats = scene.get("beats") or []
|
||||
requested_required = [
|
||||
index
|
||||
for index, beat in enumerate(beats)
|
||||
if isinstance(beat, dict) and bool(beat.get("required"))
|
||||
]
|
||||
allowed_required = set(requested_required)
|
||||
if (
|
||||
max_required_per_scene is not None
|
||||
and len(requested_required) > max_required_per_scene
|
||||
):
|
||||
if max_required_per_scene < 1:
|
||||
allowed_required = set()
|
||||
elif max_required_per_scene == 1:
|
||||
allowed_required = {requested_required[0]}
|
||||
elif max_required_per_scene == 2:
|
||||
allowed_required = {requested_required[0], requested_required[-1]}
|
||||
else:
|
||||
step = (len(requested_required) - 1) / (max_required_per_scene - 1)
|
||||
selected = {
|
||||
requested_required[round(index * step)]
|
||||
for index in range(max_required_per_scene)
|
||||
}
|
||||
allowed_required = selected
|
||||
for beat_index, beat in enumerate(beats, start=1):
|
||||
if isinstance(beat, dict):
|
||||
text = str(beat.get("text") or "")
|
||||
required = (beat_index - 1) in allowed_required
|
||||
else:
|
||||
text = str(beat)
|
||||
required = False
|
||||
requirements.append(
|
||||
{
|
||||
"id": f"S{number:02d}-B{beat_index:02d}",
|
||||
"type": "BEAT",
|
||||
"text": text,
|
||||
"severity": "HIGH" if required else "MEDIUM",
|
||||
"blocking": required,
|
||||
"required": required,
|
||||
}
|
||||
)
|
||||
ending = str(scene.get("ending_state") or "").strip()
|
||||
if ending:
|
||||
requirements.append(
|
||||
{
|
||||
"id": f"S{number:02d}-END",
|
||||
"type": "ENDING_STATE",
|
||||
"text": ending,
|
||||
"severity": "MEDIUM",
|
||||
"blocking": False,
|
||||
"required": False,
|
||||
}
|
||||
)
|
||||
groups = [
|
||||
("VALUE", "EXACT_VALUE", "exact_values", "CRITICAL", True),
|
||||
("FORBID", "FORBIDDEN_EVENT", "forbidden_events", "CRITICAL", True),
|
||||
("BOUNDARY", "SCENE_BOUNDARY", "boundary_constraints", "HIGH", True),
|
||||
("SHORTCUT", "FORBIDDEN_SHORTCUT", "forbidden_shortcuts", "MEDIUM", False),
|
||||
("CONSTRAINT", "CHAPTER_CONSTRAINT", "chapter_constraints", "MEDIUM", False),
|
||||
]
|
||||
for prefix, kind, field, severity, blocking in groups:
|
||||
for index, value in enumerate(scene_plan.get(field) or [], start=1):
|
||||
requirements.append(
|
||||
{
|
||||
"id": f"{prefix}-{index:02d}",
|
||||
"type": kind,
|
||||
"text": str(value),
|
||||
"severity": severity,
|
||||
"blocking": blocking,
|
||||
"required": blocking,
|
||||
}
|
||||
)
|
||||
for requirement_id, field in [("TIME-START", "day_start"), ("TIME-END", "day_end")]:
|
||||
value = str(scene_plan.get(field) or "").strip()
|
||||
if value:
|
||||
requirements.append(
|
||||
{
|
||||
"id": requirement_id,
|
||||
"type": "CHRONOLOGY",
|
||||
"text": value,
|
||||
"severity": "CRITICAL",
|
||||
"blocking": True,
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
final_image = str(scene_plan.get("final_image") or "").strip()
|
||||
if final_image:
|
||||
requirements.append(
|
||||
{
|
||||
"id": "FINAL-IMAGE",
|
||||
"type": "FINAL_IMAGE",
|
||||
"text": final_image,
|
||||
"severity": "MEDIUM",
|
||||
"blocking": False,
|
||||
"required": False,
|
||||
}
|
||||
)
|
||||
return requirements
|
||||
|
||||
|
||||
def requirement_is_blocking(requirement: dict[str, Any]) -> bool:
|
||||
return bool(requirement.get("blocking"))
|
||||
|
||||
|
||||
def evidence_is_present(prose: str, quote: str) -> bool:
|
||||
quote = quote.strip()
|
||||
if not quote:
|
||||
return False
|
||||
if quote in prose:
|
||||
return True
|
||||
normalized_quote = re.sub(r"[\W_]+", " ", quote.casefold()).strip()
|
||||
normalized_prose = re.sub(r"[\W_]+", " ", prose.casefold()).strip()
|
||||
if len(normalized_quote.split()) >= 4 and normalized_quote in normalized_prose:
|
||||
return True
|
||||
fragments = [
|
||||
re.sub(r"[\W_]+", " ", fragment.casefold()).strip()
|
||||
for fragment in re.split(r"[.!?]+", quote)
|
||||
]
|
||||
fragments = [fragment for fragment in fragments if len(fragment.split()) >= 2]
|
||||
if len(fragments) < 2:
|
||||
return False
|
||||
first = normalized_prose.find(fragments[0])
|
||||
if first < 0:
|
||||
return False
|
||||
cursor = first + len(fragments[0])
|
||||
for fragment in fragments[1:]:
|
||||
position = normalized_prose.find(fragment, cursor)
|
||||
if position < 0:
|
||||
return False
|
||||
cursor = position + len(fragment)
|
||||
return cursor - first <= len(normalized_quote) * 2 + 120
|
||||
|
||||
|
||||
def apply_state_changes(
|
||||
prior_state: dict[str, Any],
|
||||
changes: list[dict[str, Any]],
|
||||
*,
|
||||
through_chapter: int,
|
||||
chapter_state: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if prior_state.get("schema_version") == 2 and isinstance(prior_state.get("entities"), dict):
|
||||
state = copy.deepcopy(prior_state)
|
||||
else:
|
||||
state = {
|
||||
"schema_version": 2,
|
||||
"through_chapter": max(0, through_chapter - 1),
|
||||
"entities": {},
|
||||
"book": {"legacy_state": copy.deepcopy(prior_state)} if prior_state else {},
|
||||
}
|
||||
entities = state.setdefault("entities", {})
|
||||
missing = object()
|
||||
for change in sorted(changes, key=lambda item: int(item.get("sequence") or 0)):
|
||||
key = str(change.get("entity_key") or "book.state")
|
||||
entity = entities.setdefault(
|
||||
key,
|
||||
{
|
||||
"kind": str(change.get("entity_kind") or "book"),
|
||||
"name": str(change.get("canonical_name") or key),
|
||||
"facts": {},
|
||||
},
|
||||
)
|
||||
facts = entity.setdefault("facts", {})
|
||||
path = [part for part in str(change.get("predicate") or "state").split(".") if part]
|
||||
target = facts
|
||||
for part in path[:-1]:
|
||||
target = target.setdefault(part, {})
|
||||
leaf = path[-1] if path else "state"
|
||||
current = target.get(leaf, missing)
|
||||
previous = change.get("previous_value")
|
||||
if current is not missing and previous is not None and current != previous:
|
||||
raise ValueError(
|
||||
f"state change {change.get('sequence')} expected {key}.{'.'.join(path)} "
|
||||
f"to be {previous!r}, found {current!r}"
|
||||
)
|
||||
operation = str(change.get("operation") or "SET").upper()
|
||||
new_value = copy.deepcopy(change.get("new_value"))
|
||||
related = str(change.get("related_entity_key") or "").strip()
|
||||
if operation == "ADD":
|
||||
values = [] if current is missing or current is None else list(current)
|
||||
additions = new_value if isinstance(new_value, list) else [new_value]
|
||||
for value in additions:
|
||||
if value not in values:
|
||||
values.append(value)
|
||||
target[leaf] = values
|
||||
elif operation == "REMOVE":
|
||||
values = [] if current is missing or current is None else list(current)
|
||||
removals = new_value if isinstance(new_value, list) else [new_value]
|
||||
target[leaf] = [value for value in values if value not in removals]
|
||||
elif operation == "OPEN":
|
||||
target[leaf] = new_value if new_value is not None else "OPEN"
|
||||
elif operation == "CLOSE":
|
||||
target[leaf] = new_value if new_value is not None else "CLOSED"
|
||||
elif operation == "TRANSFER":
|
||||
if not related:
|
||||
raise ValueError(
|
||||
f"state change {change.get('sequence')} cannot TRANSFER without "
|
||||
"related_entity_key"
|
||||
)
|
||||
if new_value not in (None, "", related):
|
||||
raise ValueError(
|
||||
f"state change {change.get('sequence')} TRANSFER destination "
|
||||
f"{new_value!r} does not match related entity {related!r}"
|
||||
)
|
||||
target[leaf] = related
|
||||
else:
|
||||
target[leaf] = new_value
|
||||
if related:
|
||||
entity.setdefault("relations", {})[str(change.get("predicate") or "related")] = related
|
||||
state["through_chapter"] = through_chapter
|
||||
state["chapter_state"] = copy.deepcopy(chapter_state)
|
||||
return state
|
||||
|
||||
|
||||
def render_state_markdown(document: dict[str, Any]) -> str:
|
||||
coverage = document.get("coverage") or {}
|
||||
changes = document.get("proposed_delta") or []
|
||||
lines = [
|
||||
f"# Chapter {document.get('through_chapter', '')} State",
|
||||
"",
|
||||
f"Verdict: **{document.get('verdict') or 'PENDING'}**",
|
||||
"",
|
||||
"## Requirement Coverage",
|
||||
"",
|
||||
]
|
||||
for check in coverage.get("requirements") or []:
|
||||
lines.append(
|
||||
f"- `{check.get('requirement_id', '')}` **{check.get('status', '')}**: "
|
||||
f"{check.get('requirement_text', '')}"
|
||||
)
|
||||
if check.get("evidence_quote"):
|
||||
lines.append(f" Evidence: {check['evidence_quote']}")
|
||||
lines.extend(["", "## State Changes", ""])
|
||||
for change in changes:
|
||||
lines.append(
|
||||
f"- `{change.get('entity_key', 'book.state')}.{change.get('predicate', 'state')}` "
|
||||
f"{change.get('operation', 'SET')}: {change.get('previous_value')!r} -> "
|
||||
f"{change.get('new_value')!r}"
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Observed State",
|
||||
"",
|
||||
"```json",
|
||||
json.dumps(document.get("observed_state") or {}, ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
141
control_plane/authoring/streaming.py
Normal file
141
control_plane/authoring/streaming.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from model_router.router import ModelRequestContract, ModelRouter
|
||||
|
||||
|
||||
def word_count(text: str) -> int:
|
||||
return len(re.findall(r"\b\S+\b", text))
|
||||
|
||||
|
||||
def atomic_write_text(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(temporary)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def merge_with_overlap(existing: str, continuation: str, max_overlap: int = 4000) -> str:
|
||||
existing = existing.rstrip()
|
||||
continuation = continuation.lstrip()
|
||||
if not existing:
|
||||
return continuation
|
||||
limit = min(len(existing), len(continuation), max_overlap)
|
||||
for size in range(limit, 39, -1):
|
||||
if existing[-size:] == continuation[:size]:
|
||||
return existing + continuation[size:]
|
||||
return existing + ("" if existing.endswith((" ", "\n")) else " ") + continuation
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DraftResult:
|
||||
text: str
|
||||
attempts: int
|
||||
resumed: bool
|
||||
word_count: int
|
||||
|
||||
|
||||
class ResumableDraftWriter:
|
||||
def __init__(self, router: ModelRouter) -> None:
|
||||
self.router = router
|
||||
|
||||
def generate(
|
||||
self,
|
||||
*,
|
||||
request: ModelRequestContract,
|
||||
partial_path: Path,
|
||||
minimum_words: int = 3000,
|
||||
maximum_words: int = 15000,
|
||||
completion_marker: str = "[[END_OF_CHAPTER]]",
|
||||
max_attempts: int = 4,
|
||||
) -> DraftResult:
|
||||
attempt_path = partial_path.with_name(partial_path.name + ".attempt")
|
||||
partial = self._reconcile(partial_path, attempt_path)
|
||||
if completion_marker in partial:
|
||||
completed = partial.partition(completion_marker)[0].rstrip()
|
||||
if word_count(completed) < minimum_words:
|
||||
partial = ""
|
||||
atomic_write_text(partial_path, partial)
|
||||
resumed = bool(partial)
|
||||
last_error = "generation did not complete"
|
||||
retry_feedback = ""
|
||||
short_completions = 0
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
prompt = request.prompt + retry_feedback
|
||||
if partial:
|
||||
prompt += (
|
||||
"\n\nContinue from the exact cutoff below. Return continuation prose only; do not restart "
|
||||
"or summarize. Finish with the required completion marker.\n<saved-prose>\n"
|
||||
+ partial
|
||||
+ "\n</saved-prose>"
|
||||
)
|
||||
continued_request = ModelRequestContract(
|
||||
purpose=request.purpose,
|
||||
prompt=prompt,
|
||||
model_hint=request.model_hint,
|
||||
token_budget=request.token_budget,
|
||||
project=request.project,
|
||||
agent_version=request.agent_version,
|
||||
)
|
||||
attempt_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with attempt_path.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
for chunk in self.router.stream(continued_request):
|
||||
handle.write(chunk.content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
partial = self._reconcile(partial_path, attempt_path)
|
||||
words = word_count(partial)
|
||||
if words > maximum_words:
|
||||
raise RuntimeError(
|
||||
f"generated prose exceeds maximum: {words} > {maximum_words} words"
|
||||
)
|
||||
if completion_marker not in partial:
|
||||
last_error = "provider completed without the chapter marker"
|
||||
continue
|
||||
body = partial.partition(completion_marker)[0].rstrip()
|
||||
if word_count(body) < minimum_words:
|
||||
last_error = f"completed chapter is shorter than {minimum_words} words"
|
||||
short_completions += 1
|
||||
partial = ""
|
||||
atomic_write_text(partial_path, partial)
|
||||
if short_completions >= 2:
|
||||
break
|
||||
retry_feedback = (
|
||||
f"\n\nThe prior complete draft was too short. Write at least {minimum_words} words "
|
||||
"and fully dramatize every planned scene without padding or repeating the chapter."
|
||||
)
|
||||
continue
|
||||
atomic_write_text(partial_path, body)
|
||||
return DraftResult(body, attempt, resumed, word_count(body))
|
||||
except Exception as exc:
|
||||
last_error = str(exc)
|
||||
partial = self._reconcile(partial_path, attempt_path)
|
||||
if word_count(partial) > maximum_words:
|
||||
raise
|
||||
raise RuntimeError(
|
||||
f"chapter remains partial after {max_attempts} attempts at {partial_path}: {last_error}"
|
||||
)
|
||||
|
||||
def _reconcile(self, partial_path: Path, attempt_path: Path) -> str:
|
||||
partial = partial_path.read_text(encoding="utf-8") if partial_path.exists() else ""
|
||||
if attempt_path.exists():
|
||||
partial = merge_with_overlap(partial, attempt_path.read_text(encoding="utf-8"))
|
||||
atomic_write_text(partial_path, partial)
|
||||
attempt_path.unlink()
|
||||
return partial.strip()
|
||||
483
control_plane/authoring/views.py
Normal file
483
control_plane/authoring/views.py
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import HttpRequest, JsonResponse
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from control_plane.authoring.book_state import BookStateService
|
||||
from control_plane.authoring.models import (
|
||||
BookRun,
|
||||
BookStateVersion,
|
||||
SceneIdeation,
|
||||
StandaloneScene,
|
||||
Work,
|
||||
)
|
||||
from control_plane.authoring.standalone_scenes import (
|
||||
SceneIdeationService,
|
||||
StandaloneSceneService,
|
||||
)
|
||||
from model_router.providers import providers_from_resources
|
||||
from model_router.router import ModelRouter
|
||||
|
||||
|
||||
def scene_service() -> StandaloneSceneService:
|
||||
return StandaloneSceneService(
|
||||
ModelRouter(providers_from_resources(), persist_requests=True)
|
||||
)
|
||||
|
||||
|
||||
def ideation_service() -> SceneIdeationService:
|
||||
return SceneIdeationService(ModelRouter(providers_from_resources(), persist_requests=True))
|
||||
|
||||
|
||||
def book_service() -> BookStateService:
|
||||
return BookStateService(ModelRouter(providers_from_resources(), persist_requests=True))
|
||||
|
||||
|
||||
def _json_body(request: HttpRequest) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(request.body or b"{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("request body must be valid JSON") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("request body must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _json_bool(body: dict[str, Any], field: str, *, default: bool = False) -> bool:
|
||||
value = body.get(field, default)
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"{field} must be boolean")
|
||||
return value
|
||||
|
||||
|
||||
def _scene(scene_id) -> StandaloneScene | None:
|
||||
return (
|
||||
StandaloneScene.objects.select_related(
|
||||
"work__series", "story__project", "source_version", "book_state"
|
||||
)
|
||||
.filter(id=scene_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _idea(idea_id) -> SceneIdeation | None:
|
||||
return SceneIdeation.objects.select_related("work__series").filter(id=idea_id).first()
|
||||
|
||||
|
||||
def _payload(scene: StandaloneScene, *, include_prose: bool = False) -> dict[str, Any]:
|
||||
payload = {
|
||||
"id": str(scene.id),
|
||||
"series": scene.work.series.slug,
|
||||
"work": scene.work.slug,
|
||||
"title": scene.title,
|
||||
"scene_key": scene.scene_key,
|
||||
"revision": scene.revision,
|
||||
"status": scene.status,
|
||||
"brief": scene.brief,
|
||||
"target_words": scene.target_words,
|
||||
"word_count": scene.word_count,
|
||||
"constraints": scene.constraints,
|
||||
"forbidden_events": scene.forbidden_events,
|
||||
"boundary_constraints": scene.boundary_constraints,
|
||||
"context_pack_sha256": scene.context_pack_sha256,
|
||||
"citations": (scene.context_pack or {}).get("citations") or [],
|
||||
"plan": scene.plan,
|
||||
"contract_requirements": scene.contract_requirements,
|
||||
"review": scene.review,
|
||||
"sha256": scene.sha256,
|
||||
"artifact_uri": scene.artifact_uri,
|
||||
"review_artifact_uri": scene.review_artifact_uri,
|
||||
"generation_metadata": scene.generation_metadata,
|
||||
"approved_at": scene.approved_at.isoformat() if scene.approved_at else None,
|
||||
"approved_by": scene.approved_by,
|
||||
"source_version_id": str(scene.source_version_id) if scene.source_version_id else None,
|
||||
"book_state_id": str(scene.book_state_id) if scene.book_state_id else None,
|
||||
"chapter_key": scene.book_chapter_key,
|
||||
"failure_reason": scene.failure_reason,
|
||||
"created_at": scene.created_at.isoformat(),
|
||||
"updated_at": scene.updated_at.isoformat(),
|
||||
}
|
||||
if include_prose:
|
||||
payload["prose"] = scene.prose
|
||||
return payload
|
||||
|
||||
|
||||
def _iso(value: Any) -> str | None:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def _book_state_payload(state: BookStateVersion) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(state.id),
|
||||
"series": state.work.series.slug,
|
||||
"work": state.work.slug,
|
||||
"parent_id": str(state.parent_id) if state.parent_id else None,
|
||||
"version": state.version,
|
||||
"status": state.status,
|
||||
"content": state.content,
|
||||
"sha256": state.sha256,
|
||||
"validation": state.validation,
|
||||
"reviews": state.reviews,
|
||||
"change_summary": state.change_summary,
|
||||
"context_pack": getattr(state, "context_pack", {}),
|
||||
"context_pack_sha256": getattr(state, "context_pack_sha256", ""),
|
||||
"generation_metadata": getattr(state, "generation_metadata", {}),
|
||||
"created_by": state.created_by,
|
||||
"json_artifact_uri": getattr(state, "json_artifact_uri", ""),
|
||||
"markdown_artifact_uri": getattr(state, "markdown_artifact_uri", ""),
|
||||
"approved_at": _iso(getattr(state, "approved_at", None)),
|
||||
"approved_by": getattr(state, "approved_by", ""),
|
||||
"approval_notes": getattr(state, "approval_notes", ""),
|
||||
"approval_forced": state.approval_forced,
|
||||
"rejected_at": _iso(getattr(state, "rejected_at", None)),
|
||||
"rejected_by": getattr(state, "rejected_by", ""),
|
||||
"rejection_notes": getattr(state, "rejection_notes", ""),
|
||||
"created_at": _iso(state.created_at),
|
||||
"updated_at": _iso(state.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def _book_run_payload(run: BookRun) -> dict[str, Any]:
|
||||
state_id = getattr(run, "state_id", None) or getattr(run, "book_state_id", None)
|
||||
return {
|
||||
"id": str(run.id),
|
||||
"book_state_id": str(state_id) if state_id else None,
|
||||
"status": run.status,
|
||||
"policy": getattr(run, "policy", {}),
|
||||
"reviews": run.reviews,
|
||||
"current_chapter_key": getattr(run, "current_chapter_key", ""),
|
||||
"progress": getattr(run, "progress", {}),
|
||||
"failure_reason": getattr(run, "failure_reason", ""),
|
||||
"started_at": _iso(getattr(run, "started_at", None)),
|
||||
"finished_at": _iso(getattr(run, "finished_at", None)),
|
||||
"created_at": _iso(run.created_at),
|
||||
"updated_at": _iso(run.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def _idea_payload(idea: SceneIdeation) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(idea.id),
|
||||
"series": idea.work.series.slug,
|
||||
"work": idea.work.slug,
|
||||
"book_state_id": str(idea.book_state_id) if idea.book_state_id else None,
|
||||
"target_book": idea.target_book,
|
||||
"requested_scene_types": idea.requested_scene_types,
|
||||
"focus": idea.focus,
|
||||
"candidate_count": idea.candidate_count,
|
||||
"authorities": idea.authorities,
|
||||
"pinned_document_keys": idea.pinned_document_keys,
|
||||
"governing_document_keys": (idea.context_pack or {}).get(
|
||||
"governing_document_keys"
|
||||
)
|
||||
or [],
|
||||
"context_pack_sha256": idea.context_pack_sha256,
|
||||
"citations": (idea.context_pack or {}).get("citations") or [],
|
||||
"candidates": idea.candidates,
|
||||
"generation_metadata": idea.generation_metadata,
|
||||
"created_at": idea.created_at.isoformat(),
|
||||
"updated_at": idea.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _book_state(state_id) -> BookStateVersion | None:
|
||||
return (
|
||||
BookStateVersion.objects.select_related("work__series", "parent")
|
||||
.filter(id=state_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def book_states(request: HttpRequest) -> JsonResponse:
|
||||
if request.method == "GET":
|
||||
states = BookStateVersion.objects.select_related("work__series", "parent").order_by(
|
||||
"-updated_at"
|
||||
)[:100]
|
||||
return JsonResponse({"book_states": [_book_state_payload(state) for state in states]})
|
||||
try:
|
||||
body = _json_body(request)
|
||||
required = ["series_slug", "work_slug", "content"]
|
||||
missing = [field for field in required if body.get(field) in (None, "")]
|
||||
if missing:
|
||||
raise ValueError("missing fields: " + ", ".join(missing))
|
||||
if not isinstance(body["content"], dict):
|
||||
raise ValueError("content must be a JSON object")
|
||||
work = Work.objects.filter(
|
||||
series__slug=body["series_slug"], slug=body["work_slug"]
|
||||
).first()
|
||||
if work is None:
|
||||
return JsonResponse({"error": "work not found"}, status=404)
|
||||
state = book_service().create(
|
||||
work=work,
|
||||
content=body["content"],
|
||||
actor=str(body.get("actor") or "api"),
|
||||
context_pack=body.get("context_pack"),
|
||||
generation_metadata=body.get("generation_metadata"),
|
||||
)
|
||||
except (RuntimeError, TypeError, ValueError, ValidationError) as exc:
|
||||
return JsonResponse({"error": str(exc)}, status=400)
|
||||
return JsonResponse(_book_state_payload(state), status=201)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def book_state_detail(request: HttpRequest, state_id) -> JsonResponse:
|
||||
state = _book_state(state_id)
|
||||
if state is None:
|
||||
return JsonResponse({"error": "book state not found"}, status=404)
|
||||
return JsonResponse(_book_state_payload(state))
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
def book_state_action(request: HttpRequest, state_id) -> JsonResponse:
|
||||
state = _book_state(state_id)
|
||||
if state is None:
|
||||
return JsonResponse({"error": "book state not found"}, status=404)
|
||||
try:
|
||||
body = _json_body(request)
|
||||
action = str(body.get("action") or "").strip().replace("-", "_")
|
||||
service = book_service()
|
||||
if action == "validate":
|
||||
service.validate(state, for_approval=_json_bool(body, "for_approval"))
|
||||
elif action == "review":
|
||||
level = str(body.get("level") or "").strip()
|
||||
if not level:
|
||||
raise ValueError("level is required")
|
||||
service.review(state, level=level, model_hint=body.get("model"))
|
||||
elif action == "approve":
|
||||
service.approve(
|
||||
state,
|
||||
actor=str(body.get("actor") or "api"),
|
||||
force=_json_bool(body, "force"),
|
||||
notes=str(body.get("notes") or ""),
|
||||
)
|
||||
elif action == "reject":
|
||||
service.reject(
|
||||
state,
|
||||
actor=str(body.get("actor") or "api"),
|
||||
notes=str(body.get("notes") or ""),
|
||||
)
|
||||
elif action == "revise":
|
||||
content = body.get("content")
|
||||
if not isinstance(content, dict):
|
||||
raise ValueError("content must be a JSON object")
|
||||
revised = service.revise(
|
||||
state,
|
||||
content=content,
|
||||
actor=str(body.get("actor") or "api"),
|
||||
context_pack=body.get("context_pack"),
|
||||
generation_metadata=body.get("generation_metadata"),
|
||||
)
|
||||
return JsonResponse(_book_state_payload(revised), status=201)
|
||||
elif action == "impact":
|
||||
return JsonResponse({"impact": service.impact(state)})
|
||||
elif action == "start_run":
|
||||
run = service.start_run(state, policy=body.get("policy"))
|
||||
return JsonResponse(_book_run_payload(run), status=201)
|
||||
elif action == "sync_run":
|
||||
run_id = str(body.get("run_id") or "").strip()
|
||||
if not run_id:
|
||||
raise ValueError("run_id is required")
|
||||
run = BookRun.objects.filter(id=run_id).first()
|
||||
if run is None:
|
||||
return JsonResponse({"error": "book run not found"}, status=404)
|
||||
run_state_id = getattr(run, "state_id", None) or getattr(
|
||||
run, "book_state_id", None
|
||||
)
|
||||
if run_state_id != state.id:
|
||||
raise ValueError("book run does not belong to this state")
|
||||
service.sync_run(run)
|
||||
run.refresh_from_db()
|
||||
return JsonResponse(_book_run_payload(run))
|
||||
elif action == "review_run":
|
||||
run_id = str(body.get("run_id") or "").strip()
|
||||
if not run_id:
|
||||
raise ValueError("run_id is required")
|
||||
run = BookRun.objects.filter(id=run_id, book_state=state).first()
|
||||
if run is None:
|
||||
return JsonResponse({"error": "book run not found"}, status=404)
|
||||
service.review_run(run, model_hint=body.get("model"))
|
||||
run.refresh_from_db()
|
||||
return JsonResponse(_book_run_payload(run))
|
||||
else:
|
||||
raise ValueError("unsupported action")
|
||||
except (RuntimeError, TypeError, ValueError, ValidationError) as exc:
|
||||
return JsonResponse({"error": str(exc)}, status=400)
|
||||
state.refresh_from_db()
|
||||
return JsonResponse(_book_state_payload(state))
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def scene_ideas(request: HttpRequest) -> JsonResponse:
|
||||
if request.method == "GET":
|
||||
ideas = SceneIdeation.objects.select_related("work__series").order_by("-created_at")[:100]
|
||||
return JsonResponse({"ideas": [_idea_payload(idea) for idea in ideas]})
|
||||
try:
|
||||
body = _json_body(request)
|
||||
required = ["series_slug", "work_slug", "target_book"]
|
||||
missing = [field for field in required if not str(body.get(field) or "").strip()]
|
||||
if missing:
|
||||
raise ValueError("missing fields: " + ", ".join(missing))
|
||||
work = Work.objects.filter(
|
||||
series__slug=body["series_slug"], slug=body["work_slug"]
|
||||
).first()
|
||||
if work is None:
|
||||
return JsonResponse({"error": "work not found"}, status=404)
|
||||
book_state = None
|
||||
if body.get("book_state_id"):
|
||||
book_state = BookStateVersion.objects.filter(id=body["book_state_id"]).first()
|
||||
if book_state is None:
|
||||
return JsonResponse({"error": "book state not found"}, status=404)
|
||||
idea = ideation_service().propose(
|
||||
work=work,
|
||||
target_book=str(body["target_book"]),
|
||||
focus=str(body.get("focus") or ""),
|
||||
candidate_count=int(body.get("candidate_count") or 10),
|
||||
scene_types=list(body.get("scene_types") or []) or None,
|
||||
authorities=list(body.get("authorities") or []) or None,
|
||||
pinned_document_keys=list(body.get("pinned_document_keys") or []),
|
||||
governing_document_keys=list(body.get("governing_document_keys") or []),
|
||||
detail_level=str(body.get("detail_level") or "full"),
|
||||
model_hint=body.get("model"),
|
||||
book_state=book_state,
|
||||
)
|
||||
except (RuntimeError, TypeError, ValueError, ValidationError) as exc:
|
||||
return JsonResponse({"error": str(exc)}, status=400)
|
||||
return JsonResponse(_idea_payload(idea), status=201)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def scene_idea_detail(request: HttpRequest, idea_id) -> JsonResponse:
|
||||
idea = _idea(idea_id)
|
||||
if idea is None:
|
||||
return JsonResponse({"error": "scene ideation not found"}, status=404)
|
||||
return JsonResponse(_idea_payload(idea))
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
def scene_idea_action(request: HttpRequest, idea_id) -> JsonResponse:
|
||||
idea = _idea(idea_id)
|
||||
if idea is None:
|
||||
return JsonResponse({"error": "scene ideation not found"}, status=404)
|
||||
try:
|
||||
body = _json_body(request)
|
||||
action = str(body.get("action") or "").strip().replace("-", "_")
|
||||
if action != "select":
|
||||
raise ValueError("unsupported action")
|
||||
candidate_id = str(body.get("candidate_id") or "").strip()
|
||||
if not candidate_id:
|
||||
raise ValueError("candidate_id is required")
|
||||
scene, created = ideation_service().select_candidate(
|
||||
idea,
|
||||
candidate_id=candidate_id,
|
||||
target_words=(
|
||||
int(body["target_words"]) if body.get("target_words") is not None else None
|
||||
),
|
||||
book_chapter_key=body.get("chapter_key"),
|
||||
)
|
||||
idea.refresh_from_db()
|
||||
except (RuntimeError, TypeError, ValueError) as exc:
|
||||
return JsonResponse({"error": str(exc)}, status=400)
|
||||
return JsonResponse(
|
||||
{"idea": _idea_payload(idea), "scene": _payload(scene), "created": created},
|
||||
status=201 if created else 200,
|
||||
)
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def standalone_scenes(request: HttpRequest) -> JsonResponse:
|
||||
if request.method == "GET":
|
||||
scenes = StandaloneScene.objects.select_related("work__series", "book_state").order_by(
|
||||
"-updated_at"
|
||||
)[:100]
|
||||
return JsonResponse({"scenes": [_payload(scene) for scene in scenes]})
|
||||
try:
|
||||
body = _json_body(request)
|
||||
required = ["series_slug", "work_slug", "title", "brief"]
|
||||
missing = [field for field in required if not str(body.get(field) or "").strip()]
|
||||
if missing:
|
||||
raise ValueError("missing fields: " + ", ".join(missing))
|
||||
work = Work.objects.filter(
|
||||
series__slug=body["series_slug"], slug=body["work_slug"]
|
||||
).first()
|
||||
if work is None:
|
||||
return JsonResponse({"error": "work not found"}, status=404)
|
||||
book_state = None
|
||||
if body.get("book_state_id"):
|
||||
book_state = BookStateVersion.objects.filter(id=body["book_state_id"]).first()
|
||||
if book_state is None:
|
||||
return JsonResponse({"error": "book state not found"}, status=404)
|
||||
scene = scene_service().create(
|
||||
work=work,
|
||||
title=str(body["title"]),
|
||||
brief=str(body["brief"]),
|
||||
target_words=int(body.get("target_words") or 1800),
|
||||
constraints=list(body.get("constraints") or []),
|
||||
forbidden_events=list(body.get("forbidden_events") or []),
|
||||
boundary_constraints=list(body.get("boundary_constraints") or []),
|
||||
book_state=book_state,
|
||||
book_chapter_key=body.get("chapter_key"),
|
||||
)
|
||||
except (TypeError, ValueError, ValidationError) as exc:
|
||||
return JsonResponse({"error": str(exc)}, status=400)
|
||||
return JsonResponse(_payload(scene), status=201)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def standalone_scene_detail(request: HttpRequest, scene_id) -> JsonResponse:
|
||||
scene = _scene(scene_id)
|
||||
if scene is None:
|
||||
return JsonResponse({"error": "scene not found"}, status=404)
|
||||
return JsonResponse(_payload(scene, include_prose=request.GET.get("include_prose") == "1"))
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
def standalone_scene_action(request: HttpRequest, scene_id) -> JsonResponse:
|
||||
scene = _scene(scene_id)
|
||||
if scene is None:
|
||||
return JsonResponse({"error": "scene not found"}, status=404)
|
||||
try:
|
||||
body = _json_body(request)
|
||||
action = str(body.get("action") or "").strip().replace("-", "_")
|
||||
service = scene_service()
|
||||
if action == "context":
|
||||
service.prepare_context(
|
||||
scene,
|
||||
authorities=list(body.get("authorities") or []) or None,
|
||||
pinned_document_keys=list(body.get("pinned_document_keys") or []),
|
||||
)
|
||||
elif action == "plan":
|
||||
service.plan(
|
||||
scene,
|
||||
authorities=list(body.get("authorities") or []) or None,
|
||||
pinned_document_keys=list(body.get("pinned_document_keys") or []),
|
||||
model_hint=body.get("model"),
|
||||
)
|
||||
elif action == "approve_plan":
|
||||
service.approve_plan(scene)
|
||||
elif action == "write":
|
||||
service.write(
|
||||
scene,
|
||||
model_hint=body.get("model"),
|
||||
max_attempts=int(body.get("max_attempts") or 2),
|
||||
)
|
||||
elif action == "review":
|
||||
service.review(scene, model_hint=body.get("model"))
|
||||
elif action == "approve":
|
||||
service.approve(
|
||||
scene,
|
||||
actor=str(body.get("actor") or "api"),
|
||||
force=_json_bool(body, "force"),
|
||||
)
|
||||
elif action == "reject":
|
||||
service.reject(scene, actor=str(body.get("actor") or "api"))
|
||||
else:
|
||||
raise ValueError("unsupported action")
|
||||
except (RuntimeError, TypeError, ValueError) as exc:
|
||||
return JsonResponse({"error": str(exc)}, status=400)
|
||||
scene.refresh_from_db()
|
||||
return JsonResponse(_payload(scene))
|
||||
148
control_plane/authoring/workflow.py
Normal file
148
control_plane/authoring/workflow.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from control_plane.authoring.services import DjangoStoryWorkflowServices
|
||||
from control_plane.authoring.state import StoryGraphState
|
||||
|
||||
|
||||
def build_story_workflow(services: DjangoStoryWorkflowServices, checkpointer: object):
|
||||
try:
|
||||
from langgraph.graph import END, StateGraph
|
||||
from langgraph.types import interrupt
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Story authoring requires LangGraph") from exc
|
||||
|
||||
graph = StateGraph(StoryGraphState)
|
||||
|
||||
def build_context(state: StoryGraphState) -> dict[str, Any]:
|
||||
return services.build_context(dict(state))
|
||||
|
||||
def plan_chapter(state: StoryGraphState) -> dict[str, Any]:
|
||||
return services.plan_chapter(dict(state))
|
||||
|
||||
def approve_plan(state: StoryGraphState) -> dict[str, Any]:
|
||||
approval = services.ensure_approval(
|
||||
dict(state),
|
||||
"STORY_PLAN_APPROVAL",
|
||||
{
|
||||
"type": "story_plan",
|
||||
"revision_id": state["revision_id"],
|
||||
"scene_plan": state.get("scene_plan", {}),
|
||||
"allowed_actions": ["approve", "request_revision", "reject"],
|
||||
},
|
||||
)
|
||||
decision = interrupt(approval.payload)
|
||||
services.decide_approval(approval.id, decision)
|
||||
return {
|
||||
"approval_action": str(decision.get("action") or "reject").lower(),
|
||||
"human_notes": str(decision.get("notes") or ""),
|
||||
}
|
||||
|
||||
def draft_chapter(state: StoryGraphState) -> dict[str, Any]:
|
||||
return services.draft_chapter(dict(state))
|
||||
|
||||
def extract_continuity(state: StoryGraphState) -> dict[str, Any]:
|
||||
method = getattr(services, "extract_final_state", services.extract_continuity)
|
||||
return method(dict(state))
|
||||
|
||||
def quality_review(state: StoryGraphState) -> dict[str, Any]:
|
||||
method = getattr(services, "quality_review", None)
|
||||
if method is None:
|
||||
return {"editorial_finding_ids": []}
|
||||
return method(dict(state))
|
||||
|
||||
def judge_state_contract(state: StoryGraphState) -> dict[str, Any]:
|
||||
method = getattr(services, "finalize_combined_audit", None)
|
||||
if method is None:
|
||||
method = getattr(services, "judge_state_contract", None)
|
||||
if method is None:
|
||||
return {"state_judge_status": "pass"}
|
||||
return method(dict(state))
|
||||
|
||||
def decide_patch(state: StoryGraphState) -> dict[str, Any]:
|
||||
return services.decide_patch(dict(state))
|
||||
|
||||
def apply_patch(state: StoryGraphState) -> dict[str, Any]:
|
||||
return services.apply_automatic_patch(dict(state))
|
||||
|
||||
def verify_patch(state: StoryGraphState) -> dict[str, Any]:
|
||||
return services.verify_patch(dict(state))
|
||||
|
||||
def approve_chapter(state: StoryGraphState) -> dict[str, Any]:
|
||||
state_payload_method = getattr(services, "state_approval_payload", None)
|
||||
state_payload = state_payload_method(dict(state)) if state_payload_method else {}
|
||||
approval = services.ensure_approval(
|
||||
dict(state),
|
||||
"STORY_CHAPTER_APPROVAL",
|
||||
{
|
||||
"type": "story_chapter",
|
||||
"revision_id": state["revision_id"],
|
||||
"finding_ids": state.get("editorial_finding_ids", []),
|
||||
**state_payload,
|
||||
"allowed_actions": ["approve", "request_revision", "reject"],
|
||||
},
|
||||
)
|
||||
decision = interrupt(approval.payload)
|
||||
services.decide_approval(approval.id, decision)
|
||||
return {
|
||||
"approval_action": str(decision.get("action") or "reject").lower(),
|
||||
"human_notes": str(decision.get("notes") or ""),
|
||||
}
|
||||
|
||||
def commit_chapter(state: StoryGraphState) -> dict[str, Any]:
|
||||
return services.commit_chapter(dict(state))
|
||||
|
||||
def publish_story(state: StoryGraphState) -> dict[str, Any]:
|
||||
return {"export_uri": services.publish_story(dict(state))}
|
||||
|
||||
graph.add_node("build_context", build_context)
|
||||
graph.add_node("plan_chapter", plan_chapter)
|
||||
graph.add_node("approve_plan", approve_plan)
|
||||
graph.add_node("draft_chapter", draft_chapter)
|
||||
graph.add_node("quality_review", quality_review)
|
||||
graph.add_node("extract_continuity", extract_continuity)
|
||||
graph.add_node("review_draft", judge_state_contract)
|
||||
graph.add_node("decide_patch", decide_patch)
|
||||
graph.add_node("apply_patch", apply_patch)
|
||||
graph.add_node("extract_patched_continuity", extract_continuity)
|
||||
graph.add_node("verify_patch", verify_patch)
|
||||
graph.add_node("approve_chapter", approve_chapter)
|
||||
graph.add_node("commit_chapter", commit_chapter)
|
||||
graph.add_node("publish_story", publish_story)
|
||||
graph.add_node("manual_revision", lambda state: {})
|
||||
graph.add_node("reject", lambda state: {})
|
||||
graph.set_entry_point("build_context")
|
||||
graph.add_edge("build_context", "plan_chapter")
|
||||
graph.add_edge("plan_chapter", "approve_plan")
|
||||
graph.add_conditional_edges(
|
||||
"approve_plan",
|
||||
lambda state: state.get("approval_action", "reject"),
|
||||
{"approve": "draft_chapter", "request_revision": "plan_chapter", "reject": "reject"},
|
||||
)
|
||||
graph.add_edge("draft_chapter", "quality_review")
|
||||
graph.add_edge("quality_review", "decide_patch")
|
||||
graph.add_conditional_edges(
|
||||
"decide_patch",
|
||||
lambda state: state.get("patch_decision", "human_review"),
|
||||
{"patch": "apply_patch", "human_review": "extract_continuity"},
|
||||
)
|
||||
graph.add_conditional_edges(
|
||||
"apply_patch",
|
||||
lambda state: state.get("patch_status", "failed"),
|
||||
{"applied": "extract_patched_continuity", "failed": "extract_continuity"},
|
||||
)
|
||||
graph.add_edge("extract_continuity", "review_draft")
|
||||
graph.add_edge("extract_patched_continuity", "verify_patch")
|
||||
graph.add_edge("verify_patch", "review_draft")
|
||||
graph.add_edge("review_draft", "approve_chapter")
|
||||
graph.add_conditional_edges(
|
||||
"approve_chapter",
|
||||
lambda state: state.get("approval_action", "reject"),
|
||||
{"approve": "commit_chapter", "request_revision": "manual_revision", "reject": "reject"},
|
||||
)
|
||||
graph.add_edge("commit_chapter", "publish_story")
|
||||
graph.add_edge("publish_story", END)
|
||||
graph.add_edge("manual_revision", END)
|
||||
graph.add_edge("reject", END)
|
||||
return graph.compile(checkpointer=checkpointer)
|
||||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
306
docs/story_authoring_workflow.md
Normal file
306
docs/story_authoring_workflow.md
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
# Story Authoring Workflow
|
||||
|
||||
Artifex stores approved fiction state in Django/PostgreSQL and unfinished execution state in LangGraph checkpoints. Draft fragments and EPUB files remain ordinary artifacts under the story's configured artifact root.
|
||||
|
||||
## Workflow
|
||||
|
||||
The `story_authoring` graph runs these stages:
|
||||
|
||||
1. Build a chapter context snapshot from the approved bible, outline, canon, previous chapter, and optional source revision.
|
||||
2. Use Terra to generate a self-contained scene plan with exact values, forbidden events, and an explicit ending, then pause for human approval.
|
||||
3. Draft the complete chapter in one resumable Terra call from the approved consolidated beat plan and full previous-chapter canon.
|
||||
4. Use one Luna call to extract the complete chapter state and immutable state changes while checking every required beat, exact value, constraint, forbidden event, final image, and objective continuity issue. Deterministic checks independently enforce known temporal-state and exact-payout rules.
|
||||
5. Validate every proposed state change against exact prose evidence. Hard canon, chronology, exact values,
|
||||
forbidden events, and explicitly required beats can block; optional scene texture remains nonblocking.
|
||||
6. If the combined audit finds objective, locally repairable MEDIUM-or-higher defects, let Luna apply one exact
|
||||
patch touching at most five percent of the chapter. LOW and subjective findings are never patched
|
||||
automatically.
|
||||
7. Run one targeted Luna verifier over only the supplied findings, hard requirements, and changed
|
||||
passages. It cannot discover new issues or trigger another patch.
|
||||
8. Pause for final chapter approval with state coverage, patch metadata, and residual findings attached.
|
||||
9. Atomically commit validated state changes, materialize a cumulative canon snapshot, promote the
|
||||
revision, and rebuild the EPUB.
|
||||
|
||||
The approval inbox at `/approvals/` resumes the persisted checkpoint. A final request for revision ends
|
||||
the automated run; it never starts another model loop. Start a new explicit run for additional work.
|
||||
|
||||
## Production Setup
|
||||
|
||||
Story workflows require PostgreSQL for durable cross-process resume. SQLite uses an in-memory checkpoint saver and is suitable only for tests that start and resume in one process.
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
export DATABASE_URL=postgresql://artifex:artifex@localhost:5432/artifex
|
||||
python manage.py migrate
|
||||
python manage.py seed_spark_resources
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
Run Artifex on Spark for the simplest deployment. If the Django process runs elsewhere, set `ARTIFEX_OPENCODE_TRANSPORT=ssh` and `ARTIFEX_SPARK_SSH_ALIAS=spark`; if it runs on Spark, leave the transport as `local`. Configure the Terra and Luna OpenCode commands with `ARTIFEX_<MODEL>_OPENCODE_COMMAND` when they differ from `opencode run`.
|
||||
|
||||
## Import And Run
|
||||
|
||||
Import an approved story bible and outline, creating locked canon through Chapter 1:
|
||||
|
||||
```bash
|
||||
python manage.py story_workflow import \
|
||||
--slug the-fortune-below \
|
||||
--title "The Fortune Below" \
|
||||
--series "Labyrinth Hero" \
|
||||
--brief /path/to/labyrinth-hero-brief.md \
|
||||
--plan /path/to/labyrinth-hero-plan.json \
|
||||
--source-dir /path/to/labyrinth-hero \
|
||||
--locked-through 1 \
|
||||
--artifact-root /path/to/artifacts
|
||||
```
|
||||
|
||||
Start Chapter 2 with the old chapter retained as source material:
|
||||
|
||||
```bash
|
||||
python manage.py story_workflow start \
|
||||
--slug the-fortune-below \
|
||||
--chapter 2 \
|
||||
--source /path/to/labyrinth-hero-chapter-02-a-fortune-with-witnesses.md
|
||||
```
|
||||
|
||||
Start a clean run while retaining but disabling older paused runs:
|
||||
|
||||
```bash
|
||||
python manage.py story_workflow start \
|
||||
--slug the-fortune-below \
|
||||
--chapter 2 \
|
||||
--fresh \
|
||||
--supersede-active
|
||||
```
|
||||
|
||||
Approve or return a paused gate from the shell:
|
||||
|
||||
```bash
|
||||
python manage.py story_workflow resume --graph-run 42 --decision approve
|
||||
python manage.py story_workflow resume --graph-run 42 --decision request_revision --notes "Slow the transition into the lodging scene."
|
||||
python manage.py story_workflow resume --graph-run 42 --decision reject
|
||||
```
|
||||
|
||||
Every model request, context snapshot, review finding, approval, revision, canon snapshot, and publication artifact remains queryable in Django.
|
||||
|
||||
## State Ledger
|
||||
|
||||
`StoryEntity` gives every tracked person, item, location, account, relationship, organization,
|
||||
and plot thread a stable key. `StateChange` stores immutable, evidence-backed transitions for those
|
||||
entities. `ChapterStateDocument` stores the readable chapter state and contract coverage, while
|
||||
`CanonSnapshot` materializes the complete approved book state for fast generation context.
|
||||
|
||||
Only validated changes from an approved revision are committed. Rejected revisions retain proposed
|
||||
changes for audit but never alter canon. Corrections use superseding changes rather than rewriting history.
|
||||
|
||||
Build or inspect a state document and query committed entity history:
|
||||
|
||||
```bash
|
||||
python manage.py story_state build --revision <revision-uuid>
|
||||
python manage.py story_state show --revision <revision-uuid>
|
||||
python manage.py story_state history --slug the-fortune-below --entity character.corin.vale
|
||||
```
|
||||
|
||||
Each build writes `chapter-NN-rN.state.json` and `chapter-NN-rN.state.md` under the story artifact
|
||||
root. The database remains authoritative; these files are human-readable projections.
|
||||
|
||||
## Book Authoring State
|
||||
|
||||
`BookStateVersion` is the versioned planning contract for a whole work. Its JSON content owns act and chapter
|
||||
structure, chapter dependencies, book constraints and forbidden events, arc and thread progression, and planning
|
||||
continuity facts. Validation checks that structure and dependency graph; separate structural, continuity, and
|
||||
editorial reviews remain attached to the immutable version. Revisions point to their parent and carry an impact
|
||||
report so changed chapters and downstream dependencies can be inspected before approval.
|
||||
|
||||
Create and operate on book state with `fiction_book`:
|
||||
|
||||
```bash
|
||||
python manage.py fiction_book create --series-slug labyrinth-hero \
|
||||
--work-slug the-fortune-below --input /path/to/book-state.json
|
||||
python manage.py fiction_book validate --id <state-uuid>
|
||||
python manage.py fiction_book review --id <state-uuid> --level continuity --model luna
|
||||
python manage.py fiction_book impact --id <state-uuid>
|
||||
python manage.py fiction_book approve --id <state-uuid> --actor daniel
|
||||
python manage.py fiction_book start-run --id <state-uuid>
|
||||
python manage.py fiction_book sync-run --run-id <run-uuid>
|
||||
python manage.py fiction_book review-run --run-id <run-uuid> --model luna
|
||||
```
|
||||
|
||||
The equivalent API is `GET|POST /api/authoring/book-states/`, `GET
|
||||
/api/authoring/book-states/<uuid>/`, and `POST /api/authoring/book-states/<uuid>/actions/`. Actions are
|
||||
`validate`, `review`, `approve`, `reject`, `revise`, `impact`, `start_run`, `sync_run`, and `review_run`. A `BookRun` stores a
|
||||
durable chapter cursor and completed work, allowing an approved plan to resume without inferring progress from
|
||||
generated prose. Once every bound scene is approved, the run stops in review until a current whole-run continuity
|
||||
review passes. Bind a standalone scene to one approved chapter with `--book-state` and `--chapter-key`, or the
|
||||
scene-create JSON fields `book_state_id` and `chapter_key`.
|
||||
Completed runs retain the exact reviewed scene manifest. Start a new run before creating later revisions against
|
||||
the same approved book state.
|
||||
|
||||
Book state is planning authority, not publication authority. Approval does not promote any fact into canon,
|
||||
generate or approve chapter prose, or auto-approve a standalone scene; those remain explicit authoring and human
|
||||
approval steps.
|
||||
|
||||
## Source Registry
|
||||
|
||||
Register source files before extracting continuity claims. Registration stores immutable file versions,
|
||||
SHA-256 hashes, explicit authority labels, and line/character-addressable passages. It does not infer
|
||||
authority from directory or filename conventions and does not promote extracted facts into canon.
|
||||
|
||||
Use `--dry-run` first when inspecting an existing corpus:
|
||||
|
||||
```bash
|
||||
python manage.py story_sources register \
|
||||
--root /path/to/manuscripts/the-fortune-below \
|
||||
--series-slug labyrinth-hero \
|
||||
--series-title "Labyrinth Hero" \
|
||||
--work-slug the-fortune-below \
|
||||
--work-title "The Fortune Below" \
|
||||
--authority provisional \
|
||||
--document-type other \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Authority values are `canon`, `provisional`, `planning`, `superseded`, `rejected`, and
|
||||
`noncanon_experiment`. Register mixed-authority corpora in separate, explicitly classified batches.
|
||||
Changing an authority label creates a superseding source version even when the file content is unchanged.
|
||||
|
||||
Use repeated `--include-glob` values to register explicit authority batches while retaining paths relative to
|
||||
the full corpus root. Shared canon belongs to a `series_reference` work so every book in that series can retrieve
|
||||
it without seeing sibling-book drafts.
|
||||
|
||||
## Standalone Scenes
|
||||
|
||||
Standalone scenes have their own persistent plan, cited context pack, frozen requirements, resumable prose,
|
||||
review, hashes, artifacts, and approval state. They do not require fake chapters and do not alter series canon.
|
||||
Final approval registers the generated scene as `provisional`; canon promotion remains a separate decision.
|
||||
|
||||
Ask Sol to propose new evidence-backed scenes before creating any scene record:
|
||||
|
||||
```bash
|
||||
python manage.py fiction_ideas propose \
|
||||
--series-slug labyrinth-hero \
|
||||
--work-slug the-fortune-below \
|
||||
--target-book "Book Two" \
|
||||
--focus "Unspent Sabine and Corin choices that preserve current physical continuity" \
|
||||
--candidate-count 10 \
|
||||
--include-authority canon \
|
||||
--include-authority planning \
|
||||
--model sol
|
||||
```
|
||||
|
||||
Each persisted proposal includes its exact context hash, authority-labelled citations, candidate briefs,
|
||||
the opportunity each scene spends, future opportunities its ending creates, continuity questions, risks, and
|
||||
prompt/response hashes. The default ten-candidate menu covers ten generic dramatic functions: quiet connection,
|
||||
major turn, physical escalation, conflict pressure, boundary choice, revelation/discovery,
|
||||
aftermath/consequence, competence/task, external plot action, and ensemble/social. These types classify dramatic
|
||||
function without assuming a particular book, cast, genre, or relationship. Proposal is read-only and creates no scene. Inspect a
|
||||
proposal and explicitly select one candidate with:
|
||||
|
||||
```bash
|
||||
python manage.py fiction_ideas show --id <idea-uuid>
|
||||
python manage.py fiction_ideas export --id <idea-uuid> --output /path/to/scene-ideas.md
|
||||
python manage.py fiction_ideas select --id <idea-uuid> --candidate-id idea-02
|
||||
```
|
||||
|
||||
Pass `--book-state <approved-state-uuid>` to freeze the approved book contract into ideation context. Selecting
|
||||
from a bound proposal also requires `--chapter-key`, and the resulting scene is bound to that exact state/chapter.
|
||||
|
||||
Export renders the persisted proposal, frozen citation index, selection state, and generation hashes as
|
||||
deterministic Markdown. It does not call a model or mutate the proposal. Selection is idempotent and creates one
|
||||
`planning` scene for the existing plan/write/review workflow. Repeating
|
||||
the same selection returns the same scene. Ideation consults `canon` and `planning` by default; pass explicit
|
||||
`--include-authority` values to narrow or deliberately expand that evidence set.
|
||||
|
||||
`--target-book` is mandatory for new proposals. The selected book is a hard premise boundary: later-book canon
|
||||
may constrain consequences, but events, roles, locations, relationships, and abilities first established later
|
||||
cannot stage the proposed scene. Every candidate exports its prerequisites and explicit book-scope justification.
|
||||
Each candidate also explains why its primary scene type fits. A `physical_escalation` must cross or sharply
|
||||
approach a meaningful established physical threshold; routine care, medical assistance, incidental contact,
|
||||
bathing, or help dressing and undressing is not sufficient.
|
||||
|
||||
Use repeated `--scene-type` values to request alternatives within one or more dramatic functions. When one type
|
||||
is requested, multiple candidates may use that type; when several are requested, the proposal covers every
|
||||
requested type before repeating one:
|
||||
|
||||
```bash
|
||||
python manage.py fiction_ideas propose \
|
||||
--series-slug labyrinth-hero \
|
||||
--work-slug the-fortune-below \
|
||||
--target-book "Book Two" \
|
||||
--candidate-count 3 \
|
||||
--scene-type physical_escalation \
|
||||
--model sol
|
||||
```
|
||||
|
||||
Use `--governing-document` for an authoritative guide that the model must receive in full. Governing documents
|
||||
are frozen verbatim into the context pack before supplementary RAG passages are selected. `--pin-document`
|
||||
continues to prioritize relevant excerpts and should not be used when complete-document context is required.
|
||||
|
||||
Pass `--compact` to generate lightweight ideation candidates containing only the title, brief, citations,
|
||||
opportunity spent, and future opportunities. The same flag on `fiction_ideas export` produces a review Markdown
|
||||
without planning constraints, risks, citation index, or other full-detail sections.
|
||||
|
||||
Create and plan a scene:
|
||||
|
||||
```bash
|
||||
python manage.py fiction_scene run \
|
||||
--series-slug labyrinth-hero \
|
||||
--work-slug the-fortune-below \
|
||||
--title "Fourteen Seconds" \
|
||||
--brief /path/to/scene-brief.md \
|
||||
--target-words 1800 \
|
||||
--constraint "Sabine owns the timing." \
|
||||
--boundary "Stop when Sabine leaves the office."
|
||||
```
|
||||
|
||||
After `fiction_scene create`, preview the exact source passages before planning with:
|
||||
|
||||
```bash
|
||||
python manage.py fiction_scene context --id <scene-uuid> \
|
||||
--include-authority canon \
|
||||
--pin-document path/relative/to/the/registered/corpus.md
|
||||
```
|
||||
|
||||
The command stops at plan review. Inspect the returned plan and continue explicitly:
|
||||
|
||||
```bash
|
||||
python manage.py fiction_scene approve-plan --id <scene-uuid>
|
||||
python manage.py fiction_scene write --id <scene-uuid>
|
||||
python manage.py fiction_scene review --id <scene-uuid>
|
||||
python manage.py fiction_scene approve --id <scene-uuid> --actor daniel
|
||||
```
|
||||
|
||||
For an intentional plan-to-draft run that still stops before final prose approval:
|
||||
|
||||
```bash
|
||||
python manage.py fiction_scene run ... --auto-approve-plan
|
||||
```
|
||||
|
||||
Context retrieval uses only the latest versions with `canon` authority by default. Add an authority only when
|
||||
the scene should deliberately consult it, for example `--include-authority planning`. Pin a known document with
|
||||
`--pin-document book-two/planning/fortune-below-sabine-corin-turn-and-household-rules.md`.
|
||||
|
||||
The same workflow is available through JSON endpoints:
|
||||
|
||||
- `GET|POST /api/authoring/ideas/`
|
||||
- `GET /api/authoring/ideas/<uuid>/`
|
||||
- `POST /api/authoring/ideas/<uuid>/actions/` with action `select`
|
||||
- `GET|POST /api/authoring/scenes/`
|
||||
- `GET /api/authoring/scenes/<uuid>/`
|
||||
- `POST /api/authoring/scenes/<uuid>/actions/`
|
||||
|
||||
Supported actions are `context`, `plan`, `approve_plan`, `write`, `review`, `approve`, and `reject`. Scene detail
|
||||
omits prose by default; request `?include_prose=1` only when the caller needs the full draft.
|
||||
|
||||
## Model Policy
|
||||
|
||||
The default book policy uses only Terra, Luna, and Qwen:
|
||||
|
||||
- Terra: chapter planning and complete chapter prose.
|
||||
- Luna: combined continuity/state extraction and objective audit, one bounded exact patch, and targeted verification.
|
||||
- Qwen3.8 no-thinking: reserved for local structured fallback work.
|
||||
- Human: plan approval and final chapter approval.
|
||||
|
||||
The combined Luna and deterministic audit must reject chronology drift, incorrect economics, canon conflicts,
|
||||
omitted required beats, and prose that continues beyond the approved final image. Automation performs at most
|
||||
one bounded patch; unresolved blockers return to the human gate.
|
||||
|
|
@ -4,11 +4,20 @@ from django.utils import timezone
|
|||
|
||||
from graph.agent_control import agent_investigation_graph_v1
|
||||
from graph.crypto_venture_cohort import crypto_venture_cohort_graph_v1
|
||||
from graph.lifecycle import project_evolution_graph_v1, project_exploration_graph_v1, project_extension_graph_v1
|
||||
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus
|
||||
from graph.lifecycle import (
|
||||
project_evolution_graph_v1,
|
||||
project_exploration_graph_v1,
|
||||
project_extension_graph_v1,
|
||||
)
|
||||
from graph.models import (
|
||||
ExecutionGraphDefinition,
|
||||
ExecutionGraphVersion,
|
||||
ExecutionGraphVersionStatus,
|
||||
)
|
||||
from graph.roadmap import project_roadmap_review_graph_v1
|
||||
from graph.scenario_lab import scenario_lab_graph_v1
|
||||
from graph.steward import steward_run_graph_v1
|
||||
from graph.story_authoring import story_authoring_graph_v1, story_authoring_graph_v2
|
||||
from graph.task_execution import task_execution_graph_v1
|
||||
from graph.venture_cohort import venture_discovery_cohort_graph_v1
|
||||
from graph.venture_discovery import venture_discovery_graph_v1
|
||||
|
|
@ -113,3 +122,11 @@ def champion_venture_discovery_cohort_graph_v1() -> ExecutionGraphVersion:
|
|||
|
||||
def champion_crypto_venture_cohort_graph_v1() -> ExecutionGraphVersion:
|
||||
return _champion_graph(crypto_venture_cohort_graph_v1())
|
||||
|
||||
|
||||
def champion_story_authoring_graph_v1() -> ExecutionGraphVersion:
|
||||
return _champion_graph(story_authoring_graph_v1())
|
||||
|
||||
|
||||
def champion_story_authoring_graph_v2() -> ExecutionGraphVersion:
|
||||
return _champion_graph(story_authoring_graph_v2())
|
||||
|
|
|
|||
111
graph/story_authoring.py
Normal file
111
graph/story_authoring.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from graph.spec import ExecutionGraphSpec, GraphEdgeSpec, GraphNodeSpec
|
||||
|
||||
|
||||
def story_authoring_graph_v1() -> ExecutionGraphSpec:
|
||||
node_ids = [
|
||||
"build_context",
|
||||
"plan_chapter",
|
||||
"approve_plan",
|
||||
"draft_chapter",
|
||||
"extract_continuity",
|
||||
"review_chapter",
|
||||
"judge_chapter",
|
||||
"revise_chapter",
|
||||
"approve_chapter",
|
||||
"commit_chapter",
|
||||
"publish_story",
|
||||
"complete",
|
||||
"reject",
|
||||
]
|
||||
nodes = {
|
||||
node_id: GraphNodeSpec(
|
||||
node_id,
|
||||
f"story_{node_id}",
|
||||
{"checkpointed": True, "human_gate": node_id.startswith("approve_")},
|
||||
)
|
||||
for node_id in node_ids
|
||||
}
|
||||
edges = [
|
||||
GraphEdgeSpec("build_context", "plan_chapter", "success"),
|
||||
GraphEdgeSpec("plan_chapter", "approve_plan", "success"),
|
||||
GraphEdgeSpec("approve_plan", "draft_chapter", "approve"),
|
||||
GraphEdgeSpec("approve_plan", "plan_chapter", "request_revision"),
|
||||
GraphEdgeSpec("approve_plan", "reject", "reject"),
|
||||
GraphEdgeSpec("draft_chapter", "extract_continuity", "success"),
|
||||
GraphEdgeSpec("extract_continuity", "review_chapter", "fan_out"),
|
||||
GraphEdgeSpec("review_chapter", "judge_chapter", "success"),
|
||||
GraphEdgeSpec("judge_chapter", "revise_chapter", "revise"),
|
||||
GraphEdgeSpec("judge_chapter", "approve_chapter", "human_review"),
|
||||
GraphEdgeSpec("revise_chapter", "extract_continuity", "success"),
|
||||
GraphEdgeSpec("approve_chapter", "commit_chapter", "approve"),
|
||||
GraphEdgeSpec("approve_chapter", "revise_chapter", "request_revision"),
|
||||
GraphEdgeSpec("approve_chapter", "reject", "reject"),
|
||||
GraphEdgeSpec("commit_chapter", "publish_story", "success"),
|
||||
GraphEdgeSpec("publish_story", "complete", "success"),
|
||||
]
|
||||
spec = ExecutionGraphSpec(
|
||||
name="story_authoring",
|
||||
version=1,
|
||||
graph_type="STORY_AUTHORING",
|
||||
entry="build_context",
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
terminal_nodes=["complete", "reject"],
|
||||
metadata={
|
||||
"description": "Checkpoint-native story plan, draft, parallel review, approval, and publish workflow."
|
||||
},
|
||||
)
|
||||
spec.validate()
|
||||
return spec
|
||||
|
||||
|
||||
def story_authoring_graph_v2() -> ExecutionGraphSpec:
|
||||
node_ids = [
|
||||
"build_context", "plan_chapter", "approve_plan", "draft_chapter",
|
||||
"extract_continuity", "review_draft", "decide_patch", "apply_patch",
|
||||
"extract_patched_continuity", "verify_patch", "approve_chapter",
|
||||
"commit_chapter", "publish_story", "complete", "manual_revision", "reject",
|
||||
]
|
||||
nodes = {
|
||||
node_id: GraphNodeSpec(
|
||||
node_id,
|
||||
f"story_{node_id}",
|
||||
{"checkpointed": True, "human_gate": node_id.startswith("approve_")},
|
||||
)
|
||||
for node_id in node_ids
|
||||
}
|
||||
edges = [
|
||||
GraphEdgeSpec("build_context", "plan_chapter", "success"),
|
||||
GraphEdgeSpec("plan_chapter", "approve_plan", "success"),
|
||||
GraphEdgeSpec("approve_plan", "draft_chapter", "approve"),
|
||||
GraphEdgeSpec("approve_plan", "plan_chapter", "request_revision"),
|
||||
GraphEdgeSpec("approve_plan", "reject", "reject"),
|
||||
GraphEdgeSpec("draft_chapter", "extract_continuity", "success"),
|
||||
GraphEdgeSpec("extract_continuity", "review_draft", "success"),
|
||||
GraphEdgeSpec("review_draft", "decide_patch", "success"),
|
||||
GraphEdgeSpec("decide_patch", "apply_patch", "patch"),
|
||||
GraphEdgeSpec("decide_patch", "approve_chapter", "human_review"),
|
||||
GraphEdgeSpec("apply_patch", "extract_patched_continuity", "applied"),
|
||||
GraphEdgeSpec("apply_patch", "approve_chapter", "failed"),
|
||||
GraphEdgeSpec("extract_patched_continuity", "verify_patch", "success"),
|
||||
GraphEdgeSpec("verify_patch", "approve_chapter", "success"),
|
||||
GraphEdgeSpec("approve_chapter", "commit_chapter", "approve"),
|
||||
GraphEdgeSpec("approve_chapter", "manual_revision", "request_revision"),
|
||||
GraphEdgeSpec("approve_chapter", "reject", "reject"),
|
||||
GraphEdgeSpec("commit_chapter", "publish_story", "success"),
|
||||
GraphEdgeSpec("publish_story", "complete", "success"),
|
||||
]
|
||||
spec = ExecutionGraphSpec(
|
||||
name="story_authoring",
|
||||
version=2,
|
||||
graph_type="STORY_AUTHORING",
|
||||
entry="build_context",
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
terminal_nodes=["complete", "manual_revision", "reject"],
|
||||
metadata={"description": "Bounded one-draft, one-review, one-patch story workflow."},
|
||||
)
|
||||
spec.validate()
|
||||
return spec
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
{% extends "control_plane/base.html" %}
|
||||
{% block content %}<header class="page"><div><h2>Approvals</h2><p class="muted">Durable approval inbox for paused graph decisions.</p></div></header><table><thead><tr><th>Source</th><th>Project</th><th>Reason</th><th>Evidence</th><th>Requested</th><th>Actions</th></tr></thead><tbody>{% for approval in approvals %}<tr><td><a href="{% url 'graph_run_detail' approval.graph_run.id %}">{{ approval.graph_run.execution_graph_version.graph.name }} v{{ approval.graph_run.execution_graph_version.version }}</a></td><td>{{ approval.graph_run.project.name|default:"-" }}</td><td>{{ approval.reason }}</td><td><pre>{{ approval.payload }}</pre></td><td>{{ approval.created_at }}</td><td><form class="actions" method="post" action="{% url 'approval_action' approval.id %}">{% csrf_token %}<button name="action" value="approve">Approve</button><button class="secondary" name="action" value="reject">Reject</button></form></td></tr>{% empty %}<tr><td colspan="6">No pending approvals.</td></tr>{% endfor %}</tbody></table>{% endblock %}
|
||||
{% block content %}<header class="page"><div><h2>Approvals</h2><p class="muted">Durable approval inbox for paused graph decisions.</p></div></header><table><thead><tr><th>Source</th><th>Project</th><th>Reason</th><th>Evidence</th><th>Requested</th><th>Actions</th></tr></thead><tbody>{% for approval in approvals %}<tr><td><a href="{% url 'graph_run_detail' approval.graph_run.id %}">{{ approval.graph_run.execution_graph_version.graph.name }} v{{ approval.graph_run.execution_graph_version.version }}</a></td><td>{{ approval.graph_run.project.name|default:"-" }}</td><td>{{ approval.reason }}</td><td><pre>{{ approval.payload }}</pre></td><td>{{ approval.created_at }}</td><td><form class="actions" method="post" action="{% url 'approval_action' approval.id %}">{% csrf_token %}{% if approval.graph_run.execution_graph_version.graph.name == "story_authoring" %}<textarea name="notes" rows="3" placeholder="Optional revision notes"></textarea>{% endif %}<button name="action" value="approve">Approve</button><button class="secondary" name="action" value="reject">{% if approval.graph_run.execution_graph_version.graph.name == "story_authoring" %}Request changes{% else %}Reject{% endif %}</button></form></td></tr>{% empty %}<tr><td colspan="6">No pending approvals.</td></tr>{% endfor %}</tbody></table>{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
534
tests/test_book_authoring_state.py
Normal file
534
tests/test_book_authoring_state.py
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
from control_plane.authoring.book_state import BookStateService, validate_book_state_content
|
||||
from control_plane.authoring.models import (
|
||||
BookRunStatus,
|
||||
BookStateStatus,
|
||||
SceneDraftStatus,
|
||||
Series,
|
||||
StandaloneScene,
|
||||
StoryProject,
|
||||
Work,
|
||||
)
|
||||
from control_plane.authoring.standalone_scenes import StandaloneSceneService
|
||||
from control_plane.projects.models import Project
|
||||
from model_router.router import ModelResponseContract
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
class FakeReviewRouter:
|
||||
def __init__(self) -> None:
|
||||
self.requests = []
|
||||
|
||||
def complete(self, request):
|
||||
self.requests.append(request)
|
||||
return ModelResponseContract(
|
||||
model="test-reviewer",
|
||||
content=json.dumps({"findings": []}),
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def work(tmp_path: Path) -> Work:
|
||||
series = Series.objects.create(title="Test Series", slug="test-series")
|
||||
work = Work.objects.create(series=series, title="Test Book", slug="test-book")
|
||||
project = Project.objects.create(name="Test Book", project_type="STORY", goal="Write")
|
||||
StoryProject.objects.create(
|
||||
project=project,
|
||||
work=work,
|
||||
title=work.title,
|
||||
series=series.title,
|
||||
slug=work.slug,
|
||||
artifact_root=str(tmp_path),
|
||||
)
|
||||
return work
|
||||
|
||||
|
||||
def book_content(chapter_count: int = 3) -> dict:
|
||||
chapters = []
|
||||
for number in range(1, chapter_count + 1):
|
||||
key = f"chapter-{number}"
|
||||
chapters.append(
|
||||
{
|
||||
"chapter_key": key,
|
||||
"number": number,
|
||||
"order": number,
|
||||
"title": f"Chapter {number}",
|
||||
"purpose": f"Advance turn {number}.",
|
||||
"target_words": 1800,
|
||||
"depends_on": [] if number == 1 else [f"chapter-{number - 1}"],
|
||||
"act_id": "act-1",
|
||||
"arc_ids": ["main-arc"],
|
||||
"thread_ids": ["main-thread"],
|
||||
"beats": [
|
||||
{
|
||||
"beat_id": f"beat-{number}",
|
||||
"text": f"The chapter performs turn {number}.",
|
||||
"required": True,
|
||||
}
|
||||
],
|
||||
"ending_state": f"Turn {number} is complete.",
|
||||
"scene_refs": [],
|
||||
}
|
||||
)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"title": "Test Book",
|
||||
"premise": "A protagonist makes progressively harder choices.",
|
||||
"target_words": chapter_count * 1800,
|
||||
"constraints": ["Preserve agency."],
|
||||
"forbidden_events": [],
|
||||
"acts": [
|
||||
{
|
||||
"act_id": "act-1",
|
||||
"number": 1,
|
||||
"start_chapter": 1,
|
||||
"end_chapter": chapter_count,
|
||||
}
|
||||
],
|
||||
"arcs": [
|
||||
{
|
||||
"arc_id": "main-arc",
|
||||
"start_chapter": "chapter-1",
|
||||
"end_chapter": f"chapter-{chapter_count}",
|
||||
}
|
||||
],
|
||||
"threads": [
|
||||
{
|
||||
"thread_id": "main-thread",
|
||||
"start_chapter": "chapter-1",
|
||||
"end_chapter": f"chapter-{chapter_count}",
|
||||
}
|
||||
],
|
||||
"continuity": [
|
||||
{
|
||||
"fact_id": "fact-choice",
|
||||
"entity_key": "character.protagonist",
|
||||
"category": "character",
|
||||
"description": "The protagonist has made the first choice.",
|
||||
"established_in": "chapter-1",
|
||||
"resolved_in": f"chapter-{chapter_count}",
|
||||
}
|
||||
],
|
||||
"chapters": chapters,
|
||||
"ending": "The central choice has lasting consequences.",
|
||||
"open_questions": [],
|
||||
}
|
||||
|
||||
|
||||
def passing_reviews(state) -> dict:
|
||||
state_hash = state.sha256
|
||||
return {
|
||||
"act:act-1": {"passed": True, "state_sha256": state_hash},
|
||||
"manuscript": {"passed": True, "state_sha256": state_hash},
|
||||
}
|
||||
|
||||
|
||||
def make_review_ready(state) -> None:
|
||||
state.status = BookStateStatus.REVIEW
|
||||
state.reviews = passing_reviews(state)
|
||||
state.save(update_fields=["status", "reviews", "updated_at"])
|
||||
|
||||
|
||||
def test_validation_rejects_forward_dependency_and_sorts_messages(work: Work) -> None:
|
||||
content = book_content()
|
||||
content["chapters"][0]["depends_on"] = ["chapter-2"]
|
||||
|
||||
result = validate_book_state_content(content, work=work)
|
||||
|
||||
assert result["valid"] is False
|
||||
assert result["errors"] == sorted(result["errors"])
|
||||
assert any("must name an earlier chapter" in error for error in result["errors"])
|
||||
|
||||
|
||||
def test_validation_checks_acts_continuity_and_approval_ending(work: Work) -> None:
|
||||
content = book_content()
|
||||
content["acts"][0]["end_chapter"] = 2
|
||||
content["continuity"][0]["resolved_in"] = "chapter-1"
|
||||
content["continuity"][0]["established_in"] = "chapter-2"
|
||||
content["chapters"][1]["ending_state"] = ""
|
||||
|
||||
draft = validate_book_state_content(content, work=work)
|
||||
approval = validate_book_state_content(content, work=work, for_approval=True)
|
||||
|
||||
assert any("ranges must cover every chapter" in error for error in draft["errors"])
|
||||
assert any("cannot precede establishment" in error for error in draft["errors"])
|
||||
assert any("ending_state" in warning for warning in draft["warnings"])
|
||||
assert any("ending_state" in error for error in approval["errors"])
|
||||
|
||||
|
||||
def test_validation_reports_malformed_word_targets_without_raising(work: Work) -> None:
|
||||
content = book_content(1)
|
||||
content["chapters"][0]["target_words"] = "many"
|
||||
|
||||
result = validate_book_state_content(content, work=work)
|
||||
|
||||
assert result["valid"] is False
|
||||
assert any("target_words" in error for error in result["errors"])
|
||||
|
||||
|
||||
def test_service_allocates_versions_and_preserves_parent(work: Work) -> None:
|
||||
service = BookStateService(FakeReviewRouter())
|
||||
first = service.create(work=work, content=book_content())
|
||||
revised_content = deepcopy(first.content)
|
||||
revised_content["open_questions"] = ["Who notices the cost?"]
|
||||
second = service.revise(first, content=revised_content)
|
||||
|
||||
assert (first.version, second.version) == (1, 2)
|
||||
assert second.parent == first
|
||||
assert second.change_summary["changed_sections"] == ["open_questions"]
|
||||
assert second.change_summary["changed_from_chapter"] is None
|
||||
first.content["premise"] = "Mutation is forbidden."
|
||||
with pytest.raises(ValueError, match="immutable"):
|
||||
first.save()
|
||||
|
||||
|
||||
def test_impact_propagates_contract_change_to_dependents_and_later_continuity(
|
||||
work: Work,
|
||||
) -> None:
|
||||
service = BookStateService(FakeReviewRouter())
|
||||
first = service.create(work=work, content=book_content())
|
||||
changed = deepcopy(first.content)
|
||||
changed["chapters"][1]["purpose"] = "Force a materially different second turn."
|
||||
second = service.revise(first, content=changed)
|
||||
|
||||
impact = service.impact(second)
|
||||
|
||||
assert impact["from_chapter"] == 2
|
||||
assert impact["replan_required"] == ["chapter-2", "chapter-3"]
|
||||
assert impact["continuity_review_required"] == ["chapter-3"]
|
||||
|
||||
|
||||
def test_approval_requires_all_reviews_and_rejects_stale_parent(work: Work) -> None:
|
||||
service = BookStateService(FakeReviewRouter())
|
||||
first = service.create(work=work, content=book_content())
|
||||
first.status = BookStateStatus.REVIEW
|
||||
first.reviews = {"manuscript": {"passed": True, "state_sha256": first.sha256}}
|
||||
first.save(update_fields=["status", "reviews", "updated_at"])
|
||||
with pytest.raises(ValueError, match="act:act-1"):
|
||||
service.approve(first)
|
||||
|
||||
first.reviews = passing_reviews(first)
|
||||
first.save(update_fields=["reviews", "updated_at"])
|
||||
service.approve(first, actor="editor")
|
||||
child = service.revise(first, content=deepcopy(first.content))
|
||||
make_review_ready(child)
|
||||
work.current_book_state = None
|
||||
work.save(update_fields=["current_book_state", "updated_at"])
|
||||
|
||||
with pytest.raises(ValueError, match="stale"):
|
||||
service.approve(child)
|
||||
|
||||
|
||||
def test_review_routes_structure_and_continuity_to_distinct_capabilities(work: Work) -> None:
|
||||
router = FakeReviewRouter()
|
||||
service = BookStateService(router)
|
||||
state = service.create(work=work, content=book_content())
|
||||
|
||||
act_review = service.review(state, "act:act-1")
|
||||
manuscript_review = service.review(state, "manuscript")
|
||||
continuity_review = service.review(state, "continuity")
|
||||
|
||||
assert act_review["passed"] and manuscript_review["passed"] and continuity_review["passed"]
|
||||
assert [str(request.purpose) for request in router.requests] == [
|
||||
"STORY_REVIEW",
|
||||
"STORY_REVIEW",
|
||||
"STORY_CONTINUITY",
|
||||
]
|
||||
|
||||
|
||||
def test_run_sync_uses_only_scenes_bound_to_exact_state_and_chapter(work: Work) -> None:
|
||||
service = BookStateService(FakeReviewRouter())
|
||||
state = service.create(work=work, content=book_content(2))
|
||||
make_review_ready(state)
|
||||
state = service.approve(state)
|
||||
run = service.start_run(state)
|
||||
|
||||
run = service.sync_run(run)
|
||||
assert run.status == BookRunStatus.PAUSED
|
||||
assert run.current_chapter_key == "chapter-1"
|
||||
assert run.progress == {"chapter-1": "needs_scene", "chapter-2": "needs_scene"}
|
||||
|
||||
StandaloneScene.objects.create(
|
||||
work=work,
|
||||
book_state=state,
|
||||
book_chapter_key="chapter-1",
|
||||
scene_key="chapter-1-scene",
|
||||
revision=1,
|
||||
title="Chapter 1 Scene",
|
||||
status=SceneDraftStatus.APPROVED,
|
||||
brief="Perform the first turn.",
|
||||
prose="The protagonist made the first choice.",
|
||||
)
|
||||
run = service.sync_run(run)
|
||||
|
||||
assert run.progress["chapter-1"] == "complete"
|
||||
assert run.current_chapter_key == "chapter-2"
|
||||
|
||||
StandaloneScene.objects.create(
|
||||
work=work,
|
||||
book_state=state,
|
||||
book_chapter_key="chapter-2",
|
||||
scene_key="chapter-2-scene",
|
||||
revision=1,
|
||||
title="Chapter 2 Scene",
|
||||
status=SceneDraftStatus.APPROVED,
|
||||
brief="Perform the second turn.",
|
||||
prose="The protagonist made the second choice.",
|
||||
)
|
||||
run = service.sync_run(run)
|
||||
assert run.status == BookRunStatus.REVIEW
|
||||
|
||||
review = service.review_run(run)
|
||||
run.refresh_from_db()
|
||||
|
||||
assert review["passed"] is True
|
||||
assert run.status == BookRunStatus.COMPLETE
|
||||
assert run.reviews["continuity"]["scene_set_sha256"]
|
||||
assert [item["chapter_key"] for item in run.reviews["continuity"]["scene_manifest"]] == [
|
||||
"chapter-1",
|
||||
"chapter-2",
|
||||
]
|
||||
|
||||
scene_service = StandaloneSceneService(FakeReviewRouter())
|
||||
with pytest.raises(ValueError, match="start a new book run"):
|
||||
scene_service.create(
|
||||
work=work,
|
||||
title="Chapter 2 Scene",
|
||||
brief="Revise after completion.",
|
||||
book_state=state,
|
||||
book_chapter_key="chapter-2",
|
||||
)
|
||||
service.start_run(state)
|
||||
revision = scene_service.create(
|
||||
work=work,
|
||||
title="Chapter 2 Scene",
|
||||
brief="Revise in an explicit new run.",
|
||||
book_state=state,
|
||||
book_chapter_key="chapter-2",
|
||||
)
|
||||
assert revision.revision == 2
|
||||
|
||||
|
||||
def test_lifecycle_persists_audit_metadata_and_run_policy(work: Work) -> None:
|
||||
service = BookStateService(FakeReviewRouter())
|
||||
state = service.create(
|
||||
work=work,
|
||||
content=book_content(1),
|
||||
actor="planner",
|
||||
context_pack={"sources": ["outline-v2"]},
|
||||
generation_metadata={"model": "test-planner"},
|
||||
)
|
||||
state.status = BookStateStatus.REVIEW
|
||||
state.save(update_fields=["status", "updated_at"])
|
||||
|
||||
with pytest.raises(ValueError, match="requires notes"):
|
||||
service.approve(state, actor="editor", force=True)
|
||||
state = service.approve(state, actor="editor", force=True, notes="Manual exception.")
|
||||
run = service.start_run(state, policy={"max_parallel_chapters": 1})
|
||||
|
||||
assert state.created_by == "planner"
|
||||
assert state.context_pack == {"sources": ["outline-v2"]}
|
||||
assert state.generation_metadata == {"model": "test-planner"}
|
||||
assert state.approval_forced is True
|
||||
assert state.approval_notes == "Manual exception."
|
||||
assert run.policy == {
|
||||
"max_parallel_chapters": 1,
|
||||
"required_reviews": ["act:act-1", "manuscript"],
|
||||
"run_required_reviews": ["continuity"],
|
||||
}
|
||||
|
||||
|
||||
def test_rejection_persists_actor_notes_and_timestamp(work: Work) -> None:
|
||||
service = BookStateService(FakeReviewRouter())
|
||||
state = service.create(work=work, content=book_content(1))
|
||||
|
||||
state = service.reject(state, actor="editor", notes="Dependency needs revision.")
|
||||
|
||||
assert state.status == BookStateStatus.REJECTED
|
||||
assert state.rejected_at is not None
|
||||
assert state.rejected_by == "editor"
|
||||
assert state.rejection_notes == "Dependency needs revision."
|
||||
|
||||
|
||||
def test_book_state_artifact_paths_remain_pinned_through_approval(
|
||||
work: Work, tmp_path: Path
|
||||
) -> None:
|
||||
service = BookStateService(FakeReviewRouter())
|
||||
state = service.create(work=work, content=book_content(1))
|
||||
original_json_uri = state.json_artifact_uri
|
||||
original_markdown_uri = state.markdown_artifact_uri
|
||||
story = work.story_project
|
||||
story.artifact_root = str(tmp_path / "moved-artifacts")
|
||||
story.save(update_fields=["artifact_root", "updated_at"])
|
||||
make_review_ready(state)
|
||||
|
||||
state = service.approve(state)
|
||||
|
||||
assert state.json_artifact_uri == original_json_uri
|
||||
assert state.markdown_artifact_uri == original_markdown_uri
|
||||
|
||||
|
||||
def test_book_api_approval_and_run_actions_match_service_contract(
|
||||
work: Work, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
service = BookStateService(FakeReviewRouter())
|
||||
monkeypatch.setattr("control_plane.authoring.views.book_service", lambda: service)
|
||||
client = Client()
|
||||
response = client.post(
|
||||
reverse("book_states"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"series_slug": work.series.slug,
|
||||
"work_slug": work.slug,
|
||||
"content": book_content(1),
|
||||
"actor": "api-planner",
|
||||
"context_pack": {"sources": ["outline-v2"]},
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
state_id = response.json()["id"]
|
||||
state = work.book_state_versions.get(id=state_id)
|
||||
state.status = BookStateStatus.REVIEW
|
||||
state.save(update_fields=["status", "updated_at"])
|
||||
|
||||
invalid_boolean = client.post(
|
||||
reverse("book_state_action", args=[state.id]),
|
||||
data=json.dumps(
|
||||
{
|
||||
"action": "approve",
|
||||
"force": "false",
|
||||
"notes": "This must not be treated as true.",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
approval = client.post(
|
||||
reverse("book_state_action", args=[state.id]),
|
||||
data=json.dumps(
|
||||
{
|
||||
"action": "approve",
|
||||
"actor": "api-editor",
|
||||
"force": True,
|
||||
"notes": "Reviewed outside Artifex.",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
run = client.post(
|
||||
reverse("book_state_action", args=[state.id]),
|
||||
data=json.dumps({"action": "start_run", "policy": {"max_parallel_chapters": 1}}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert invalid_boolean.status_code == 400
|
||||
assert invalid_boolean.json()["error"] == "force must be boolean"
|
||||
assert approval.status_code == 200
|
||||
assert approval.json()["approval_forced"] is True
|
||||
assert approval.json()["approval_notes"] == "Reviewed outside Artifex."
|
||||
assert run.status_code == 201
|
||||
assert run.json()["policy"]["max_parallel_chapters"] == 1
|
||||
|
||||
|
||||
def test_review_rejects_malformed_model_response(work: Work) -> None:
|
||||
class MalformedReviewRouter(FakeReviewRouter):
|
||||
def complete(self, request):
|
||||
self.requests.append(request)
|
||||
return ModelResponseContract(model="bad-reviewer", content="{}", metadata={})
|
||||
|
||||
service = BookStateService(MalformedReviewRouter())
|
||||
state = service.create(work=work, content=book_content(1))
|
||||
|
||||
with pytest.raises(ValueError, match="findings list"):
|
||||
service.review(state, "manuscript")
|
||||
|
||||
|
||||
def test_sync_rejects_cancelled_run(work: Work) -> None:
|
||||
service = BookStateService(FakeReviewRouter())
|
||||
state = service.create(work=work, content=book_content(1))
|
||||
make_review_ready(state)
|
||||
state = service.approve(state)
|
||||
run = service.start_run(state)
|
||||
run.status = BookRunStatus.CANCELLED
|
||||
run.save(update_fields=["status", "updated_at"])
|
||||
|
||||
with pytest.raises(ValueError, match="cancelled"):
|
||||
service.sync_run(run)
|
||||
|
||||
|
||||
def test_bound_scene_lineage_is_scoped_and_unambiguous(work: Work) -> None:
|
||||
book_service = BookStateService(FakeReviewRouter())
|
||||
state = book_service.create(work=work, content=book_content(1))
|
||||
make_review_ready(state)
|
||||
state = book_service.approve(state)
|
||||
scene_service = StandaloneSceneService(FakeReviewRouter())
|
||||
|
||||
first = scene_service.create(
|
||||
work=work,
|
||||
title="First Draft",
|
||||
brief="Perform the chapter turn.",
|
||||
book_state=state,
|
||||
book_chapter_key="chapter-1",
|
||||
)
|
||||
revision = scene_service.create(
|
||||
work=work,
|
||||
title="First Draft",
|
||||
brief="Revise the chapter turn.",
|
||||
book_state=state,
|
||||
book_chapter_key="chapter-1",
|
||||
)
|
||||
|
||||
assert revision.parent == first
|
||||
assert revision.revision == 2
|
||||
unbound = scene_service.create(
|
||||
work=work,
|
||||
title="First Draft",
|
||||
brief="Use the same title outside book-state orchestration.",
|
||||
)
|
||||
assert scene_service._artifact_paths(first) != scene_service._artifact_paths(unbound)
|
||||
with pytest.raises(ValueError, match="different scene lineage"):
|
||||
scene_service.create(
|
||||
work=work,
|
||||
title="Competing Draft",
|
||||
brief="Create an ambiguous chapter lineage.",
|
||||
book_state=state,
|
||||
book_chapter_key="chapter-1",
|
||||
)
|
||||
|
||||
|
||||
def test_scene_refs_reject_scenes_bound_to_another_book_state(work: Work) -> None:
|
||||
service = BookStateService(FakeReviewRouter())
|
||||
state = service.create(work=work, content=book_content(1))
|
||||
make_review_ready(state)
|
||||
state = service.approve(state)
|
||||
scene = StandaloneScene.objects.create(
|
||||
work=work,
|
||||
book_state=state,
|
||||
book_chapter_key="chapter-1",
|
||||
scene_key="bound-source",
|
||||
revision=1,
|
||||
title="Bound Source",
|
||||
status=SceneDraftStatus.APPROVED,
|
||||
brief="A bound scene cannot be reused as an assembly reference.",
|
||||
prose="Approved prose.",
|
||||
)
|
||||
revised = deepcopy(state.content)
|
||||
revised["chapters"][0]["scene_refs"] = [
|
||||
{"scene_id": str(scene.id), "revision": scene.revision, "sha256": scene.sha256}
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="unbound assembly scenes"):
|
||||
service.revise(state, content=revised)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
428
tests/test_scene_ideation.py
Normal file
428
tests/test_scene_ideation.py
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
from control_plane.authoring.models import (
|
||||
BookStateStatus,
|
||||
BookStateVersion,
|
||||
DocumentAuthority,
|
||||
DocumentType,
|
||||
SceneDraftStatus,
|
||||
Series,
|
||||
StandaloneScene,
|
||||
Work,
|
||||
)
|
||||
from control_plane.authoring.sources import register_source
|
||||
from control_plane.authoring.standalone_scenes import (
|
||||
SceneIdeationService,
|
||||
render_scene_ideation_compact_markdown,
|
||||
render_scene_ideation_markdown,
|
||||
)
|
||||
from model_router.router import ModelRequestContract, ModelResponseContract, ModelRouter
|
||||
|
||||
|
||||
class FakeIdeationProvider:
|
||||
provider_name = "test"
|
||||
|
||||
def complete(self, _request: ModelRequestContract) -> ModelResponseContract:
|
||||
return ModelResponseContract(
|
||||
model="test-ideas",
|
||||
content=json.dumps(
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"title": "The Unused Chair",
|
||||
"brief": "Sabine and Corin negotiate an ordinary household choice.",
|
||||
"purpose": "Spend an unresolved agency question through action.",
|
||||
"placement": "After Sabine begins paid estate work.",
|
||||
"pov_character": "Sabine",
|
||||
"scene_type": "quiet_connection",
|
||||
"type_fit": "The scene changes meaning through a freely chosen quiet presence.",
|
||||
"scope_fit": "Sabine's paid estate work exists within Book Six.",
|
||||
"prerequisites": ["Sabine has begun paid estate work."],
|
||||
"target_words": 1600,
|
||||
"citations": ["SRC-01", "SRC-02"],
|
||||
"opportunity": "Turn a stated boundary into an ordinary decision.",
|
||||
"future_opportunities": [
|
||||
"Sabine can later delegate authority on her own terms.",
|
||||
"Corin can face a higher-cost choice not to intervene.",
|
||||
],
|
||||
"constraints": ["Sabine makes the final choice."],
|
||||
"forbidden_events": ["No sexual escalation."],
|
||||
"boundary_constraints": ["The decision remains reversible."],
|
||||
"continuity_questions": ["Confirm exact chronology."],
|
||||
"risks": ["Do not make employment repayment for rescue."],
|
||||
},
|
||||
{
|
||||
"title": "A Route Left Open",
|
||||
"brief": "Corin declines to optimize a shared evening for Sabine.",
|
||||
"purpose": "Test whether restraint can produce a new romantic option.",
|
||||
"placement": "During an unassigned Book Two interval.",
|
||||
"pov_character": "Corin",
|
||||
"scene_type": "major_turn",
|
||||
"type_fit": "Corin's refusal changes their available relationship choices.",
|
||||
"scope_fit": "The established relationship question is active in Book Six.",
|
||||
"prerequisites": ["Sabine and Corin know each other."],
|
||||
"target_words": 1900,
|
||||
"citations": ["SRC-01"],
|
||||
"opportunity": (
|
||||
"Use an unspent choice without resolving later milestones."
|
||||
),
|
||||
"future_opportunities": [
|
||||
"Their unfinished route can acquire a different meaning later."
|
||||
],
|
||||
"constraints": [],
|
||||
"forbidden_events": ["No completed intercourse."],
|
||||
"boundary_constraints": ["Envelope After remains sealed."],
|
||||
"continuity_questions": [],
|
||||
"risks": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
metadata={},
|
||||
)
|
||||
|
||||
def health(self) -> str:
|
||||
return "AVAILABLE"
|
||||
|
||||
|
||||
def idea_work(tmp_path: Path) -> Work:
|
||||
series = Series.objects.create(title="Labyrinth Hero", slug="labyrinth-hero")
|
||||
work = Work.objects.create(
|
||||
series=series,
|
||||
title="The Fortune Below",
|
||||
slug="the-fortune-below",
|
||||
)
|
||||
root = tmp_path / "idea-sources"
|
||||
root.mkdir()
|
||||
sources = [
|
||||
("canon.md", "Sabine preserves explicit choice and independent authority.", "canon"),
|
||||
("planning.md", "Sabine begins paid estate work only after freedom.", "planning"),
|
||||
("experiment.md", "Sabine accepts an invented irreversible promise.", "provisional"),
|
||||
]
|
||||
for name, content, authority in sources:
|
||||
path = root / name
|
||||
path.write_text(content, encoding="utf-8")
|
||||
register_source(
|
||||
work=work,
|
||||
path=path,
|
||||
root=root,
|
||||
authority=authority,
|
||||
document_type=(
|
||||
DocumentType.CANON
|
||||
if authority == DocumentAuthority.CANON
|
||||
else DocumentType.PLANNING
|
||||
),
|
||||
)
|
||||
return work
|
||||
|
||||
|
||||
def test_ideation_is_cited_read_only_and_selection_is_idempotent(tmp_path: Path) -> None:
|
||||
work = idea_work(tmp_path)
|
||||
service = SceneIdeationService(ModelRouter({"sol": FakeIdeationProvider()}))
|
||||
|
||||
idea = service.propose(
|
||||
work=work,
|
||||
target_book="Book Six",
|
||||
focus="Sabine ordinary choice agency",
|
||||
candidate_count=2,
|
||||
governing_document_keys=["planning.md"],
|
||||
model_hint="sol",
|
||||
)
|
||||
|
||||
assert StandaloneScene.objects.count() == 0
|
||||
assert {item["authority"] for item in idea.context_pack["citations"]} == {
|
||||
DocumentAuthority.CANON,
|
||||
DocumentAuthority.PLANNING,
|
||||
}
|
||||
assert [item["candidate_id"] for item in idea.candidates] == ["idea-01", "idea-02"]
|
||||
assert idea.candidates[0]["future_opportunities"] == [
|
||||
"Sabine can later delegate authority on her own terms.",
|
||||
"Corin can face a higher-cost choice not to intervene.",
|
||||
]
|
||||
assert len(idea.generation_metadata["prompt_sha256"]) == 64
|
||||
assert idea.target_book == "Book Six"
|
||||
assert "physical_escalation" in idea.requested_scene_types
|
||||
assert idea.context_pack["governing_document_keys"] == ["planning.md"]
|
||||
assert idea.context_pack["citations"][0]["kind"] == "governing_document"
|
||||
|
||||
scene, created = service.select_candidate(idea, candidate_id="idea-01")
|
||||
same_scene, created_again = service.select_candidate(idea, candidate_id="idea-01")
|
||||
|
||||
assert created is True
|
||||
assert created_again is False
|
||||
assert same_scene.id == scene.id
|
||||
assert scene.status == SceneDraftStatus.PLANNING
|
||||
assert scene.title == "The Unused Chair"
|
||||
assert scene.constraints == [
|
||||
"Sabine makes the final choice.",
|
||||
"Placement scope: Book Six. Sabine's paid estate work exists within Book Six.",
|
||||
]
|
||||
assert StandaloneScene.objects.count() == 1
|
||||
|
||||
|
||||
def test_scene_idea_api_proposes_and_selects_candidate(tmp_path: Path) -> None:
|
||||
work = idea_work(tmp_path)
|
||||
service = SceneIdeationService(ModelRouter({"sol": FakeIdeationProvider()}))
|
||||
with patch("control_plane.authoring.views.ideation_service", return_value=service):
|
||||
response = Client().post(
|
||||
reverse("scene_ideas"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"series_slug": work.series.slug,
|
||||
"work_slug": work.slug,
|
||||
"target_book": "Book Six",
|
||||
"focus": "Sabine ordinary choice agency",
|
||||
"candidate_count": 2,
|
||||
"model": "sol",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
idea_id = response.json()["id"]
|
||||
selected = Client().post(
|
||||
reverse("scene_idea_action", args=[idea_id]),
|
||||
data=json.dumps({"action": "select", "candidate_id": "idea-02"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
selected_again = Client().post(
|
||||
reverse("scene_idea_action", args=[idea_id]),
|
||||
data=json.dumps({"action": "select", "candidate_id": "idea-02"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["candidates"][0]["candidate_id"] == "idea-01"
|
||||
assert selected.status_code == 201
|
||||
assert selected.json()["created"] is True
|
||||
assert selected.json()["scene"]["status"] == SceneDraftStatus.PLANNING
|
||||
assert selected_again.status_code == 200
|
||||
assert selected_again.json()["created"] is False
|
||||
|
||||
|
||||
def test_book_state_ideation_binds_selected_scene_to_chapter(tmp_path: Path) -> None:
|
||||
work = idea_work(tmp_path)
|
||||
state = BookStateVersion.objects.create(
|
||||
work=work,
|
||||
version=1,
|
||||
status=BookStateStatus.APPROVED,
|
||||
content={
|
||||
"title": work.title,
|
||||
"chapters": [
|
||||
{
|
||||
"chapter_key": "chapter-1",
|
||||
"title": "Chapter 1",
|
||||
"purpose": "Open the route.",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
service = SceneIdeationService(ModelRouter({"sol": FakeIdeationProvider()}))
|
||||
idea = service.propose(
|
||||
work=work,
|
||||
target_book="Book Two",
|
||||
candidate_count=2,
|
||||
model_hint="sol",
|
||||
book_state=state,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="book_chapter_key"):
|
||||
service.select_candidate(idea, candidate_id="idea-01")
|
||||
scene, created = service.select_candidate(
|
||||
idea,
|
||||
candidate_id="idea-01",
|
||||
book_chapter_key="chapter-1",
|
||||
)
|
||||
|
||||
assert created is True
|
||||
assert idea.book_state == state
|
||||
assert idea.context_pack["book_state_sha256"] == state.sha256
|
||||
assert scene.book_state == state
|
||||
assert scene.book_chapter_key == "chapter-1"
|
||||
|
||||
|
||||
def test_ideation_rejects_citations_outside_frozen_context() -> None:
|
||||
with pytest.raises(ValueError, match="supplied citation IDs"):
|
||||
SceneIdeationService._normalize_candidates(
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"title": "Unsupported Idea",
|
||||
"brief": "A proposal grounded in evidence the model did not receive.",
|
||||
"purpose": "Verify citation validation.",
|
||||
"opportunity": "Spend an unsupported question.",
|
||||
"scene_type": "quiet_connection",
|
||||
"type_fit": "A quiet choice changes the relationship.",
|
||||
"scope_fit": "All prerequisites exist in the selected book.",
|
||||
"citations": ["SRC-99"],
|
||||
"future_opportunities": ["A later choice becomes available."],
|
||||
}
|
||||
]
|
||||
},
|
||||
candidate_count=1,
|
||||
citation_ids={"SRC-01"},
|
||||
)
|
||||
|
||||
|
||||
def test_ideation_requires_future_opportunities() -> None:
|
||||
with pytest.raises(ValueError, match="future_opportunities"):
|
||||
SceneIdeationService._normalize_candidates(
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"title": "Closed Door",
|
||||
"brief": "A choice closes one route without resolving the relationship.",
|
||||
"purpose": "Spend an established question.",
|
||||
"opportunity": "The unresolved question can now be answered through action.",
|
||||
"scene_type": "quiet_connection",
|
||||
"type_fit": "The choice changes their physical distance.",
|
||||
"scope_fit": "All prerequisites exist in the selected book.",
|
||||
"citations": ["SRC-01"],
|
||||
}
|
||||
]
|
||||
},
|
||||
candidate_count=1,
|
||||
citation_ids={"SRC-01"},
|
||||
)
|
||||
|
||||
|
||||
def test_ideation_markdown_export_is_deterministic_and_read_only(tmp_path: Path) -> None:
|
||||
work = idea_work(tmp_path)
|
||||
service = SceneIdeationService(ModelRouter({"sol": FakeIdeationProvider()}))
|
||||
idea = service.propose(
|
||||
work=work,
|
||||
target_book="Book Six",
|
||||
focus="Sabine ordinary choice agency",
|
||||
candidate_count=2,
|
||||
model_hint="sol",
|
||||
)
|
||||
expected = render_scene_ideation_markdown(idea)
|
||||
output = tmp_path / "reviews" / "ideas.md"
|
||||
stdout = StringIO()
|
||||
|
||||
call_command(
|
||||
"fiction_ideas",
|
||||
"export",
|
||||
"--id",
|
||||
str(idea.id),
|
||||
"--output",
|
||||
str(output),
|
||||
stdout=stdout,
|
||||
)
|
||||
|
||||
assert output.read_text(encoding="utf-8") == expected
|
||||
assert "# Scene Ideas: The Fortune Below" in expected
|
||||
assert "## idea-01: The Unused Chair" in expected
|
||||
assert "**Scene type:** `quiet_connection`" in expected
|
||||
assert "### Future Opportunities Created" in expected
|
||||
assert "Sabine can later delegate authority on her own terms." in expected
|
||||
assert "- No candidate has been selected." in expected
|
||||
assert "- Target book: `Book Six`" in expected
|
||||
assert "- Requested scene types:" in expected
|
||||
assert "- Governing documents: (none)" in expected
|
||||
assert "### Book Scope Fit" in expected
|
||||
assert "### Scene Type Fit" in expected
|
||||
assert "## Frozen Citation Index" in expected
|
||||
assert "`canon.md`" in expected
|
||||
assert str(output) in stdout.getvalue()
|
||||
assert StandaloneScene.objects.count() == 0
|
||||
|
||||
|
||||
def test_compact_ideation_normalization_and_export(tmp_path: Path) -> None:
|
||||
candidates = SceneIdeationService._normalize_candidates(
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"title": "A Deliberate Threshold",
|
||||
"brief": "Corin makes a physical choice Sabine did not design for him.",
|
||||
"scene_type": "physical_escalation",
|
||||
"citations": ["SRC-01"],
|
||||
"opportunity": "Reveal what Corin independently wants.",
|
||||
"future_opportunities": [
|
||||
"Sabine can recognize the choice when he makes it again.",
|
||||
"Corin can decide whether to cross the next threshold.",
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
candidate_count=1,
|
||||
citation_ids={"SRC-01"},
|
||||
allowed_scene_types={"physical_escalation"},
|
||||
compact=True,
|
||||
)
|
||||
work = idea_work(tmp_path)
|
||||
service = SceneIdeationService(ModelRouter({"sol": FakeIdeationProvider()}))
|
||||
idea = service.propose(
|
||||
work=work,
|
||||
target_book="Book Six",
|
||||
candidate_count=2,
|
||||
model_hint="sol",
|
||||
)
|
||||
idea.candidates = candidates
|
||||
idea.save(update_fields=["candidates", "updated_at"])
|
||||
|
||||
rendered = render_scene_ideation_compact_markdown(idea)
|
||||
output = tmp_path / "compact-ideas.md"
|
||||
call_command(
|
||||
"fiction_ideas",
|
||||
"export",
|
||||
"--id",
|
||||
str(idea.id),
|
||||
"--output",
|
||||
str(output),
|
||||
"--compact",
|
||||
)
|
||||
|
||||
assert output.read_text(encoding="utf-8") == rendered
|
||||
assert "### Brief" in rendered
|
||||
assert "### Opportunity Spent" in rendered
|
||||
assert "### Future Opportunities Created" in rendered
|
||||
assert "### Evaluation:" in rendered
|
||||
assert "### Feedback:" in rendered
|
||||
assert "### Purpose" not in rendered
|
||||
assert "### Constraints" not in rendered
|
||||
assert "## Frozen Citation Index" not in rendered
|
||||
assert set(candidates[0]) == {
|
||||
"candidate_id",
|
||||
"title",
|
||||
"brief",
|
||||
"scene_type",
|
||||
"citations",
|
||||
"opportunity",
|
||||
"future_opportunities",
|
||||
}
|
||||
|
||||
|
||||
def test_ideation_requires_target_book(tmp_path: Path) -> None:
|
||||
work = idea_work(tmp_path)
|
||||
service = SceneIdeationService(ModelRouter({"sol": FakeIdeationProvider()}))
|
||||
|
||||
with pytest.raises(ValueError, match="target_book is required"):
|
||||
service.propose(work=work, target_book="", candidate_count=2, model_hint="sol")
|
||||
|
||||
|
||||
def test_ideation_allows_repeated_explicit_scene_type() -> None:
|
||||
data = json.loads(FakeIdeationProvider().complete(None).content)
|
||||
for candidate in data["candidates"]:
|
||||
candidate["scene_type"] = "physical_escalation"
|
||||
|
||||
candidates = SceneIdeationService._normalize_candidates(
|
||||
data,
|
||||
candidate_count=2,
|
||||
citation_ids={"SRC-01", "SRC-02"},
|
||||
allowed_scene_types={"physical_escalation"},
|
||||
)
|
||||
|
||||
assert [candidate["scene_type"] for candidate in candidates] == [
|
||||
"physical_escalation",
|
||||
"physical_escalation",
|
||||
]
|
||||
83
tests/test_standalone_scene_api.py
Normal file
83
tests/test_standalone_scene_api.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
from control_plane.authoring.models import (
|
||||
DocumentAuthority,
|
||||
DocumentType,
|
||||
Series,
|
||||
StandaloneScene,
|
||||
Work,
|
||||
)
|
||||
from control_plane.authoring.sources import register_source
|
||||
|
||||
|
||||
def api_work() -> Work:
|
||||
series = Series.objects.create(title="Labyrinth Hero", slug="labyrinth-hero")
|
||||
return Work.objects.create(series=series, title="The Fortune Below", slug="the-fortune-below")
|
||||
|
||||
|
||||
def test_scene_api_creates_lists_and_returns_optional_prose() -> None:
|
||||
work = api_work()
|
||||
response = Client().post(
|
||||
reverse("standalone_scenes"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"series_slug": work.series.slug,
|
||||
"work_slug": work.slug,
|
||||
"title": "Office Visit",
|
||||
"brief": "Sabine visits Corin during ordinary work.",
|
||||
"target_words": 1200,
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
scene = StandaloneScene.objects.get(id=response.json()["id"])
|
||||
scene.prose = "Draft prose."
|
||||
scene.save()
|
||||
listing = Client().get(reverse("standalone_scenes"))
|
||||
detail = Client().get(reverse("standalone_scene_detail", args=[scene.id]))
|
||||
detail_with_prose = Client().get(
|
||||
reverse("standalone_scene_detail", args=[scene.id]), {"include_prose": "1"}
|
||||
)
|
||||
|
||||
assert listing.status_code == 200
|
||||
assert listing.json()["scenes"][0]["id"] == str(scene.id)
|
||||
assert "prose" not in detail.json()
|
||||
assert detail_with_prose.json()["prose"] == "Draft prose."
|
||||
|
||||
|
||||
def test_scene_api_previews_cited_context_without_model_call(tmp_path: Path) -> None:
|
||||
work = api_work()
|
||||
root = tmp_path / "sources"
|
||||
root.mkdir()
|
||||
source = root / "canon.md"
|
||||
source.write_text("Sabine visits Corin's office during ordinary work.", encoding="utf-8")
|
||||
register_source(
|
||||
work=work,
|
||||
path=source,
|
||||
root=root,
|
||||
authority=DocumentAuthority.CANON,
|
||||
document_type=DocumentType.CANON,
|
||||
)
|
||||
scene = StandaloneScene.objects.create(
|
||||
work=work,
|
||||
scene_key="office-visit",
|
||||
title="Office Visit",
|
||||
brief="Sabine visits Corin's office.",
|
||||
)
|
||||
|
||||
response = Client().post(
|
||||
reverse("standalone_scene_action", args=[scene.id]),
|
||||
data=json.dumps({"action": "context", "authorities": ["canon"]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["citations"][0]["document_key"] == "canon.md"
|
||||
318
tests/test_standalone_scenes.py
Normal file
318
tests/test_standalone_scenes.py
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from control_plane.authoring.models import (
|
||||
DocumentAuthority,
|
||||
DocumentType,
|
||||
SceneDraftStatus,
|
||||
Series,
|
||||
StoryProject,
|
||||
Work,
|
||||
WorkType,
|
||||
)
|
||||
from control_plane.authoring.scene_context import build_scene_context_pack
|
||||
from control_plane.authoring.sources import register_source
|
||||
from control_plane.authoring.standalone_scenes import StandaloneSceneService
|
||||
from control_plane.projects.models import Project
|
||||
from model_router.router import (
|
||||
ModelCapability,
|
||||
ModelChunk,
|
||||
ModelRequestContract,
|
||||
ModelResponseContract,
|
||||
ModelRouter,
|
||||
)
|
||||
|
||||
|
||||
class FakeSceneProvider:
|
||||
provider_name = "test"
|
||||
|
||||
def complete(self, request: ModelRequestContract) -> ModelResponseContract:
|
||||
if str(request.purpose) == str(ModelCapability.STORY_PLANNING):
|
||||
content = json.dumps(
|
||||
{
|
||||
"purpose": "Sabine gives Corin a precise answer.",
|
||||
"pov_character": "Sabine",
|
||||
"tense": "past",
|
||||
"location": "Corin's office",
|
||||
"time_context": "ordinary daytime work",
|
||||
"present": ["Sabine", "Corin"],
|
||||
"beats": [
|
||||
{"text": "Sabine crosses the room.", "required": True},
|
||||
{"text": "Corin waits for her answer.", "required": True},
|
||||
{"text": "Sabine leaves on her own timing.", "required": True},
|
||||
{"text": "Corin does not turn it into a negotiation.", "required": True},
|
||||
{"text": "Ordinary work remains unfinished.", "required": True},
|
||||
],
|
||||
"exact_values": [],
|
||||
"constraints": [],
|
||||
"forbidden_events": [],
|
||||
"ending_state": "Sabine has left the office.",
|
||||
"final_image": "Corin remains beside the unfinished paperwork.",
|
||||
"boundary_constraints": [],
|
||||
"continuity_questions": [],
|
||||
}
|
||||
)
|
||||
else:
|
||||
requirement_ids = re.findall(r'"id":\s*"([^"]+)"', request.prompt)
|
||||
content = json.dumps(
|
||||
{
|
||||
"passed": True,
|
||||
"requirement_results": [
|
||||
{
|
||||
"requirement_id": requirement_id,
|
||||
"status": "HIT",
|
||||
"evidence_quote": "Sabine crossed the room.",
|
||||
"details": "Satisfied in the scene.",
|
||||
}
|
||||
for requirement_id in requirement_ids
|
||||
],
|
||||
"findings": [],
|
||||
"observed_state": {"scene_end": {"location": "office"}},
|
||||
"proposed_changes": [],
|
||||
}
|
||||
)
|
||||
return ModelResponseContract(model="test-model", content=content, metadata={})
|
||||
|
||||
def stream(self, _request: ModelRequestContract):
|
||||
prose = "Sabine crossed the room. " + "She waited. " * 245
|
||||
yield ModelChunk(prose + "[[END_OF_SCENE]]")
|
||||
|
||||
def health(self) -> str:
|
||||
return "AVAILABLE"
|
||||
|
||||
|
||||
def scene_work(tmp_path: Path) -> Work:
|
||||
series = Series.objects.create(title="Labyrinth Hero", slug="labyrinth-hero")
|
||||
work = Work.objects.create(series=series, title="The Fortune Below", slug="the-fortune-below")
|
||||
project = Project.objects.create(name="The Fortune Below", project_type="STORY", goal="Write")
|
||||
StoryProject.objects.create(
|
||||
project=project,
|
||||
work=work,
|
||||
title=work.title,
|
||||
series=series.title,
|
||||
slug=work.slug,
|
||||
artifact_root=str(tmp_path / "artifacts"),
|
||||
)
|
||||
return work
|
||||
|
||||
|
||||
def add_canon_source(work: Work, tmp_path: Path) -> None:
|
||||
root = tmp_path / "sources"
|
||||
root.mkdir()
|
||||
source = root / "relationship-canon.md"
|
||||
source.write_text(
|
||||
"# Relationship Canon\n\n"
|
||||
"Sabine and Corin preserve explicit choice and ordinary work boundaries.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
register_source(
|
||||
work=work,
|
||||
path=source,
|
||||
root=root,
|
||||
authority=DocumentAuthority.CANON,
|
||||
document_type=DocumentType.CANON,
|
||||
)
|
||||
|
||||
|
||||
def test_context_pack_only_uses_latest_allowed_authority(tmp_path: Path) -> None:
|
||||
work = scene_work(tmp_path)
|
||||
root = tmp_path / "sources"
|
||||
root.mkdir()
|
||||
source = root / "facts.md"
|
||||
source.write_text("# Facts\n\nSabine works at the estate.\n", encoding="utf-8")
|
||||
register_source(
|
||||
work=work,
|
||||
path=source,
|
||||
root=root,
|
||||
authority=DocumentAuthority.CANON,
|
||||
document_type=DocumentType.CANON,
|
||||
)
|
||||
register_source(
|
||||
work=work,
|
||||
path=source,
|
||||
root=root,
|
||||
authority=DocumentAuthority.SUPERSEDED,
|
||||
document_type=DocumentType.CANON,
|
||||
)
|
||||
|
||||
pack, ranked = build_scene_context_pack(work=work, query="Sabine estate")
|
||||
|
||||
assert ranked == []
|
||||
assert pack["citations"] == []
|
||||
|
||||
|
||||
def test_context_pack_includes_series_reference_but_not_sibling_book(tmp_path: Path) -> None:
|
||||
work = scene_work(tmp_path)
|
||||
reference = Work.objects.create(
|
||||
series=work.series,
|
||||
title="Series Reference",
|
||||
slug="series-reference",
|
||||
work_type=WorkType.SERIES_REFERENCE,
|
||||
)
|
||||
sibling = Work.objects.create(
|
||||
series=work.series,
|
||||
title="Sibling Book",
|
||||
slug="sibling-book",
|
||||
)
|
||||
root = tmp_path / "series-sources"
|
||||
root.mkdir()
|
||||
reference_path = root / "shared.md"
|
||||
sibling_path = root / "sibling.md"
|
||||
reference_path.write_text("Sabine follows the shared household rule.", encoding="utf-8")
|
||||
sibling_path.write_text("Sabine ignores a sibling-only invention.", encoding="utf-8")
|
||||
for source_work, path in [(reference, reference_path), (sibling, sibling_path)]:
|
||||
register_source(
|
||||
work=source_work,
|
||||
path=path,
|
||||
root=root,
|
||||
authority=DocumentAuthority.CANON,
|
||||
document_type=DocumentType.CANON,
|
||||
)
|
||||
|
||||
pack, _ranked = build_scene_context_pack(work=work, query="Sabine shared sibling invention")
|
||||
|
||||
keys = {citation["document_key"] for citation in pack["citations"]}
|
||||
assert "shared.md" in keys
|
||||
assert "sibling.md" not in keys
|
||||
|
||||
|
||||
def test_pinned_planning_sources_do_not_crowd_out_canon(tmp_path: Path) -> None:
|
||||
work = scene_work(tmp_path)
|
||||
root = tmp_path / "mixed-sources"
|
||||
root.mkdir()
|
||||
canon_path = root / "canon.md"
|
||||
planning_path = root / "planning.md"
|
||||
canon_path.write_text(
|
||||
"\n\n".join(f"Sabine canon boundary {index}." for index in range(4)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
planning_path.write_text(
|
||||
"\n\n".join(f"Sabine planning detail {index}." for index in range(10)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
register_source(
|
||||
work=work,
|
||||
path=canon_path,
|
||||
root=root,
|
||||
authority=DocumentAuthority.CANON,
|
||||
document_type=DocumentType.CANON,
|
||||
)
|
||||
register_source(
|
||||
work=work,
|
||||
path=planning_path,
|
||||
root=root,
|
||||
authority=DocumentAuthority.PLANNING,
|
||||
document_type=DocumentType.PLANNING,
|
||||
)
|
||||
|
||||
pack, _ranked = build_scene_context_pack(
|
||||
work=work,
|
||||
query="Sabine boundary planning detail",
|
||||
authorities=[DocumentAuthority.CANON, DocumentAuthority.PLANNING],
|
||||
pinned_document_keys=["planning.md"],
|
||||
limit=6,
|
||||
)
|
||||
|
||||
authorities = {citation["authority"] for citation in pack["citations"]}
|
||||
assert authorities == {DocumentAuthority.CANON, DocumentAuthority.PLANNING}
|
||||
|
||||
|
||||
def test_governing_document_is_supplied_in_full_before_rag(tmp_path: Path) -> None:
|
||||
work = scene_work(tmp_path)
|
||||
root = tmp_path / "governing-sources"
|
||||
root.mkdir()
|
||||
governing_path = root / "rules.md"
|
||||
canon_path = root / "canon.md"
|
||||
governing_text = "# Rules\n\nFirst governing rule.\n\nFinal governing rule.\n"
|
||||
governing_path.write_text(governing_text, encoding="utf-8")
|
||||
canon_path.write_text("Sabine has an additional canon fact.", encoding="utf-8")
|
||||
register_source(
|
||||
work=work,
|
||||
path=governing_path,
|
||||
root=root,
|
||||
authority=DocumentAuthority.PLANNING,
|
||||
document_type=DocumentType.PLANNING,
|
||||
)
|
||||
register_source(
|
||||
work=work,
|
||||
path=canon_path,
|
||||
root=root,
|
||||
authority=DocumentAuthority.CANON,
|
||||
document_type=DocumentType.CANON,
|
||||
)
|
||||
|
||||
pack, _ranked = build_scene_context_pack(
|
||||
work=work,
|
||||
query="Sabine canon",
|
||||
authorities=[DocumentAuthority.CANON, DocumentAuthority.PLANNING],
|
||||
governing_document_keys=["rules.md"],
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert pack["governing_document_keys"] == ["rules.md"]
|
||||
assert pack["citations"][0]["kind"] == "governing_document"
|
||||
assert pack["citations"][0]["sha256"]
|
||||
assert "# Rules" in pack["rendered_context"]
|
||||
assert "First governing rule." in pack["rendered_context"]
|
||||
assert "Final governing rule." in pack["rendered_context"]
|
||||
assert "additional canon fact" in pack["rendered_context"]
|
||||
|
||||
|
||||
def test_standalone_scene_runs_from_cited_plan_to_provisional_approval(tmp_path: Path) -> None:
|
||||
work = scene_work(tmp_path)
|
||||
add_canon_source(work, tmp_path)
|
||||
provider = FakeSceneProvider()
|
||||
service = StandaloneSceneService(ModelRouter({"terra": provider, "luna": provider}))
|
||||
scene = service.create(
|
||||
work=work,
|
||||
title="Fourteen Seconds",
|
||||
brief="Sabine interrupts Corin's ordinary work, gives him a deliberate answer, and leaves.",
|
||||
target_words=300,
|
||||
constraints=["Sabine owns the timing."],
|
||||
forbidden_events=["Do not turn this into a scored turn."],
|
||||
boundary_constraints=["Stop when Sabine leaves the office."],
|
||||
)
|
||||
|
||||
service.prepare_context(scene)
|
||||
assert scene.status == SceneDraftStatus.PLANNING
|
||||
assert scene.context_citations.count() == 1
|
||||
|
||||
service.plan(scene)
|
||||
assert scene.status == SceneDraftStatus.PLAN_REVIEW
|
||||
assert scene.context_citations.count() == 1
|
||||
assert scene.context_pack["citations"][0]["authority"] == DocumentAuthority.CANON
|
||||
assert scene.contract_requirements
|
||||
blocking_beats = [
|
||||
item
|
||||
for item in scene.contract_requirements
|
||||
if item["type"] == "BEAT" and item["blocking"]
|
||||
]
|
||||
assert len(blocking_beats) == 5
|
||||
|
||||
service.approve_plan(scene)
|
||||
service.write(scene)
|
||||
scene.prose = scene.prose.removeprefix("# Fourteen Seconds\n\n")
|
||||
scene.save()
|
||||
service.review(scene)
|
||||
service.approve(scene, actor="test")
|
||||
scene.refresh_from_db()
|
||||
|
||||
assert scene.status == SceneDraftStatus.APPROVED
|
||||
assert scene.prose.startswith("# Fourteen Seconds\n\n")
|
||||
assert scene.word_count >= 250
|
||||
assert scene.review["passed"] is True
|
||||
assert scene.source_version.authority == DocumentAuthority.PROVISIONAL
|
||||
assert len(scene.generation_metadata["planning"]["prompt_sha256"]) == 64
|
||||
assert len(scene.generation_metadata["prose"]["prompt_sha256"]) == 64
|
||||
assert scene.generation_metadata["approval"]["forced"] is False
|
||||
assert Path(scene.artifact_uri).exists()
|
||||
assert Path(scene.review_artifact_uri).exists()
|
||||
|
||||
scene.prose += " Changed after approval."
|
||||
with pytest.raises(ValueError, match="immutable"):
|
||||
scene.save()
|
||||
835
tests/test_story_authoring_workflow.py
Normal file
835
tests/test_story_authoring_workflow.py
Normal file
|
|
@ -0,0 +1,835 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
from control_plane.authoring.epub import write_epub
|
||||
from control_plane.authoring.models import (
|
||||
CanonSnapshot,
|
||||
Chapter,
|
||||
ChapterRevision,
|
||||
ChapterStateDocument,
|
||||
EditorialFinding,
|
||||
GenerationContextSnapshot,
|
||||
OutlineVersion,
|
||||
StateChange,
|
||||
StateChangeStatus,
|
||||
StoryBibleVersion,
|
||||
StoryEntity,
|
||||
StoryProject,
|
||||
)
|
||||
from control_plane.authoring.runner import StoryWorkflowRunner
|
||||
from control_plane.authoring.services import (
|
||||
DjangoStoryWorkflowServices,
|
||||
apply_exact_edits,
|
||||
compact_chapter_plan,
|
||||
compact_scene_contract,
|
||||
deterministic_temporal_findings,
|
||||
scene_draft_packet,
|
||||
)
|
||||
from control_plane.authoring.state_management import (
|
||||
apply_state_changes,
|
||||
build_contract_requirements,
|
||||
evidence_is_present,
|
||||
)
|
||||
from control_plane.authoring.streaming import DraftResult, ResumableDraftWriter
|
||||
from control_plane.authoring.workflow import build_story_workflow
|
||||
from control_plane.projects.models import Project
|
||||
from graph.models import GraphApproval, GraphApprovalStatus, GraphRunStatus
|
||||
from model_router.router import (
|
||||
ModelChunk,
|
||||
ModelRequestContract,
|
||||
ModelResponseContract,
|
||||
ModelRouter,
|
||||
)
|
||||
|
||||
|
||||
def story_revision() -> ChapterRevision:
|
||||
project = Project.objects.create(name="Story Test", project_type="STORY", goal="Write")
|
||||
story = StoryProject.objects.create(project=project, title="Test Book", slug="test-book")
|
||||
bible = StoryBibleVersion.objects.create(story=story, version=1, content="Canon")
|
||||
outline = OutlineVersion.objects.create(
|
||||
story=story,
|
||||
version=1,
|
||||
content={"chapters": [{"number": 1, "title": "Opening", "beats": ["Begin"]}]},
|
||||
)
|
||||
chapter = Chapter.objects.create(story=story, number=1, title="Opening")
|
||||
return ChapterRevision.objects.create(
|
||||
chapter=chapter, revision=1, story_bible=bible, outline=outline
|
||||
)
|
||||
|
||||
|
||||
class FakeStoryServices:
|
||||
def __init__(self) -> None:
|
||||
self.review_calls: list[str] = []
|
||||
self.audit_calls = 0
|
||||
self.plan_calls = 0
|
||||
|
||||
def build_context(self, state):
|
||||
return {"context_snapshot_id": "context-1"}
|
||||
|
||||
def plan_chapter(self, state):
|
||||
self.plan_calls += 1
|
||||
return {"scene_plan": {"scenes": [{"number": 1}]}, "human_notes": ""}
|
||||
|
||||
def draft_chapter(self, state):
|
||||
return {"revision_id": state["revision_id"]}
|
||||
|
||||
def extract_continuity(self, state):
|
||||
return {}
|
||||
|
||||
def judge_state_contract(self, state):
|
||||
self.audit_calls += 1
|
||||
return {"state_judge_status": "pass", "editorial_finding_ids": []}
|
||||
|
||||
def decide_patch(self, state):
|
||||
return {"patch_decision": "human_review", "patch_finding_ids": []}
|
||||
|
||||
def apply_automatic_patch(self, state):
|
||||
raise AssertionError("patch should not run")
|
||||
|
||||
def verify_patch(self, state):
|
||||
raise AssertionError("verification should not run")
|
||||
|
||||
def state_approval_payload(self, state):
|
||||
return {}
|
||||
|
||||
def review_chapter(self, state, review_kind):
|
||||
self.review_calls.append(review_kind)
|
||||
return [f"finding-{review_kind}"]
|
||||
|
||||
def judge_chapter(self, state):
|
||||
return "human_review"
|
||||
|
||||
def revise_chapter(self, state):
|
||||
return {
|
||||
"revision_id": state["revision_id"],
|
||||
"revision_attempt": int(state.get("revision_attempt") or 0) + 1,
|
||||
}
|
||||
|
||||
def ensure_approval(self, state, gate, payload):
|
||||
approval, _ = GraphApproval.objects.get_or_create(
|
||||
graph_run_id=state["graph_run_id"],
|
||||
reason=f"{gate}:{state['revision_id']}",
|
||||
status=GraphApprovalStatus.PENDING,
|
||||
defaults={"payload": payload},
|
||||
)
|
||||
return approval
|
||||
|
||||
def decide_approval(self, approval_id, decision):
|
||||
approval = GraphApproval.objects.get(id=approval_id)
|
||||
approval.status = (
|
||||
GraphApprovalStatus.APPROVED
|
||||
if decision["action"] == "approve"
|
||||
else GraphApprovalStatus.REJECTED
|
||||
)
|
||||
approval.save(update_fields=["status", "updated_at"])
|
||||
|
||||
def commit_chapter(self, state):
|
||||
return {"canon_snapshot_id": "canon-1"}
|
||||
|
||||
def publish_story(self, state):
|
||||
return "test.epub"
|
||||
|
||||
|
||||
def test_story_graph_runs_one_consolidated_review() -> None:
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
revision = story_revision()
|
||||
services = FakeStoryServices()
|
||||
runner = StoryWorkflowRunner(build_story_workflow(services, MemorySaver()))
|
||||
|
||||
graph_run = runner.start(revision)
|
||||
|
||||
assert graph_run.status == GraphRunStatus.PAUSED
|
||||
assert graph_run.current_node == "approve_plan"
|
||||
assert GraphApproval.objects.filter(status=GraphApprovalStatus.PENDING).count() == 1
|
||||
|
||||
graph_run = runner.resume(graph_run.id, {"action": "approve", "actor": "test"})
|
||||
|
||||
assert graph_run.status == GraphRunStatus.PAUSED
|
||||
assert graph_run.current_node == "approve_chapter"
|
||||
assert services.audit_calls == 1
|
||||
assert services.review_calls == []
|
||||
|
||||
graph_run = runner.resume(graph_run.id, {"action": "approve", "actor": "test"})
|
||||
|
||||
assert graph_run.status == GraphRunStatus.COMPLETE
|
||||
assert graph_run.metadata["final_state"]["export_uri"] == "test.epub"
|
||||
|
||||
|
||||
def test_story_graph_allows_one_patch_then_one_final_extraction() -> None:
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
class PatchServices(FakeStoryServices):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.extraction_calls = 0
|
||||
self.patch_calls = 0
|
||||
self.verification_calls = 0
|
||||
|
||||
def extract_continuity(self, state):
|
||||
self.extraction_calls += 1
|
||||
return {}
|
||||
|
||||
def judge_state_contract(self, state):
|
||||
self.audit_calls += 1
|
||||
return {"state_judge_status": "revise", "editorial_finding_ids": ["finding-1"]}
|
||||
|
||||
def decide_patch(self, state):
|
||||
return {"patch_decision": "patch", "patch_finding_ids": ["finding-1"]}
|
||||
|
||||
def apply_automatic_patch(self, state):
|
||||
self.patch_calls += 1
|
||||
return {
|
||||
"revision_id": state["revision_id"],
|
||||
"patch_attempted": True,
|
||||
"patch_status": "applied",
|
||||
"patch_source_revision_id": state["revision_id"],
|
||||
"changed_passages": [{"old_text": "old", "new_text": "new"}],
|
||||
}
|
||||
|
||||
def verify_patch(self, state):
|
||||
self.verification_calls += 1
|
||||
return {"verification_status": "pass"}
|
||||
|
||||
revision = story_revision()
|
||||
services = PatchServices()
|
||||
runner = StoryWorkflowRunner(build_story_workflow(services, MemorySaver()))
|
||||
graph_run = runner.start(revision)
|
||||
graph_run = runner.resume(graph_run.id, {"action": "approve", "actor": "test"})
|
||||
|
||||
assert graph_run.current_node == "approve_chapter"
|
||||
assert services.audit_calls == 1
|
||||
assert services.extraction_calls == 1
|
||||
assert services.patch_calls == 1
|
||||
assert services.verification_calls == 1
|
||||
|
||||
graph_run = runner.resume(
|
||||
graph_run.id, {"action": "request_revision", "actor": "test"}
|
||||
)
|
||||
|
||||
assert graph_run.status == GraphRunStatus.COMPLETE
|
||||
assert services.patch_calls == 1
|
||||
assert services.verification_calls == 1
|
||||
|
||||
|
||||
def test_patch_selection_excludes_subjective_low_and_optional_contract_findings() -> None:
|
||||
from control_plane.authoring.services import DjangoStoryWorkflowServices
|
||||
|
||||
revision = story_revision()
|
||||
EditorialFinding.objects.create(
|
||||
revision=revision,
|
||||
review_kind="story_audit",
|
||||
severity="LOW",
|
||||
category="style",
|
||||
description="Optional preference",
|
||||
evidence={"objective": True, "exact_patch_suitable": True},
|
||||
)
|
||||
EditorialFinding.objects.create(
|
||||
revision=revision,
|
||||
review_kind="state_contract",
|
||||
severity="MEDIUM",
|
||||
category="contract:BEAT",
|
||||
description="Optional beat detail",
|
||||
evidence={
|
||||
"objective": True,
|
||||
"exact_patch_suitable": True,
|
||||
"blocking": False,
|
||||
},
|
||||
)
|
||||
objective = EditorialFinding.objects.create(
|
||||
revision=revision,
|
||||
review_kind="story_audit",
|
||||
severity="MEDIUM",
|
||||
category="continuity",
|
||||
description="Concrete contradiction",
|
||||
evidence={"objective": True, "exact_patch_suitable": True},
|
||||
)
|
||||
services = DjangoStoryWorkflowServices(ModelRouter({}))
|
||||
|
||||
result = services.decide_patch({"revision_id": str(revision.id)})
|
||||
|
||||
assert result["patch_decision"] == "patch"
|
||||
assert result["patch_finding_ids"] == [str(objective.id)]
|
||||
|
||||
|
||||
def test_approval_inbox_resumes_story_checkpoint(monkeypatch) -> None:
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
from control_plane.projects import views
|
||||
|
||||
revision = story_revision()
|
||||
services = FakeStoryServices()
|
||||
checkpointer = MemorySaver()
|
||||
graph_run = StoryWorkflowRunner(build_story_workflow(services, checkpointer)).start(revision)
|
||||
approval = GraphApproval.objects.get(graph_run=graph_run, status=GraphApprovalStatus.PENDING)
|
||||
assert graph_run.execution_graph_version.graph.name == "story_authoring"
|
||||
|
||||
@contextmanager
|
||||
def checkpointer_context():
|
||||
yield checkpointer
|
||||
|
||||
monkeypatch.setattr(views, "open_story_checkpointer", checkpointer_context)
|
||||
monkeypatch.setattr(views, "DjangoStoryWorkflowServices", lambda *_args, **_kwargs: services)
|
||||
|
||||
response = Client().post(
|
||||
reverse("approval_action", args=[approval.id]),
|
||||
{"action": "approve", "notes": "Keep the opening quiet."},
|
||||
)
|
||||
|
||||
graph_run.refresh_from_db()
|
||||
approval.refresh_from_db()
|
||||
assert response.status_code == 302
|
||||
assert approval.status == GraphApprovalStatus.APPROVED, list(
|
||||
GraphApproval.objects.filter(graph_run=graph_run).values_list("reason", "status")
|
||||
)
|
||||
assert graph_run.status == GraphRunStatus.PAUSED
|
||||
assert graph_run.current_node == "approve_chapter"
|
||||
|
||||
|
||||
class InterruptedStreamingProvider:
|
||||
provider_name = "test"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def stream(self, request):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
yield ModelChunk("one two three ")
|
||||
raise RuntimeError("connection lost")
|
||||
yield ModelChunk("four five [[END_OF_CHAPTER]]")
|
||||
|
||||
def health(self):
|
||||
return "AVAILABLE"
|
||||
|
||||
|
||||
def test_streamed_story_draft_resumes_preserved_fragment(tmp_path: Path) -> None:
|
||||
provider = InterruptedStreamingProvider()
|
||||
writer = ResumableDraftWriter(ModelRouter({"terra": provider}))
|
||||
|
||||
result = writer.generate(
|
||||
request=ModelRequestContract(
|
||||
purpose="STORY_PROSE", prompt="write", model_hint="terra"
|
||||
),
|
||||
partial_path=tmp_path / "chapter.partial.md",
|
||||
minimum_words=5,
|
||||
max_attempts=2,
|
||||
)
|
||||
|
||||
assert result.text == "one two three four five"
|
||||
assert result.resumed is False
|
||||
assert provider.calls == 2
|
||||
assert not (tmp_path / "chapter.partial.md.attempt").exists()
|
||||
|
||||
|
||||
def test_short_completed_draft_restarts_without_duplication(tmp_path: Path) -> None:
|
||||
class ShortThenCompleteProvider:
|
||||
provider_name = "test"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def stream(self, request):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
yield ModelChunk("too short [[END_OF_CHAPTER]]")
|
||||
else:
|
||||
yield ModelChunk("one two three four five [[END_OF_CHAPTER]]")
|
||||
|
||||
def health(self):
|
||||
return "AVAILABLE"
|
||||
|
||||
provider = ShortThenCompleteProvider()
|
||||
writer = ResumableDraftWriter(ModelRouter({"qwen": provider}))
|
||||
|
||||
result = writer.generate(
|
||||
request=ModelRequestContract(
|
||||
purpose="STORY_PROSE", prompt="write", model_hint="qwen"
|
||||
),
|
||||
partial_path=tmp_path / "chapter.partial.md",
|
||||
minimum_words=5,
|
||||
max_attempts=2,
|
||||
)
|
||||
|
||||
assert result.text == "one two three four five"
|
||||
assert provider.calls == 2
|
||||
|
||||
|
||||
def test_short_completed_draft_stops_after_one_regeneration(tmp_path: Path) -> None:
|
||||
class AlwaysShortProvider:
|
||||
provider_name = "test"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def stream(self, request):
|
||||
self.calls += 1
|
||||
yield ModelChunk("too short [[END_OF_CHAPTER]]")
|
||||
|
||||
def health(self):
|
||||
return "AVAILABLE"
|
||||
|
||||
provider = AlwaysShortProvider()
|
||||
writer = ResumableDraftWriter(ModelRouter({"qwen": provider}))
|
||||
|
||||
with pytest.raises(RuntimeError, match="shorter than 5 words"):
|
||||
writer.generate(
|
||||
request=ModelRequestContract(
|
||||
purpose="STORY_PROSE", prompt="write", model_hint="qwen"
|
||||
),
|
||||
partial_path=tmp_path / "chapter.partial.md",
|
||||
minimum_words=5,
|
||||
max_attempts=4,
|
||||
)
|
||||
|
||||
assert provider.calls == 2
|
||||
|
||||
|
||||
def test_draft_chapter_generates_full_chapter_in_one_call(tmp_path: Path) -> None:
|
||||
revision = story_revision()
|
||||
revision.chapter.story.artifact_root = str(tmp_path)
|
||||
revision.chapter.story.save(update_fields=["artifact_root", "updated_at"])
|
||||
revision.scene_plan = {
|
||||
"target_words": 2000,
|
||||
"scenes": [
|
||||
{"number": 1, "word_budget": 1000, "beats": ["first"]},
|
||||
{"number": 2, "word_budget": 1000, "beats": ["second"]},
|
||||
],
|
||||
}
|
||||
revision.save(update_fields=["scene_plan", "updated_at"])
|
||||
services = DjangoStoryWorkflowServices(ModelRouter({}))
|
||||
services._context = lambda state, current: {
|
||||
"chapter": {},
|
||||
"structured_canon": {},
|
||||
"prior_canon": {},
|
||||
"previous_chapter_tail": "",
|
||||
}
|
||||
|
||||
class ChapterWriter:
|
||||
def __init__(self) -> None:
|
||||
self.requests = []
|
||||
|
||||
def generate(self, *, request, **kwargs):
|
||||
self.requests.append(request)
|
||||
assert kwargs["max_attempts"] == 1
|
||||
return DraftResult("Complete chapter prose.", 1, False, 3)
|
||||
|
||||
writer = ChapterWriter()
|
||||
services.writer = writer
|
||||
|
||||
services.draft_chapter({"revision_id": str(revision.id)})
|
||||
|
||||
revision.refresh_from_db()
|
||||
assert revision.prose == "Complete chapter prose."
|
||||
assert len(writer.requests) == 1
|
||||
assert writer.requests[0].model_hint == "terra"
|
||||
assert revision.generation_metadata["draft_mode"] == "full_chapter"
|
||||
|
||||
|
||||
def test_compact_scene_contract_consolidates_all_required_beats_into_three() -> None:
|
||||
scene = {
|
||||
"number": 1,
|
||||
"purpose": "Test",
|
||||
"beats": [{"text": f"beat {number}", "required": True} for number in range(1, 13)],
|
||||
}
|
||||
|
||||
compact = compact_scene_contract(scene)
|
||||
|
||||
assert len(compact["beats"]) == 3
|
||||
assert [beat["source_beat_count"] for beat in compact["beats"]] == [4, 4, 4]
|
||||
combined = " ".join(beat["text"] for beat in compact["beats"])
|
||||
assert all(f"beat {number}" in combined for number in range(1, 13))
|
||||
|
||||
|
||||
def test_scene_draft_packet_excludes_other_scenes() -> None:
|
||||
selected = {"number": 1, "beats": [{"text": "selected", "required": True}]}
|
||||
packet = scene_draft_packet(
|
||||
{
|
||||
"target_words": 2000,
|
||||
"exact_values": ["exact"],
|
||||
"scenes": [selected, {"number": 2, "beats": [{"text": "unrelated"}]}],
|
||||
},
|
||||
selected,
|
||||
)
|
||||
|
||||
assert packet["target_words"] == 1000
|
||||
assert packet["scene"]["beats"] == [{"text": "selected", "required": True}]
|
||||
assert "scenes" not in packet["chapter_scope"]
|
||||
|
||||
|
||||
def test_compact_chapter_plan_preserves_all_required_beats() -> None:
|
||||
plan = {
|
||||
"target_words": 5000,
|
||||
"scenes": [
|
||||
{
|
||||
"number": number,
|
||||
"beats": [
|
||||
{"text": f"scene {number} beat {beat}", "required": True}
|
||||
for beat in range(1, 7)
|
||||
],
|
||||
}
|
||||
for number in range(1, 3)
|
||||
],
|
||||
}
|
||||
|
||||
compact = compact_chapter_plan(plan)
|
||||
|
||||
assert [len(scene["beats"]) for scene in compact["scenes"]] == [3, 3]
|
||||
combined = json.dumps(compact)
|
||||
assert all(
|
||||
f"scene {scene} beat {beat}" in combined
|
||||
for scene in range(1, 3)
|
||||
for beat in range(1, 7)
|
||||
)
|
||||
|
||||
|
||||
def test_deterministic_temporal_findings_catch_premature_wealth() -> None:
|
||||
prose = (
|
||||
"The dungeon had not noticed that he had become wealthy.\n\n"
|
||||
"The bids were opened one at a time."
|
||||
)
|
||||
|
||||
findings = deterministic_temporal_findings(prose)
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0]["evidence_quote"] == "The dungeon had not noticed that he had become wealthy."
|
||||
assert findings[0]["suggested_revision"] == "The dungeon had not noticed that he might soon become wealthy."
|
||||
|
||||
|
||||
def test_deterministic_temporal_findings_catch_exact_payout_before_bidding() -> None:
|
||||
prose = (
|
||||
"Three and a half million crowns would buy better weapons.\n\n"
|
||||
"The bids were opened one at a time."
|
||||
)
|
||||
plan = {
|
||||
"exact_values": [
|
||||
"Corin's five-sixths finder share is exactly 3,500,000 silver crowns."
|
||||
]
|
||||
}
|
||||
|
||||
findings = deterministic_temporal_findings(prose, plan)
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0]["category"] == "premature_exact_value"
|
||||
assert findings[0]["suggested_revision"] == "The sale proceeds would buy better weapons."
|
||||
|
||||
|
||||
def test_epub_contains_approved_chapter_entries(tmp_path: Path) -> None:
|
||||
destination = write_epub(
|
||||
title="Test Book",
|
||||
series="Test Series",
|
||||
chapters=[{"title": "Chapter 1: Opening", "content": "First paragraph.\n\nSecond."}],
|
||||
destination=tmp_path / "book.epub",
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(destination) as archive:
|
||||
content = archive.read("OEBPS/chapter-1.xhtml").decode("utf-8")
|
||||
|
||||
assert "Chapter 1: Opening" in content
|
||||
assert "First paragraph." in content
|
||||
|
||||
|
||||
def test_story_versions_hash_content() -> None:
|
||||
revision = story_revision()
|
||||
|
||||
assert len(revision.story_bible.sha256) == 64
|
||||
assert len(revision.outline.sha256) == 64
|
||||
|
||||
|
||||
def test_exact_patch_preserves_unaffected_prose() -> None:
|
||||
prose = "First paragraph.\n\nThe water remained.\n\nLast paragraph."
|
||||
|
||||
revised = apply_exact_edits(
|
||||
prose,
|
||||
[{"old_text": "The water remained.", "new_text": "The water ran thinner than before."}],
|
||||
)
|
||||
|
||||
assert revised == "First paragraph.\n\nThe water ran thinner than before.\n\nLast paragraph."
|
||||
|
||||
|
||||
def test_exact_patch_rejects_ambiguous_source_text() -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="exactly once"):
|
||||
apply_exact_edits(
|
||||
"Repeated. Repeated.",
|
||||
[{"old_text": "Repeated.", "new_text": "Changed."}],
|
||||
)
|
||||
|
||||
|
||||
def test_contract_requirements_receive_stable_ids() -> None:
|
||||
requirements = build_contract_requirements(
|
||||
{
|
||||
"day_start": "Morning",
|
||||
"day_end": "Evening",
|
||||
"scenes": [
|
||||
{
|
||||
"number": 2,
|
||||
"beats": ["First beat", "Second beat"],
|
||||
"ending_state": "The door is closed.",
|
||||
}
|
||||
],
|
||||
"exact_values": ["Ten crowns"],
|
||||
"forbidden_shortcuts": ["Do not montage the sale."],
|
||||
}
|
||||
)
|
||||
|
||||
assert [item["id"] for item in requirements] == [
|
||||
"S02-B01",
|
||||
"S02-B02",
|
||||
"S02-END",
|
||||
"VALUE-01",
|
||||
"SHORTCUT-01",
|
||||
"TIME-START",
|
||||
"TIME-END",
|
||||
]
|
||||
assert requirements[0]["blocking"] is False
|
||||
assert requirements[3]["blocking"] is True
|
||||
assert requirements[4]["blocking"] is False
|
||||
|
||||
|
||||
def test_contract_marks_only_explicit_required_beats_as_blocking() -> None:
|
||||
requirements = build_contract_requirements(
|
||||
{
|
||||
"scenes": [
|
||||
{
|
||||
"number": 1,
|
||||
"beats": [
|
||||
{"text": "The sale settles.", "required": True},
|
||||
{"text": "Rain taps the window.", "required": False},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert requirements[0]["blocking"] is True
|
||||
assert requirements[1]["blocking"] is False
|
||||
|
||||
|
||||
def test_contract_caps_required_beats_per_scene() -> None:
|
||||
requirements = build_contract_requirements(
|
||||
{
|
||||
"scenes": [
|
||||
{
|
||||
"number": 1,
|
||||
"beats": [
|
||||
{"text": f"Beat {index}", "required": True}
|
||||
for index in range(7)
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert sum(1 for item in requirements if item["blocking"]) == 3
|
||||
|
||||
|
||||
def test_exact_patch_rejects_overlapping_and_over_budget_edits() -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="overlap"):
|
||||
apply_exact_edits(
|
||||
"abcdefghij",
|
||||
[
|
||||
{"old_text": "abcde", "new_text": "ABCDE"},
|
||||
{"old_text": "defgh", "new_text": "DEFGH"},
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="limit"):
|
||||
apply_exact_edits(
|
||||
"a" * 100 + " target " + "b" * 100,
|
||||
[{"old_text": " target ", "new_text": " a much longer replacement passage "}],
|
||||
max_change_ratio=0.05,
|
||||
)
|
||||
|
||||
|
||||
def test_state_changes_build_queryable_book_snapshot() -> None:
|
||||
state = apply_state_changes(
|
||||
{},
|
||||
[
|
||||
{
|
||||
"sequence": 1,
|
||||
"entity_key": "character.corin.vale",
|
||||
"entity_kind": "character",
|
||||
"canonical_name": "Corin Vale",
|
||||
"predicate": "finances.balance",
|
||||
"operation": "SET",
|
||||
"previous_value": None,
|
||||
"new_value": 10,
|
||||
}
|
||||
],
|
||||
through_chapter=2,
|
||||
chapter_state={"summary": "Corin receives ten crowns."},
|
||||
)
|
||||
|
||||
assert state["through_chapter"] == 2
|
||||
assert state["entities"]["character.corin.vale"]["facts"]["finances"]["balance"] == 10
|
||||
|
||||
|
||||
def test_transfer_state_change_records_explicit_custody_destination() -> None:
|
||||
state = apply_state_changes(
|
||||
{},
|
||||
[
|
||||
{
|
||||
"sequence": 1,
|
||||
"entity_key": "object.envelope.after",
|
||||
"entity_kind": "object",
|
||||
"canonical_name": "Envelope After",
|
||||
"predicate": "custody.holder",
|
||||
"operation": "TRANSFER",
|
||||
"previous_value": None,
|
||||
"new_value": None,
|
||||
"related_entity_key": "character.sabine",
|
||||
}
|
||||
],
|
||||
through_chapter=2,
|
||||
chapter_state={},
|
||||
)
|
||||
|
||||
envelope = state["entities"]["object.envelope.after"]
|
||||
assert envelope["facts"]["custody"]["holder"] == "character.sabine"
|
||||
assert envelope["relations"]["custody.holder"] == "character.sabine"
|
||||
|
||||
|
||||
def test_transfer_state_change_requires_destination_entity() -> None:
|
||||
with pytest.raises(ValueError, match="related_entity_key"):
|
||||
apply_state_changes(
|
||||
{},
|
||||
[
|
||||
{
|
||||
"sequence": 1,
|
||||
"entity_key": "object.envelope.after",
|
||||
"predicate": "custody.holder",
|
||||
"operation": "TRANSFER",
|
||||
"previous_value": None,
|
||||
"new_value": None,
|
||||
}
|
||||
],
|
||||
through_chapter=2,
|
||||
chapter_state={},
|
||||
)
|
||||
|
||||
|
||||
class StateWorkflowProvider:
|
||||
provider_name = "test"
|
||||
|
||||
def complete(self, request):
|
||||
if str(request.purpose) == "STORY_CONTINUITY":
|
||||
content = """{
|
||||
"schema_version": 2,
|
||||
"through_chapter": 1,
|
||||
"state_document": {
|
||||
"timeline": {"relative": "Morning"},
|
||||
"scene_end": {"location": "Market"},
|
||||
"characters": {"Corin Vale": {"location": "Market"}},
|
||||
"inventory": [],
|
||||
"money": [{"owner": "Corin Vale", "balance": 10}],
|
||||
"relationships": [],
|
||||
"open_threads": [],
|
||||
"promises_and_constraints": [],
|
||||
"reveals": {},
|
||||
"chapter_summary": ["Corin received ten crowns."]
|
||||
},
|
||||
"changes": [{
|
||||
"entity_key": "character.corin.vale",
|
||||
"entity_kind": "character",
|
||||
"canonical_name": "Corin Vale",
|
||||
"change_type": "MONEY_CHANGED",
|
||||
"predicate": "finances.balance",
|
||||
"operation": "SET",
|
||||
"previous_value": null,
|
||||
"new_value": 10,
|
||||
"related_entity_key": "",
|
||||
"evidence_quote": "Corin received ten crowns.",
|
||||
"evidence_location": "Scene 1"
|
||||
}]
|
||||
}"""
|
||||
else:
|
||||
content = """{
|
||||
"verdict": "PASS",
|
||||
"requirements": [
|
||||
{"requirement_id":"S01-B01","status":"HIT","evidence_quote":"Corin received ten crowns.","evidence_location":"Scene 1","details":""},
|
||||
{"requirement_id":"S01-END","status":"HIT","evidence_quote":"Corin received ten crowns.","evidence_location":"Scene 1","details":""}
|
||||
]
|
||||
}"""
|
||||
return ModelResponseContract(model="test", content=content, metadata={})
|
||||
|
||||
|
||||
def test_validated_state_document_commits_entity_history(tmp_path: Path) -> None:
|
||||
from control_plane.authoring.services import DjangoStoryWorkflowServices
|
||||
|
||||
revision = story_revision()
|
||||
story = revision.chapter.story
|
||||
story.artifact_root = str(tmp_path)
|
||||
story.save(update_fields=["artifact_root", "updated_at"])
|
||||
revision.scene_plan = {
|
||||
"scenes": [
|
||||
{
|
||||
"number": 1,
|
||||
"beats": ["Corin receives ten crowns."],
|
||||
"ending_state": "Corin has ten crowns.",
|
||||
}
|
||||
]
|
||||
}
|
||||
revision.prose = "Corin received ten crowns."
|
||||
revision.save()
|
||||
context = {
|
||||
"chapter": {"number": 1, "title": "Opening", "outline": {}},
|
||||
"story_bible": "Canon",
|
||||
"structured_canon": {},
|
||||
"prior_canon": {},
|
||||
"previous_chapter_tail": "",
|
||||
"source_revision": "",
|
||||
}
|
||||
snapshot = GenerationContextSnapshot.objects.create(
|
||||
story=story,
|
||||
chapter=revision.chapter,
|
||||
story_bible=revision.story_bible,
|
||||
outline=revision.outline,
|
||||
content=context,
|
||||
sha256="0" * 64,
|
||||
)
|
||||
revision.context_snapshot = snapshot
|
||||
revision.save(update_fields=["context_snapshot", "updated_at"])
|
||||
provider = StateWorkflowProvider()
|
||||
services = DjangoStoryWorkflowServices(
|
||||
ModelRouter({"qwen": provider, "terra": provider, "luna": provider})
|
||||
)
|
||||
state = {
|
||||
"revision_id": str(revision.id),
|
||||
"story_id": str(story.id),
|
||||
"context_snapshot_id": str(snapshot.id),
|
||||
}
|
||||
|
||||
services.extract_continuity(state)
|
||||
result = services.judge_state_contract(state)
|
||||
commit = services.commit_chapter(state)
|
||||
|
||||
document = ChapterStateDocument.objects.get(revision=revision)
|
||||
change = StateChange.objects.get(state_document=document)
|
||||
canon = CanonSnapshot.objects.get(id=commit["canon_snapshot_id"])
|
||||
assert result["state_judge_status"] == "pass"
|
||||
assert document.status == "COMMITTED"
|
||||
assert StoryEntity.objects.get(entity_key="character.corin.vale").canonical_name == "Corin Vale"
|
||||
assert change.status == StateChangeStatus.COMMITTED
|
||||
assert canon.state["entities"]["character.corin.vale"]["facts"]["finances"]["balance"] == 10
|
||||
assert Path(document.json_artifact_uri).exists()
|
||||
assert Path(document.markdown_artifact_uri).exists()
|
||||
|
||||
|
||||
def test_evidence_accepts_ordered_dialogue_fragments_with_attribution() -> None:
|
||||
prose = '"Replace those clothes," she said. "Eat somewhere respectable. Make no promises tonight."'
|
||||
|
||||
assert evidence_is_present(
|
||||
prose,
|
||||
"Replace those clothes. Eat somewhere respectable. Make no promises tonight.",
|
||||
)
|
||||
122
tests/test_story_sources.py
Normal file
122
tests/test_story_sources.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from control_plane.authoring.models import (
|
||||
DocumentAuthority,
|
||||
DocumentType,
|
||||
Series,
|
||||
SourceDocument,
|
||||
Work,
|
||||
)
|
||||
from control_plane.authoring.sources import discover_source_paths, passage_spans, register_source
|
||||
|
||||
|
||||
def source_work() -> Work:
|
||||
series = Series.objects.create(title="Labyrinth Hero", slug="labyrinth-hero")
|
||||
return Work.objects.create(series=series, title="The Fortune Below", slug="the-fortune-below")
|
||||
|
||||
|
||||
def test_passage_spans_preserve_exact_source_locations() -> None:
|
||||
content = "# Heading\n\nFirst line.\nSecond line.\n\nLast.\n"
|
||||
|
||||
passages = passage_spans(content)
|
||||
|
||||
assert [(item["start_line"], item["end_line"]) for item in passages] == [(1, 1), (3, 4), (6, 6)]
|
||||
assert all(
|
||||
content[item["start_char"] : item["end_char"]] == item["content"]
|
||||
for item in passages
|
||||
)
|
||||
|
||||
|
||||
def test_source_discovery_supports_explicit_classification_globs(tmp_path: Path) -> None:
|
||||
canon = tmp_path / "canon"
|
||||
planning = tmp_path / "planning"
|
||||
canon.mkdir()
|
||||
planning.mkdir()
|
||||
(canon / "facts.md").write_text("Canon", encoding="utf-8")
|
||||
(planning / "notes.md").write_text("Planning", encoding="utf-8")
|
||||
|
||||
discovered = discover_source_paths(tmp_path, ["canon/*.md"])
|
||||
|
||||
assert [path.name for path in discovered] == ["facts.md"]
|
||||
|
||||
|
||||
def test_register_source_is_idempotent_and_versions_changed_content(tmp_path: Path) -> None:
|
||||
work = source_work()
|
||||
root = tmp_path / "corpus"
|
||||
root.mkdir()
|
||||
path = root / "scene.md"
|
||||
path.write_text("# Scene\n\nFirst version.\n", encoding="utf-8")
|
||||
|
||||
first = register_source(
|
||||
work=work,
|
||||
path=path,
|
||||
root=root,
|
||||
authority=DocumentAuthority.PROVISIONAL,
|
||||
document_type=DocumentType.SCENE,
|
||||
)
|
||||
unchanged = register_source(
|
||||
work=work,
|
||||
path=path,
|
||||
root=root,
|
||||
authority=DocumentAuthority.PROVISIONAL,
|
||||
document_type=DocumentType.SCENE,
|
||||
)
|
||||
reclassified = register_source(
|
||||
work=work,
|
||||
path=path,
|
||||
root=root,
|
||||
authority=DocumentAuthority.CANON,
|
||||
document_type=DocumentType.SCENE,
|
||||
)
|
||||
path.write_text("# Scene\n\nSecond version.\n", encoding="utf-8")
|
||||
second = register_source(
|
||||
work=work,
|
||||
path=path,
|
||||
root=root,
|
||||
authority=DocumentAuthority.CANON,
|
||||
document_type=DocumentType.SCENE,
|
||||
)
|
||||
|
||||
document = SourceDocument.objects.get(work=work, logical_key="scene.md")
|
||||
versions = list(document.versions.order_by("version"))
|
||||
assert first.status == "created"
|
||||
assert unchanged.status == "unchanged"
|
||||
assert second.status == "versioned"
|
||||
assert reclassified.status == "versioned"
|
||||
assert len(versions) == 3
|
||||
assert versions[1].supersedes == versions[0]
|
||||
assert versions[1].source_sha256 == versions[0].source_sha256
|
||||
assert versions[1].authority == DocumentAuthority.CANON
|
||||
assert versions[2].supersedes == versions[1]
|
||||
assert versions[2].passages.count() == 2
|
||||
assert versions[2].source_sha256 == hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
versions[2].authority = DocumentAuthority.REJECTED
|
||||
with pytest.raises(ValueError, match="immutable"):
|
||||
versions[2].save()
|
||||
|
||||
|
||||
def test_story_sources_dry_run_does_not_create_database_records(tmp_path: Path) -> None:
|
||||
path = tmp_path / "canon.md"
|
||||
path.write_text("# Canon\n\nA fact.\n", encoding="utf-8")
|
||||
|
||||
call_command(
|
||||
"story_sources",
|
||||
"register",
|
||||
root=tmp_path,
|
||||
series_slug="labyrinth-hero",
|
||||
series_title="Labyrinth Hero",
|
||||
work_slug="the-fortune-below",
|
||||
work_title="The Fortune Below",
|
||||
authority=DocumentAuthority.PROVISIONAL,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert not Series.objects.exists()
|
||||
assert not SourceDocument.objects.exists()
|
||||
29
uv.lock
generated
29
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue