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(), }