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))