218 lines
8.7 KiB
Python
218 lines
8.7 KiB
Python
|
|
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(),
|
||
|
|
}
|