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))