204 lines
8 KiB
Python
204 lines
8 KiB
Python
|
|
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))
|