Artifex/control_plane/authoring/management/commands/fiction_scene.py

204 lines
8 KiB
Python
Raw Normal View History

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