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

483 lines
20 KiB
Python

from __future__ import annotations
import json
from typing import Any
from django.core.exceptions import ValidationError
from django.http import HttpRequest, JsonResponse
from django.views.decorators.http import require_http_methods
from control_plane.authoring.book_state import BookStateService
from control_plane.authoring.models import (
BookRun,
BookStateVersion,
SceneIdeation,
StandaloneScene,
Work,
)
from control_plane.authoring.standalone_scenes import (
SceneIdeationService,
StandaloneSceneService,
)
from model_router.providers import providers_from_resources
from model_router.router import ModelRouter
def scene_service() -> StandaloneSceneService:
return StandaloneSceneService(
ModelRouter(providers_from_resources(), persist_requests=True)
)
def ideation_service() -> SceneIdeationService:
return SceneIdeationService(ModelRouter(providers_from_resources(), persist_requests=True))
def book_service() -> BookStateService:
return BookStateService(ModelRouter(providers_from_resources(), persist_requests=True))
def _json_body(request: HttpRequest) -> dict[str, Any]:
try:
value = json.loads(request.body or b"{}")
except json.JSONDecodeError as exc:
raise ValueError("request body must be valid JSON") from exc
if not isinstance(value, dict):
raise ValueError("request body must be a JSON object")
return value
def _json_bool(body: dict[str, Any], field: str, *, default: bool = False) -> bool:
value = body.get(field, default)
if not isinstance(value, bool):
raise ValueError(f"{field} must be boolean")
return value
def _scene(scene_id) -> StandaloneScene | None:
return (
StandaloneScene.objects.select_related(
"work__series", "story__project", "source_version", "book_state"
)
.filter(id=scene_id)
.first()
)
def _idea(idea_id) -> SceneIdeation | None:
return SceneIdeation.objects.select_related("work__series").filter(id=idea_id).first()
def _payload(scene: StandaloneScene, *, include_prose: bool = False) -> dict[str, Any]:
payload = {
"id": str(scene.id),
"series": scene.work.series.slug,
"work": scene.work.slug,
"title": scene.title,
"scene_key": scene.scene_key,
"revision": scene.revision,
"status": scene.status,
"brief": scene.brief,
"target_words": scene.target_words,
"word_count": scene.word_count,
"constraints": scene.constraints,
"forbidden_events": scene.forbidden_events,
"boundary_constraints": scene.boundary_constraints,
"context_pack_sha256": scene.context_pack_sha256,
"citations": (scene.context_pack or {}).get("citations") or [],
"plan": scene.plan,
"contract_requirements": scene.contract_requirements,
"review": scene.review,
"sha256": scene.sha256,
"artifact_uri": scene.artifact_uri,
"review_artifact_uri": scene.review_artifact_uri,
"generation_metadata": scene.generation_metadata,
"approved_at": scene.approved_at.isoformat() if scene.approved_at else None,
"approved_by": scene.approved_by,
"source_version_id": str(scene.source_version_id) if scene.source_version_id else None,
"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,
"created_at": scene.created_at.isoformat(),
"updated_at": scene.updated_at.isoformat(),
}
if include_prose:
payload["prose"] = scene.prose
return payload
def _iso(value: Any) -> str | None:
return value.isoformat() if value else None
def _book_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", ""),
"generation_metadata": getattr(state, "generation_metadata", {}),
"created_by": state.created_by,
"json_artifact_uri": getattr(state, "json_artifact_uri", ""),
"markdown_artifact_uri": getattr(state, "markdown_artifact_uri", ""),
"approved_at": _iso(getattr(state, "approved_at", None)),
"approved_by": getattr(state, "approved_by", ""),
"approval_notes": getattr(state, "approval_notes", ""),
"approval_forced": state.approval_forced,
"rejected_at": _iso(getattr(state, "rejected_at", None)),
"rejected_by": getattr(state, "rejected_by", ""),
"rejection_notes": getattr(state, "rejection_notes", ""),
"created_at": _iso(state.created_at),
"updated_at": _iso(state.updated_at),
}
def _book_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": _iso(getattr(run, "started_at", None)),
"finished_at": _iso(getattr(run, "finished_at", None)),
"created_at": _iso(run.created_at),
"updated_at": _iso(run.updated_at),
}
def _idea_payload(idea: SceneIdeation) -> dict[str, Any]:
return {
"id": str(idea.id),
"series": idea.work.series.slug,
"work": idea.work.slug,
"book_state_id": str(idea.book_state_id) if idea.book_state_id else None,
"target_book": idea.target_book,
"requested_scene_types": idea.requested_scene_types,
"focus": idea.focus,
"candidate_count": idea.candidate_count,
"authorities": idea.authorities,
"pinned_document_keys": idea.pinned_document_keys,
"governing_document_keys": (idea.context_pack or {}).get(
"governing_document_keys"
)
or [],
"context_pack_sha256": idea.context_pack_sha256,
"citations": (idea.context_pack or {}).get("citations") or [],
"candidates": idea.candidates,
"generation_metadata": idea.generation_metadata,
"created_at": idea.created_at.isoformat(),
"updated_at": idea.updated_at.isoformat(),
}
def _book_state(state_id) -> BookStateVersion | None:
return (
BookStateVersion.objects.select_related("work__series", "parent")
.filter(id=state_id)
.first()
)
@require_http_methods(["GET", "POST"])
def book_states(request: HttpRequest) -> JsonResponse:
if request.method == "GET":
states = BookStateVersion.objects.select_related("work__series", "parent").order_by(
"-updated_at"
)[:100]
return JsonResponse({"book_states": [_book_state_payload(state) for state in states]})
try:
body = _json_body(request)
required = ["series_slug", "work_slug", "content"]
missing = [field for field in required if body.get(field) in (None, "")]
if missing:
raise ValueError("missing fields: " + ", ".join(missing))
if not isinstance(body["content"], dict):
raise ValueError("content must be a JSON object")
work = Work.objects.filter(
series__slug=body["series_slug"], slug=body["work_slug"]
).first()
if work is None:
return JsonResponse({"error": "work not found"}, status=404)
state = book_service().create(
work=work,
content=body["content"],
actor=str(body.get("actor") or "api"),
context_pack=body.get("context_pack"),
generation_metadata=body.get("generation_metadata"),
)
except (RuntimeError, TypeError, ValueError, ValidationError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
return JsonResponse(_book_state_payload(state), status=201)
@require_http_methods(["GET"])
def book_state_detail(request: HttpRequest, state_id) -> JsonResponse:
state = _book_state(state_id)
if state is None:
return JsonResponse({"error": "book state not found"}, status=404)
return JsonResponse(_book_state_payload(state))
@require_http_methods(["POST"])
def book_state_action(request: HttpRequest, state_id) -> JsonResponse:
state = _book_state(state_id)
if state is None:
return JsonResponse({"error": "book state not found"}, status=404)
try:
body = _json_body(request)
action = str(body.get("action") or "").strip().replace("-", "_")
service = book_service()
if action == "validate":
service.validate(state, for_approval=_json_bool(body, "for_approval"))
elif action == "review":
level = str(body.get("level") or "").strip()
if not level:
raise ValueError("level is required")
service.review(state, level=level, model_hint=body.get("model"))
elif action == "approve":
service.approve(
state,
actor=str(body.get("actor") or "api"),
force=_json_bool(body, "force"),
notes=str(body.get("notes") or ""),
)
elif action == "reject":
service.reject(
state,
actor=str(body.get("actor") or "api"),
notes=str(body.get("notes") or ""),
)
elif action == "revise":
content = body.get("content")
if not isinstance(content, dict):
raise ValueError("content must be a JSON object")
revised = service.revise(
state,
content=content,
actor=str(body.get("actor") or "api"),
context_pack=body.get("context_pack"),
generation_metadata=body.get("generation_metadata"),
)
return JsonResponse(_book_state_payload(revised), status=201)
elif action == "impact":
return JsonResponse({"impact": service.impact(state)})
elif action == "start_run":
run = service.start_run(state, policy=body.get("policy"))
return JsonResponse(_book_run_payload(run), status=201)
elif action == "sync_run":
run_id = str(body.get("run_id") or "").strip()
if not run_id:
raise ValueError("run_id is required")
run = BookRun.objects.filter(id=run_id).first()
if run is None:
return JsonResponse({"error": "book run not found"}, status=404)
run_state_id = getattr(run, "state_id", None) or getattr(
run, "book_state_id", None
)
if run_state_id != state.id:
raise ValueError("book run does not belong to this state")
service.sync_run(run)
run.refresh_from_db()
return JsonResponse(_book_run_payload(run))
elif action == "review_run":
run_id = str(body.get("run_id") or "").strip()
if not run_id:
raise ValueError("run_id is required")
run = BookRun.objects.filter(id=run_id, book_state=state).first()
if run is None:
return JsonResponse({"error": "book run not found"}, status=404)
service.review_run(run, model_hint=body.get("model"))
run.refresh_from_db()
return JsonResponse(_book_run_payload(run))
else:
raise ValueError("unsupported action")
except (RuntimeError, TypeError, ValueError, ValidationError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
state.refresh_from_db()
return JsonResponse(_book_state_payload(state))
@require_http_methods(["GET", "POST"])
def scene_ideas(request: HttpRequest) -> JsonResponse:
if request.method == "GET":
ideas = SceneIdeation.objects.select_related("work__series").order_by("-created_at")[:100]
return JsonResponse({"ideas": [_idea_payload(idea) for idea in ideas]})
try:
body = _json_body(request)
required = ["series_slug", "work_slug", "target_book"]
missing = [field for field in required if not str(body.get(field) or "").strip()]
if missing:
raise ValueError("missing fields: " + ", ".join(missing))
work = Work.objects.filter(
series__slug=body["series_slug"], slug=body["work_slug"]
).first()
if work is None:
return JsonResponse({"error": "work not found"}, status=404)
book_state = None
if body.get("book_state_id"):
book_state = BookStateVersion.objects.filter(id=body["book_state_id"]).first()
if book_state is None:
return JsonResponse({"error": "book state not found"}, status=404)
idea = ideation_service().propose(
work=work,
target_book=str(body["target_book"]),
focus=str(body.get("focus") or ""),
candidate_count=int(body.get("candidate_count") or 10),
scene_types=list(body.get("scene_types") or []) or None,
authorities=list(body.get("authorities") or []) or None,
pinned_document_keys=list(body.get("pinned_document_keys") or []),
governing_document_keys=list(body.get("governing_document_keys") or []),
detail_level=str(body.get("detail_level") or "full"),
model_hint=body.get("model"),
book_state=book_state,
)
except (RuntimeError, TypeError, ValueError, ValidationError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
return JsonResponse(_idea_payload(idea), status=201)
@require_http_methods(["GET"])
def scene_idea_detail(request: HttpRequest, idea_id) -> JsonResponse:
idea = _idea(idea_id)
if idea is None:
return JsonResponse({"error": "scene ideation not found"}, status=404)
return JsonResponse(_idea_payload(idea))
@require_http_methods(["POST"])
def scene_idea_action(request: HttpRequest, idea_id) -> JsonResponse:
idea = _idea(idea_id)
if idea is None:
return JsonResponse({"error": "scene ideation not found"}, status=404)
try:
body = _json_body(request)
action = str(body.get("action") or "").strip().replace("-", "_")
if action != "select":
raise ValueError("unsupported action")
candidate_id = str(body.get("candidate_id") or "").strip()
if not candidate_id:
raise ValueError("candidate_id is required")
scene, created = ideation_service().select_candidate(
idea,
candidate_id=candidate_id,
target_words=(
int(body["target_words"]) if body.get("target_words") is not None else None
),
book_chapter_key=body.get("chapter_key"),
)
idea.refresh_from_db()
except (RuntimeError, TypeError, ValueError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
return JsonResponse(
{"idea": _idea_payload(idea), "scene": _payload(scene), "created": created},
status=201 if created else 200,
)
@require_http_methods(["GET", "POST"])
def standalone_scenes(request: HttpRequest) -> JsonResponse:
if request.method == "GET":
scenes = StandaloneScene.objects.select_related("work__series", "book_state").order_by(
"-updated_at"
)[:100]
return JsonResponse({"scenes": [_payload(scene) for scene in scenes]})
try:
body = _json_body(request)
required = ["series_slug", "work_slug", "title", "brief"]
missing = [field for field in required if not str(body.get(field) or "").strip()]
if missing:
raise ValueError("missing fields: " + ", ".join(missing))
work = Work.objects.filter(
series__slug=body["series_slug"], slug=body["work_slug"]
).first()
if work is None:
return JsonResponse({"error": "work not found"}, status=404)
book_state = None
if body.get("book_state_id"):
book_state = BookStateVersion.objects.filter(id=body["book_state_id"]).first()
if book_state is None:
return JsonResponse({"error": "book state not found"}, status=404)
scene = scene_service().create(
work=work,
title=str(body["title"]),
brief=str(body["brief"]),
target_words=int(body.get("target_words") or 1800),
constraints=list(body.get("constraints") or []),
forbidden_events=list(body.get("forbidden_events") or []),
boundary_constraints=list(body.get("boundary_constraints") or []),
book_state=book_state,
book_chapter_key=body.get("chapter_key"),
)
except (TypeError, ValueError, ValidationError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
return JsonResponse(_payload(scene), status=201)
@require_http_methods(["GET"])
def standalone_scene_detail(request: HttpRequest, scene_id) -> JsonResponse:
scene = _scene(scene_id)
if scene is None:
return JsonResponse({"error": "scene not found"}, status=404)
return JsonResponse(_payload(scene, include_prose=request.GET.get("include_prose") == "1"))
@require_http_methods(["POST"])
def standalone_scene_action(request: HttpRequest, scene_id) -> JsonResponse:
scene = _scene(scene_id)
if scene is None:
return JsonResponse({"error": "scene not found"}, status=404)
try:
body = _json_body(request)
action = str(body.get("action") or "").strip().replace("-", "_")
service = scene_service()
if action == "context":
service.prepare_context(
scene,
authorities=list(body.get("authorities") or []) or None,
pinned_document_keys=list(body.get("pinned_document_keys") or []),
)
elif action == "plan":
service.plan(
scene,
authorities=list(body.get("authorities") or []) or None,
pinned_document_keys=list(body.get("pinned_document_keys") or []),
model_hint=body.get("model"),
)
elif action == "approve_plan":
service.approve_plan(scene)
elif action == "write":
service.write(
scene,
model_hint=body.get("model"),
max_attempts=int(body.get("max_attempts") or 2),
)
elif action == "review":
service.review(scene, model_hint=body.get("model"))
elif action == "approve":
service.approve(
scene,
actor=str(body.get("actor") or "api"),
force=_json_bool(body, "force"),
)
elif action == "reject":
service.reject(scene, actor=str(body.get("actor") or "api"))
else:
raise ValueError("unsupported action")
except (RuntimeError, TypeError, ValueError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
scene.refresh_from_db()
return JsonResponse(_payload(scene))