483 lines
20 KiB
Python
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))
|