Artifex/control_plane/authoring/management/commands/fiction_book.py
2026-08-28 23:51:02 +07:00

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