from __future__ import annotations import json import re from pathlib import Path from typing import Any from django.conf import settings from django.db import transaction from django.db.models import Max, Q from django.utils import timezone from control_plane.authoring.epub import write_epub from control_plane.authoring.models import ( CanonSnapshot, Chapter, ChapterContract, ChapterRevision, ChapterStateDocument, ChapterStatus, EditorialFinding, FindingSeverity, FindingStatus, GenerationContextSnapshot, PromptVersion, RequirementCheck, RequirementStatus, RevisionStatus, StateChange, StateChangeStatus, StateDocumentStatus, StateOperation, StoryEntity, StoryProject, text_sha256, ) from control_plane.authoring.prompts import ( DEFAULT_CONTINUITY_TEMPLATE, DEFAULT_DRAFT_SYSTEM, DEFAULT_FINAL_STATE_TEMPLATE, DEFAULT_FULL_CHAPTER_DRAFT_TEMPLATE, DEFAULT_PATCH_REVISION_TEMPLATE, DEFAULT_PLAN_SYSTEM, DEFAULT_PLAN_TEMPLATE, DEFAULT_QUALITY_REVIEW_TEMPLATE, DEFAULT_REPAIR_PLAN_TEMPLATE, DEFAULT_REVIEW_TEMPLATE, DEFAULT_REVISION_TEMPLATE, DEFAULT_STATE_JUDGE_TEMPLATE, DEFAULT_TARGETED_VERIFICATION_TEMPLATE, ) from control_plane.authoring.state_management import ( apply_state_changes, build_contract_requirements, evidence_is_present, json_sha256, normalize_entity_key, render_state_markdown, requirement_is_blocking, ) from control_plane.authoring.streaming import ResumableDraftWriter, atomic_write_text from graph.models import GraphApproval, GraphApprovalStatus from model_router.providers import ProviderError, extract_json_object from model_router.router import ModelCapability, ModelRequestContract, ModelRouter def compact_scene_contract(scene: dict[str, Any], maximum_beats: int = 3) -> dict[str, Any]: compact = { key: scene[key] for key in ("number", "purpose", "location", "present", "ending_state") if key in scene } beats = [beat if isinstance(beat, dict) else {"text": str(beat)} for beat in scene.get("beats") or []] required = [beat for beat in beats if beat.get("required", True)] if len(required) <= maximum_beats: compact["beats"] = required return compact consolidated = [] for index in range(maximum_beats): start = index * len(required) // maximum_beats end = (index + 1) * len(required) // maximum_beats group = required[start:end] consolidated.append( { "text": " ".join(str(beat.get("text") or "") for beat in group), "required": True, "source_beat_count": len(group), } ) compact["beats"] = consolidated return compact def deterministic_temporal_findings( prose: str, scene_plan: dict[str, Any] | None = None ) -> list[dict[str, Any]]: settlement_offset = prose.find("The bids were opened") if settlement_offset < 0: return [] findings = [] for match in re.finditer( r"[^.!?\n]*(?:had become|was already|was now) (?:rich|wealthy|a millionaire)[^.!?\n]*[.!?]", prose[:settlement_offset], flags=re.IGNORECASE, ): evidence = match.group(0).strip() replacement = re.sub( r"had become wealthy", "might soon become wealthy", evidence, flags=re.IGNORECASE, ) findings.append( { "severity": FindingSeverity.HIGH, "category": "premature_state", "location": "before bidding and settlement", "evidence_quote": evidence, "description": "Wealth cannot be established before bidding and settlement.", "suggested_revision": replacement, "objective": True, "exact_patch_suitable": True, "source": "deterministic", } ) exact_values = (scene_plan or {}).get("exact_values") or [] payout = None for value in exact_values: text = str(value) if "finder share" not in text.lower() and "corin receives" not in text.lower(): continue match = re.search(r"(?:exactly|is)\s+([\d,]+)\s+(?:silver\s+)?crowns", text, re.IGNORECASE) if match: payout = int(match.group(1).replace(",", "")) break aliases = [] if payout: aliases.extend([f"{payout:,} crowns", f"{payout / 1_000_000:g} million crowns"]) whole_millions, remainder = divmod(payout, 1_000_000) number_words = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"] if remainder == 500_000 and 0 < whole_millions < len(number_words): aliases.append(f"{number_words[whole_millions]} and a half million crowns") for alias in aliases: match = re.search( rf"[^.!?\n]*\b{re.escape(alias)}\b[^.!?\n]*[.!?]", prose[:settlement_offset], flags=re.IGNORECASE, ) if not match: continue evidence = match.group(0).strip() replacement = re.sub(re.escape(alias), "The sale proceeds", evidence, flags=re.IGNORECASE) findings.append( { "severity": FindingSeverity.HIGH, "category": "premature_exact_value", "location": "before bidding and settlement", "evidence_quote": evidence, "description": "Exact finder proceeds cannot be known before bidding and settlement.", "suggested_revision": replacement, "objective": True, "exact_patch_suitable": True, "source": "deterministic", } ) break return findings def scene_draft_packet(scene_plan: dict[str, Any], scene: dict[str, Any]) -> dict[str, Any]: scenes = scene_plan.get("scenes") or [] target_words = int(scene_plan.get("target_words") or 5500) default_scene_words = max(800, target_words // max(1, len(scenes))) return { "chapter_scope": { key: scene_plan.get(key) for key in ( "day_start", "day_end", "exact_values", "forbidden_events", "chapter_constraints", "final_image", ) if scene_plan.get(key) }, "scene": compact_scene_contract(scene), "target_words": int(scene.get("word_budget") or default_scene_words), } def compact_chapter_plan(scene_plan: dict[str, Any]) -> dict[str, Any]: packet = scene_draft_packet(scene_plan, {}) return { **packet["chapter_scope"], "target_words": int(scene_plan.get("target_words") or 5500), "scenes": [compact_scene_contract(scene) for scene in scene_plan.get("scenes") or []], } def patch_change_ratio(prose: str, edits: list[dict[str, Any]]) -> float: if not prose: return 1.0 return sum( max(len(str(edit.get("old_text") or "")), len(str(edit.get("new_text") or ""))) for edit in edits ) / len(prose) def apply_exact_edits( prose: str, edits: list[dict[str, Any]], *, max_change_ratio: float = 1.0, ) -> str: if not edits: raise ValueError("patch response contains no edits") spans: list[tuple[int, int, str]] = [] for index, edit in enumerate(edits, start=1): old_text = str(edit.get("old_text") or "") new_text = str(edit.get("new_text") or "") if not old_text or not new_text or old_text == new_text: raise ValueError(f"patch edit {index} is empty or unchanged") occurrences = prose.count(old_text) if occurrences != 1: raise ValueError( f"patch edit {index} old_text must occur exactly once; found {occurrences}" ) start = prose.index(old_text) spans.append((start, start + len(old_text), new_text)) ordered = sorted(spans) for previous, current in zip(ordered, ordered[1:], strict=False): if current[0] < previous[1]: raise ValueError("patch edits overlap in the original chapter") ratio = patch_change_ratio(prose, edits) if ratio > max_change_ratio: raise ValueError( f"patch changes {ratio:.1%} of the chapter; limit is {max_change_ratio:.1%}" ) revised = prose for start, end, new_text in reversed(ordered): revised = revised[:start] + new_text + revised[end:] return revised class DjangoStoryWorkflowServices: def __init__(self, router: ModelRouter) -> None: self.router = router self.writer = ResumableDraftWriter(router) def build_context(self, state: dict[str, Any]) -> dict[str, Any]: revision = self._revision(state) chapter = revision.chapter pinned_canon_id = (revision.generation_metadata or {}).get("pinned_prior_canon_id") if pinned_canon_id: prior_canon = CanonSnapshot.objects.filter( id=pinned_canon_id, story=chapter.story, through_chapter__lt=chapter.number, ).first() if prior_canon is None: raise RuntimeError("pinned prior canon is unavailable or invalid for this chapter") else: prior_canon = ( CanonSnapshot.objects.filter( story=chapter.story, through_chapter__lt=chapter.number ) .order_by("-through_chapter", "-version") .first() ) previous = ( Chapter.objects.filter(story=chapter.story, number__lt=chapter.number) .exclude(current_revision=None) .select_related("current_revision") .order_by("-number") .first() ) chapter_outline = self._chapter_outline(revision) content = { "chapter": { "number": chapter.number, "title": chapter.title, "outline": chapter_outline, }, "story_bible": revision.story_bible.content, "structured_canon": revision.story_bible.structured_canon, "prior_canon": prior_canon.state if prior_canon else {}, "previous_chapter_tail": self._tail( previous.current_revision.prose if previous and previous.current_revision else "" ), "source_revision": ( revision.source_revision.prose if revision.source_revision_id else revision.parent.prose if revision.parent_id else "" ), } canonical = json.dumps(content, ensure_ascii=False, sort_keys=True, separators=(",", ":")) snapshot = GenerationContextSnapshot.objects.create( story=chapter.story, chapter=chapter, story_bible=revision.story_bible, outline=revision.outline, prior_canon=prior_canon, content=content, sha256=text_sha256(canonical), ) revision.context_snapshot = snapshot revision.save(update_fields=["context_snapshot", "updated_at"]) return {"context_snapshot_id": str(snapshot.id)} def plan_chapter(self, state: dict[str, Any]) -> dict[str, Any]: revision = self._revision(state) context = self._context(state, revision) human_notes = str(state.get("human_notes") or "") if revision.scene_plan and not human_notes: self._ensure_contract(revision) return {"scene_plan": revision.scene_plan} prompt = self._render_prompt( "STORY_PLAN", DEFAULT_PLAN_SYSTEM, DEFAULT_PLAN_TEMPLATE, chapter_number=revision.chapter.number, chapter_title=revision.chapter.title, story_bible=context["story_bible"], chapter_outline=json.dumps(context["chapter"]["outline"], ensure_ascii=False, indent=2), prior_canon=json.dumps(context["prior_canon"], ensure_ascii=False, indent=2), source_prose=context.get("source_revision", ""), human_notes=human_notes, ) request = ModelRequestContract( purpose=ModelCapability.STORY_PLANNING, prompt=prompt, model_hint="terra", token_budget=5000, project=revision.chapter.story.project, ) response = self.router.complete(request) plan = extract_json_object(response.content) if not isinstance(plan.get("scenes"), list) or not plan["scenes"]: raise RuntimeError("story planner returned no scenes") revision.scene_plan = plan revision.generation_metadata = { **revision.generation_metadata, "planning_model": response.model, } revision.save(update_fields=["scene_plan", "generation_metadata", "updated_at"]) self._ensure_contract(revision) return {"scene_plan": plan, "human_notes": ""} def draft_chapter(self, state: dict[str, Any]) -> dict[str, Any]: revision = self._revision(state) if revision.prose: return {"revision_id": str(revision.id)} context = self._context(state, revision) scenes = revision.scene_plan.get("scenes") or [] if not scenes: raise ValueError("approved scene plan has no scenes") previous_chapter = ( Chapter.objects.select_related("current_revision") .filter(story=revision.chapter.story, number=revision.chapter.number - 1) .first() ) source_chapter = ( previous_chapter.current_revision.prose if previous_chapter and previous_chapter.current_revision else "[opening chapter; no previous chapter]" ) prompt = self._render_prompt( "STORY_CHAPTER_PROSE", DEFAULT_DRAFT_SYSTEM, DEFAULT_FULL_CHAPTER_DRAFT_TEMPLATE, chapter_number=revision.chapter.number, chapter_title=revision.chapter.title, source_chapter=source_chapter, structured_canon=json.dumps(context["structured_canon"], ensure_ascii=False, indent=2), scene_plan=json.dumps(compact_chapter_plan(revision.scene_plan), ensure_ascii=False, indent=2), ) partial_path = self._partial_path(revision) result = self.writer.generate( request=ModelRequestContract( purpose=ModelCapability.STORY_PROSE, prompt=prompt, model_hint="terra", token_budget=15000, project=revision.chapter.story.project, ), partial_path=partial_path, minimum_words=4000, maximum_words=8000, completion_marker="[[END_OF_CHAPTER]]", max_attempts=1, ) revision.prose = result.text atomic_write_text(partial_path, revision.prose) revision.artifact_uri = str(partial_path) revision.status = RevisionStatus.REVIEW revision.generation_metadata = { **revision.generation_metadata, "draft_attempts": result.attempts, "resumed": result.resumed, "draft_model": "terra", "draft_mode": "full_chapter", } revision.save() revision.chapter.status = ChapterStatus.REVIEW revision.chapter.save(update_fields=["status", "updated_at"]) return {"revision_id": str(revision.id)} def quality_review(self, state: dict[str, Any]) -> dict[str, Any]: revision = self._revision(state) previous = ( Chapter.objects.select_related("current_revision") .filter(story=revision.chapter.story, number=revision.chapter.number - 1) .first() ) prompt = self._render_prompt( "STORY_QUALITY_REVIEW", "You are a rigorous developmental and continuity editor. Return strict JSON only.", DEFAULT_QUALITY_REVIEW_TEMPLATE, previous_chapter=( previous.current_revision.prose if previous and previous.current_revision else "[opening chapter]" ), scene_plan=json.dumps(revision.scene_plan, ensure_ascii=False, indent=2), prose=revision.prose, ) response = self.router.complete( ModelRequestContract( purpose=ModelCapability.STORY_REVIEW, prompt=prompt, model_hint="terra", token_budget=5000, project=revision.chapter.story.project, ) ) reviewed = extract_json_object(response.content) revision.findings.filter( review_kind="quality_finish", status=FindingStatus.OPEN ).update(status=FindingStatus.RESOLVED) finding_ids: list[str] = [] allowed_severities = { FindingSeverity.MEDIUM, FindingSeverity.HIGH, FindingSeverity.CRITICAL, } occupied_ranges: list[tuple[int, int]] = [] for item in (reviewed.get("findings") or [])[:6]: quote = str(item.get("evidence_quote") or "") replacement = str(item.get("suggested_revision") or "") if not quote or revision.prose.count(quote) != 1 or not replacement: continue start = revision.prose.index(quote) end = start + len(quote) if any(start < right and left < end for left, right in occupied_ranges): continue occupied_ranges.append((start, end)) severity = str(item.get("severity") or FindingSeverity.MEDIUM).upper() if severity not in allowed_severities: severity = FindingSeverity.MEDIUM finding = EditorialFinding.objects.create( revision=revision, review_kind="quality_finish", severity=severity, category=str(item.get("category") or "quality")[:80], location=str(item.get("location") or "")[:255], description=str(item.get("description") or "Material chapter defect"), suggested_revision=replacement, evidence={ "quote": quote, "blocking": severity in [FindingSeverity.HIGH, FindingSeverity.CRITICAL], "objective": True, "exact_patch_suitable": bool(item.get("exact_patch_suitable", True)), }, model_metadata={"model": response.model, "review_phase": "quality_finish"}, ) finding_ids.append(str(finding.id)) return {"editorial_finding_ids": finding_ids, "quality_finding_count": len(finding_ids)} def extract_continuity(self, state: dict[str, Any]) -> dict[str, Any]: return self._extract_state(state, final=False) def extract_final_state(self, state: dict[str, Any]) -> dict[str, Any]: return self._extract_state(state, final=True) def _extract_state(self, state: dict[str, Any], *, final: bool) -> dict[str, Any]: revision = self._revision(state) context = self._context(state, revision) prompt = self._render_prompt( "STORY_FINAL_STATE" if final else "STORY_CONTINUITY", "Return strict factual JSON only. Never repair or invent story facts.", DEFAULT_FINAL_STATE_TEMPLATE if final else DEFAULT_CONTINUITY_TEMPLATE, chapter_number=revision.chapter.number, scene_plan=json.dumps(revision.scene_plan, ensure_ascii=False, indent=2), prior_canon=json.dumps(context["prior_canon"], ensure_ascii=False, indent=2), prose=revision.prose, ) request = ModelRequestContract( purpose=ModelCapability.STORY_CONTINUITY, prompt=prompt, model_hint="luna", token_budget=7000 if final else 12000, project=revision.chapter.story.project, ) response = self.router.complete(request) extracted = extract_json_object(response.content) observed_state = extracted.get("state_document") or {} proposed_delta = extracted.get("changes") or [] objective_findings = deterministic_temporal_findings(revision.prose, revision.scene_plan) if not final: objective_findings.extend(extracted.get("objective_findings") or []) if not isinstance(observed_state, dict) or not isinstance(proposed_delta, list): raise RuntimeError("continuity extraction returned an invalid state document") if not isinstance(objective_findings, list): raise RuntimeError("continuity extraction returned invalid objective findings") contract = self._ensure_contract(revision) state_document, _ = ChapterStateDocument.objects.update_or_create( revision=revision, defaults={ "contract": contract, "status": StateDocumentStatus.EXTRACTED, "start_state": context["prior_canon"], "observed_state": observed_state, "proposed_delta": proposed_delta, "coverage": {}, "verdict": "", "sha256": json_sha256(observed_state), "model_metadata": {"model": response.model}, "validated_at": None, "committed_at": None, }, ) state_document.changes.all().delete() state_document.requirement_checks.all().delete() normalized_delta = self._persist_state_changes(state_document, proposed_delta) state_document.proposed_delta = normalized_delta state_document.save(update_fields=["proposed_delta", "updated_at"]) revision.continuity_state = observed_state revision.generation_metadata = { **revision.generation_metadata, "continuity_model": response.model, } revision.save(update_fields=["continuity_state", "generation_metadata", "updated_at"]) revision.findings.filter( review_kind="continuity_audit", status=FindingStatus.OPEN ).update(status=FindingStatus.RESOLVED) finding_ids = [] allowed_severities = { FindingSeverity.MEDIUM, FindingSeverity.HIGH, FindingSeverity.CRITICAL, } occupied_ranges: list[tuple[int, int]] = [] for item in objective_findings[:8]: evidence_quote = str(item.get("evidence_quote") or "") if not evidence_quote or revision.prose.count(evidence_quote) != 1: continue start = revision.prose.index(evidence_quote) end = start + len(evidence_quote) if any(start < occupied_end and occupied_start < end for occupied_start, occupied_end in occupied_ranges): continue occupied_ranges.append((start, end)) severity = str(item.get("severity") or FindingSeverity.MEDIUM).upper() if severity not in allowed_severities: severity = FindingSeverity.MEDIUM finding = EditorialFinding.objects.create( revision=revision, review_kind="continuity_audit", severity=severity, category=str(item.get("category") or "continuity")[:80], location=str(item.get("location") or "")[:255], description=str(item.get("description") or "Objective continuity defect"), suggested_revision=str(item.get("suggested_revision") or ""), evidence={ "quote": evidence_quote, "objective": bool(item.get("objective", True)), "exact_patch_suitable": bool(item.get("exact_patch_suitable", True)), "blocking": True, }, model_metadata={ "model": response.model, "source": item.get("source") or "combined_continuity_audit", }, ) finding_ids.append(str(finding.id)) self._write_state_artifacts(state_document) return { "state_document_id": str(state_document.id), "editorial_finding_ids": finding_ids, "objective_finding_count": len(finding_ids), } def finalize_combined_audit(self, state: dict[str, Any]) -> dict[str, Any]: revision = self._revision(state) state_document = ChapterStateDocument.objects.get(revision=revision) revision.findings.filter( review_kind="state_contract", status=FindingStatus.OPEN ).update(status=FindingStatus.RESOLVED) failed = False for error in self._validate_state_change_evidence(state_document, revision.prose): failed = True EditorialFinding.objects.create( revision=revision, review_kind="state_contract", severity=FindingSeverity.HIGH, category="state_change", location=error.get("location", ""), description=error["description"], suggested_revision="Correct or remove the unsupported state transition.", evidence={**error, "blocking": True, "objective": True}, model_metadata={"source": "deterministic"}, ) if not state_document.proposed_delta: failed = True EditorialFinding.objects.create( revision=revision, review_kind="state_contract", severity=FindingSeverity.HIGH, category="state_change", description="The chapter state extraction produced no trackable state changes.", suggested_revision="Extract the chapter's material state changes.", evidence={"blocking": True, "objective": True}, model_metadata={"source": "deterministic"}, ) open_findings = revision.findings.filter(status=FindingStatus.OPEN) failed = failed or open_findings.filter( severity__in=[FindingSeverity.HIGH, FindingSeverity.CRITICAL] ).exists() verdict = "FAIL" if failed else "PASS" state_document.coverage = { "counts": { "objective_findings": open_findings.filter( review_kind__in=["continuity_audit", "state_contract"] ).count() } } state_document.verdict = verdict state_document.status = ( StateDocumentStatus.NEEDS_REVISION if failed else StateDocumentStatus.VALIDATED ) state_document.validated_at = None if failed else timezone.now() state_document.model_metadata = { **state_document.model_metadata, "validation": "combined_luna_and_deterministic", } state_document.save() state_document.changes.exclude(status=StateChangeStatus.REJECTED).update( status=StateChangeStatus.PROPOSED if failed else StateChangeStatus.VALIDATED ) self._write_state_artifacts(state_document) finding_ids = [str(value) for value in open_findings.values_list("id", flat=True)] return { "state_document_id": str(state_document.id), "state_judge_status": "revise" if failed else "pass", "editorial_finding_ids": finding_ids, } def judge_state_contract(self, state: dict[str, Any]) -> dict[str, Any]: revision = self._revision(state) contract = self._ensure_contract(revision) state_document = ChapterStateDocument.objects.get(revision=revision) state_document.changes.filter(metadata__source="judge").delete() state_document.proposed_delta = [ item for item in state_document.proposed_delta if not isinstance(item, dict) or item.get("source") != "judge" ] invalid_sequences: set[int] = set() extraction_changes = state_document.changes.filter( Q(metadata__source="extraction") | Q(metadata__source__isnull=True) ) for change in extraction_changes: if evidence_is_present(revision.prose, change.evidence_quote): change.status = StateChangeStatus.PROPOSED else: change.status = StateChangeStatus.REJECTED invalid_sequences.add(change.sequence) change.save(update_fields=["status"]) state_document.proposed_delta = [ item for item in state_document.proposed_delta if not isinstance(item, dict) or item.get("sequence") not in invalid_sequences ] state_document.save(update_fields=["proposed_delta", "updated_at"]) revision.findings.filter( review_kind__in=["state_contract", "story_audit"], status=FindingStatus.OPEN, ).update(status=FindingStatus.RESOLVED) prompt = self._render_prompt( "STORY_STATE_JUDGE", "You are the final story contract judge. Return strict JSON only.", DEFAULT_STATE_JUDGE_TEMPLATE, contract=json.dumps(contract.requirements, ensure_ascii=False, indent=2), prior_state=json.dumps(state_document.start_state, ensure_ascii=False, indent=2), observed_state=json.dumps(state_document.observed_state, ensure_ascii=False, indent=2), proposed_delta=json.dumps(state_document.proposed_delta, ensure_ascii=False, indent=2), scene_plan=json.dumps(revision.scene_plan, ensure_ascii=False, indent=2), prose=revision.prose, ) request = ModelRequestContract( purpose=ModelCapability.STORY_REVIEW, prompt=prompt, model_hint="terra", token_budget=max(6000, len(contract.requirements) * 120), project=revision.chapter.story.project, ) response = self.router.complete(request) judged = extract_json_object(response.content) missing_changes = judged.get("missing_state_changes") or [] if isinstance(missing_changes, list) and missing_changes: appended = self._persist_state_changes( state_document, missing_changes, start_sequence=state_document.changes.count() + 1, source="judge", ) state_document.proposed_delta = [*state_document.proposed_delta, *appended] state_document.save(update_fields=["proposed_delta", "updated_at"]) returned = { str(item.get("requirement_id") or ""): item for item in judged.get("requirements") or [] if item.get("requirement_id") } state_document.requirement_checks.all().delete() finding_ids: list[str] = [] coverage: list[dict[str, Any]] = [] allowed_statuses = set(RequirementStatus.values) failed = False for requirement in contract.requirements: requirement_id = requirement["id"] item = returned.get(requirement_id) or { "status": RequirementStatus.MISSED, "details": "Judge omitted this frozen requirement.", } status = str(item.get("status") or RequirementStatus.UNVERIFIABLE).upper() if status not in allowed_statuses: status = RequirementStatus.UNVERIFIABLE quote = str(item.get("evidence_quote") or "").strip() details = str(item.get("details") or "") if status in [RequirementStatus.HIT, RequirementStatus.PARTIAL] and not evidence_is_present( revision.prose, quote ): status = RequirementStatus.UNVERIFIABLE details = (details + " Evidence quote is absent from the chapter.").strip() check = RequirementCheck.objects.create( state_document=state_document, requirement_id=requirement_id, requirement_type=requirement["type"], requirement_text=requirement["text"], status=status, severity=requirement["severity"], evidence_quote=quote, evidence_location=str(item.get("evidence_location") or "")[:255], details=details, model_metadata={"model": response.model}, ) coverage.append( { "requirement_id": check.requirement_id, "requirement_type": check.requirement_type, "requirement_text": check.requirement_text, "status": check.status, "severity": check.severity, "evidence_quote": check.evidence_quote, "evidence_location": check.evidence_location, "details": check.details, "blocking": requirement_is_blocking(requirement), } ) if status != RequirementStatus.HIT: blocking = requirement_is_blocking(requirement) failed = failed or blocking finding_severity = ( FindingSeverity.MEDIUM if not blocking or status == RequirementStatus.PARTIAL else requirement["severity"] ) finding = EditorialFinding.objects.create( revision=revision, review_kind="state_contract", severity=finding_severity, category=f"contract:{requirement['type']}"[:80], location=check.evidence_location, description=f"{requirement_id} is {status}: {requirement['text']}. {details}".strip(), suggested_revision="Revise the chapter so this frozen requirement is explicitly satisfied.", evidence={ "quote": quote, "requirement_id": requirement_id, "blocking": blocking, "objective": True, "exact_patch_suitable": True, }, model_metadata={"model": response.model, "review_phase": "initial"}, ) finding_ids.append(str(finding.id)) allowed_severities = set(FindingSeverity.values) for item in (judged.get("findings") or [])[:8]: severity = str(item.get("severity") or FindingSeverity.INFO).upper() if severity not in allowed_severities: severity = FindingSeverity.INFO quote = str(item.get("evidence_quote") or "").strip() objective = bool(item.get("objective")) and severity != FindingSeverity.LOW patch_suitable = bool(item.get("exact_patch_suitable")) and objective finding = EditorialFinding.objects.create( revision=revision, review_kind="story_audit", severity=severity, category=str(item.get("category") or "editorial")[:80], location=str(item.get("location") or "")[:255], description=str(item.get("description") or "No description supplied"), suggested_revision=str(item.get("suggested_revision") or ""), evidence={ "quote": quote, "blocking": objective and severity in [FindingSeverity.HIGH, FindingSeverity.CRITICAL], "objective": objective, "exact_patch_suitable": patch_suitable, }, model_metadata={"model": response.model, "review_phase": "initial"}, ) finding_ids.append(str(finding.id)) change_errors = self._validate_state_change_evidence(state_document, revision.prose) for error in change_errors: failed = True finding = EditorialFinding.objects.create( revision=revision, review_kind="state_contract", severity=FindingSeverity.HIGH, category="state_change", location=error.get("location", ""), description=error["description"], suggested_revision="Correct or remove the unsupported state transition.", evidence=error, model_metadata={"model": response.model}, ) finding_ids.append(str(finding.id)) if not state_document.proposed_delta: failed = True finding = EditorialFinding.objects.create( revision=revision, review_kind="state_contract", severity=FindingSeverity.HIGH, category="state_change", description="The chapter state extraction produced no trackable state changes.", suggested_revision="Extract the chapter's character, item, money, and timeline changes.", model_metadata={"model": response.model}, ) finding_ids.append(str(finding.id)) verdict = "FAIL" if failed else "PASS" state_document.coverage = { "requirements": coverage, "counts": { status: sum(1 for item in coverage if item["status"] == status) for status in RequirementStatus.values }, } state_document.verdict = verdict state_document.status = ( StateDocumentStatus.NEEDS_REVISION if verdict == "FAIL" else StateDocumentStatus.VALIDATED ) state_document.validated_at = timezone.now() if verdict == "PASS" else None state_document.model_metadata = { **state_document.model_metadata, "judge_model": response.model, } state_document.save() state_document.changes.exclude(status=StateChangeStatus.REJECTED).update( status=( StateChangeStatus.VALIDATED if verdict == "PASS" else StateChangeStatus.PROPOSED ) ) self._write_state_artifacts(state_document) return { "state_document_id": str(state_document.id), "state_judge_status": "revise" if verdict == "FAIL" else "pass", "editorial_finding_ids": finding_ids, } def decide_patch(self, state: dict[str, Any]) -> dict[str, Any]: if state.get("patch_attempted"): return {"patch_decision": "human_review", "patch_finding_ids": []} revision = self._revision(state) candidates: list[str] = [] for finding in revision.findings.filter(status=FindingStatus.OPEN).order_by("created_at"): evidence = finding.evidence or {} objective = bool(evidence.get("objective")) suitable = bool(evidence.get("exact_patch_suitable")) if finding.review_kind == "state_contract" and not evidence.get("blocking"): continue severity_ok = finding.severity in [ FindingSeverity.MEDIUM, FindingSeverity.HIGH, FindingSeverity.CRITICAL, ] if objective and suitable and severity_ok and finding.category != "state_change": candidates.append(str(finding.id)) return { "patch_decision": "patch" if candidates else "human_review", "patch_finding_ids": candidates[:8], } def apply_automatic_patch(self, state: dict[str, Any]) -> dict[str, Any]: current = self._revision(state) finding_ids = state.get("patch_finding_ids") or [] findings = list( current.findings.filter(id__in=finding_ids, status=FindingStatus.OPEN).values( "id", "severity", "category", "location", "description", "suggested_revision" ) ) if not findings: return {"patch_attempted": True, "patch_status": "failed"} base_prompt = self._render_prompt( "STORY_PATCH_REVISION", "You are a precise fiction copy editor. Return strict JSON only.", DEFAULT_PATCH_REVISION_TEMPLATE, findings=json.dumps(findings, ensure_ascii=False, indent=2, default=str), human_notes="", prose=current.prose, ) try: response = self.router.complete( ModelRequestContract( purpose=ModelCapability.STORY_REVISION, prompt=base_prompt, model_hint="luna", token_budget=4000, project=current.chapter.story.project, ) ) patch = extract_json_object(response.content) edits = patch.get("edits") or [] revised_prose = apply_exact_edits(current.prose, edits, max_change_ratio=0.05) except (ProviderError, ValueError): return { "patch_attempted": True, "patch_status": "failed", "patch_source_revision_id": str(current.id), } revised = ChapterRevision.objects.create( chapter=current.chapter, revision=(current.chapter.revisions.aggregate(value=Max("revision"))["value"] or 0) + 1, status=RevisionStatus.REVIEW, parent=current, source_revision=current.source_revision, story_bible=current.story_bible, outline=current.outline, context_snapshot=current.context_snapshot, scene_plan=current.scene_plan, graph_thread_id=current.graph_thread_id, prose=revised_prose, ) changed_passages = [ { "old_text": str(edit.get("old_text") or ""), "new_text": str(edit.get("new_text") or ""), } for edit in edits ] ratio = patch_change_ratio(current.prose, edits) revised.artifact_uri = str(self._partial_path(revised)) revised.generation_metadata = { "revision_mode": "bounded_exact_patch", "graph_run_id": state.get("graph_run_id"), "base_revision_id": str(current.id), "finding_ids": [str(item["id"]) for item in findings], "patch_generation_attempts": 1, "patch_count": len(edits), "patch_change_ratio": ratio, "changed_passages": changed_passages, "model": response.model, } atomic_write_text(Path(revised.artifact_uri), revised.prose) revised.save() current.findings.filter(id__in=finding_ids).update(status=FindingStatus.RESOLVED) return { "revision_id": str(revised.id), "patch_attempted": True, "patch_status": "applied", "patch_source_revision_id": str(current.id), "patch_change_ratio": ratio, "changed_passages": changed_passages, } def verify_patch(self, state: dict[str, Any]) -> dict[str, Any]: revision = self._revision(state) parent = ChapterRevision.objects.get(id=state["patch_source_revision_id"]) contract = self._ensure_contract(revision) state_document = ChapterStateDocument.objects.get(revision=revision) parent_document = ChapterStateDocument.objects.get(revision=parent) findings = list( parent.findings.filter(id__in=state.get("patch_finding_ids") or []).values( "id", "severity", "category", "location", "description", "suggested_revision" ) ) finding_requirement_ids = { str((finding.evidence or {}).get("requirement_id")) for finding in parent.findings.filter(id__in=state.get("patch_finding_ids") or []) if (finding.evidence or {}).get("requirement_id") } requirements_to_verify = [ item for item in contract.requirements if requirement_is_blocking(item) or item["id"] in finding_requirement_ids ] prompt = self._render_prompt( "STORY_TARGETED_VERIFICATION", "You are a narrow regression verifier. Return strict JSON only.", DEFAULT_TARGETED_VERIFICATION_TEMPLATE, findings=json.dumps(findings, ensure_ascii=False, indent=2, default=str), requirements=json.dumps(requirements_to_verify, ensure_ascii=False, indent=2), changed_passages=json.dumps(state.get("changed_passages") or [], ensure_ascii=False, indent=2), prose=revision.prose, ) request = ModelRequestContract( purpose=ModelCapability.STORY_REVIEW, prompt=prompt, model_hint="luna", token_budget=max(4000, len(requirements_to_verify) * 100), project=revision.chapter.story.project, ) response = self.router.complete(request) verified = extract_json_object(response.content) requirement_results = { str(item.get("requirement_id") or ""): item for item in verified.get("requirement_results") or [] } finding_results = { str(item.get("finding_id") or ""): item for item in verified.get("finding_results") or [] } parent_checks = { check.requirement_id: check for check in parent_document.requirement_checks.all() } state_document.requirement_checks.all().delete() coverage: list[dict[str, Any]] = [] blocking_failed = False for requirement in contract.requirements: source = requirement_results.get(requirement["id"]) parent_check = parent_checks.get(requirement["id"]) status = str( (source or {}).get("status") or (parent_check.status if parent_check else RequirementStatus.UNVERIFIABLE) ).upper() if status not in set(RequirementStatus.values): status = RequirementStatus.UNVERIFIABLE quote = str( (source or {}).get("evidence_quote") or (parent_check.evidence_quote if parent_check else "") ).strip() details = str( (source or {}).get("details") or (parent_check.details if parent_check else "Verifier omitted requirement.") ) if status in [RequirementStatus.HIT, RequirementStatus.PARTIAL] and not evidence_is_present( revision.prose, quote ): status = RequirementStatus.UNVERIFIABLE details = (details + " Evidence quote is absent from the revised chapter.").strip() check = RequirementCheck.objects.create( state_document=state_document, requirement_id=requirement["id"], requirement_type=requirement["type"], requirement_text=requirement["text"], status=status, severity=requirement["severity"], evidence_quote=quote, evidence_location=str((source or {}).get("evidence_location") or "")[:255], details=details, model_metadata={"model": response.model, "review_phase": "targeted"}, ) blocking = requirement_is_blocking(requirement) blocking_failed = blocking_failed or (blocking and status != RequirementStatus.HIT) coverage.append( { "requirement_id": check.requirement_id, "requirement_type": check.requirement_type, "requirement_text": check.requirement_text, "status": check.status, "severity": check.severity, "evidence_quote": check.evidence_quote, "evidence_location": check.evidence_location, "details": check.details, "blocking": blocking, } ) unresolved_ids: list[str] = [] for finding in parent.findings.filter(id__in=state.get("patch_finding_ids") or []): result = finding_results.get(str(finding.id)) or {} if str(result.get("status") or "UNRESOLVED").upper() == "RESOLVED": finding.status = FindingStatus.RESOLVED finding.save(update_fields=["status", "updated_at"]) continue unresolved_ids.append(str(finding.id)) EditorialFinding.objects.create( revision=revision, review_kind="targeted_verification", severity=finding.severity, category=finding.category, location=finding.location, description=finding.description, suggested_revision=finding.suggested_revision, evidence={**(finding.evidence or {}), "source_finding_id": str(finding.id)}, model_metadata={"model": response.model, "review_phase": "targeted"}, ) change_errors = self._validate_state_change_evidence(state_document, revision.prose) failed = blocking_failed or bool(unresolved_ids) or bool(change_errors) or not state_document.proposed_delta verdict = "FAIL" if failed else "PASS" state_document.coverage = { "requirements": coverage, "counts": { status: sum(1 for item in coverage if item["status"] == status) for status in RequirementStatus.values }, } state_document.verdict = verdict state_document.status = ( StateDocumentStatus.NEEDS_REVISION if failed else StateDocumentStatus.VALIDATED ) state_document.validated_at = None if failed else timezone.now() state_document.model_metadata = { **state_document.model_metadata, "verification_model": response.model, "verification_scope": "targeted", } state_document.save() state_document.changes.exclude(status=StateChangeStatus.REJECTED).update( status=StateChangeStatus.PROPOSED if failed else StateChangeStatus.VALIDATED ) self._write_state_artifacts(state_document) return { "state_document_id": str(state_document.id), "verification_status": "fail" if failed else "pass", } def review_chapter(self, state: dict[str, Any], review_kind: str) -> list[str]: revision = self._revision(state) context = self._context(state, revision) prompt = self._render_prompt( f"STORY_REVIEW_{review_kind.upper()}", "You are an independent fiction editor. Return strict JSON only.", DEFAULT_REVIEW_TEMPLATE, review_kind=review_kind, context=json.dumps(context, ensure_ascii=False, indent=2), scene_plan=json.dumps(revision.scene_plan, ensure_ascii=False, indent=2), prose=revision.prose, ) response = self.router.complete( ModelRequestContract( purpose=ModelCapability.STORY_REVIEW, prompt=prompt, model_hint="terra", token_budget=5000, project=revision.chapter.story.project, ) ) data = extract_json_object(response.content) finding_ids: list[str] = [] allowed = set(FindingSeverity.values) for item in data.get("findings") or []: severity = str(item.get("severity", "INFO")).upper() if severity not in allowed: severity = FindingSeverity.INFO finding = EditorialFinding.objects.create( revision=revision, review_kind=review_kind, severity=severity, category=str(item.get("category") or review_kind)[:80], location=str(item.get("location") or "")[:255], description=str(item.get("description") or "No description supplied"), suggested_revision=str(item.get("suggested_revision") or ""), model_metadata={"model": response.model}, ) finding_ids.append(str(finding.id)) return finding_ids def judge_chapter(self, state: dict[str, Any]) -> str: revision = self._revision(state) serious = revision.findings.filter( status="OPEN", severity__in=[FindingSeverity.HIGH, FindingSeverity.CRITICAL] ).exists() attempts = int(state.get("revision_attempt") or 0) maximum = int(state.get("max_revisions") or 2) if serious and attempts < maximum: return "revise" return "human_review" def revise_chapter(self, state: dict[str, Any]) -> dict[str, Any]: current = self._revision(state) findings = list( current.findings.filter(status="OPEN").values( "severity", "category", "location", "description", "suggested_revision" ) ) next_number = ( current.chapter.revisions.aggregate(value=Max("revision"))["value"] or 0 ) + 1 revised = ChapterRevision.objects.create( chapter=current.chapter, revision=next_number, status=RevisionStatus.DRAFT, parent=current, source_revision=current.source_revision or current, story_bible=current.story_bible, outline=current.outline, context_snapshot=current.context_snapshot, scene_plan=current.scene_plan, graph_thread_id=current.graph_thread_id, ) structural = any( finding["severity"] in [FindingSeverity.HIGH, FindingSeverity.CRITICAL] for finding in findings ) if findings and not structural: base_prompt = self._render_prompt( "STORY_PATCH_REVISION", "You are a precise fiction copy editor. Return strict JSON only.", DEFAULT_PATCH_REVISION_TEMPLATE, findings=json.dumps(findings, ensure_ascii=False, indent=2), human_notes=str(state.get("human_notes") or ""), prose=current.prose, ) try: response = self.router.complete(ModelRequestContract( purpose=ModelCapability.STORY_REVISION, prompt=base_prompt, model_hint="luna", token_budget=4000, project=current.chapter.story.project, )) patch = extract_json_object(response.content) edits = patch.get("edits") or [] revised.prose = apply_exact_edits(current.prose, edits) except (ProviderError, ValueError): revised.status = RevisionStatus.REJECTED revised.save(update_fields=["status", "updated_at"]) raise revised.artifact_uri = str(self._partial_path(revised)) revised.status = RevisionStatus.REVIEW revised.generation_metadata = { "revision_attempt": int(state.get("revision_attempt") or 0) + 1, "revision_mode": "exact_patch", "patch_count": len(edits), "model": response.model, } atomic_write_text(Path(revised.artifact_uri), revised.prose) revised.save() return { "revision_id": str(revised.id), "revision_attempt": int(state.get("revision_attempt") or 0) + 1, } context = self._context(state, current) repair_prompt = self._render_prompt( "STORY_REPAIR_PLAN", "You are a developmental story architect. Return one valid JSON object only.", DEFAULT_REPAIR_PLAN_TEMPLATE, context=json.dumps(context, ensure_ascii=False, indent=2), scene_plan=json.dumps(current.scene_plan, ensure_ascii=False, indent=2), findings=json.dumps(findings, ensure_ascii=False, indent=2), prose=current.prose, ) repair_response = self.router.complete( ModelRequestContract( purpose=ModelCapability.STORY_PLANNING, prompt=repair_prompt, model_hint="terra", token_budget=5000, project=current.chapter.story.project, ) ) repair_plan = extract_json_object(repair_response.content) revision_context = { "chapter": context["chapter"], "structured_canon": context["structured_canon"], "prior_canon": context["prior_canon"], "previous_chapter_tail": context["previous_chapter_tail"], } prompt = self._render_prompt( "STORY_REVISION", DEFAULT_DRAFT_SYSTEM, DEFAULT_REVISION_TEMPLATE, context=json.dumps(revision_context, ensure_ascii=False, indent=2), scene_plan=json.dumps(current.scene_plan, ensure_ascii=False, indent=2), findings=json.dumps(findings, ensure_ascii=False, indent=2), repair_plan=json.dumps(repair_plan, ensure_ascii=False, indent=2), prose=current.prose, ) result = self.writer.generate( request=ModelRequestContract( purpose=ModelCapability.STORY_REVISION, prompt=prompt, model_hint="terra", token_budget=15000, project=current.chapter.story.project, ), partial_path=self._partial_path(revised), ) revised.prose = result.text revised.artifact_uri = str(self._partial_path(revised)) revised.status = RevisionStatus.REVIEW revised.generation_metadata = { "revision_attempt": int(state.get("revision_attempt") or 0) + 1, "draft_attempts": result.attempts, "resumed": result.resumed, } revised.save() return { "revision_id": str(revised.id), "revision_attempt": int(state.get("revision_attempt") or 0) + 1, } def ensure_approval( self, state: dict[str, Any], gate: str, payload: dict[str, Any] ) -> GraphApproval: revision = self._revision(state) reason = f"{gate}:{revision.id}" approval = GraphApproval.objects.filter( graph_run_id=state["graph_run_id"], reason=reason, status=GraphApprovalStatus.PENDING ).first() if approval is None: approval = GraphApproval.objects.create( graph_run_id=state["graph_run_id"], reason=reason, payload=payload ) return approval def decide_approval( self, approval_id: int, decision: dict[str, Any] ) -> None: approval = GraphApproval.objects.get(id=approval_id) action = str(decision.get("action") or "").lower() approval.status = ( GraphApprovalStatus.APPROVED if action == "approve" else GraphApprovalStatus.REJECTED ) approval.decided_by = str(decision.get("actor") or "human") approval.decided_at = timezone.now() approval.payload = {**approval.payload, "decision": decision} approval.save( update_fields=["status", "decided_by", "decided_at", "payload", "updated_at"] ) if action == "approve" and approval.reason.startswith("STORY_PLAN_APPROVAL:"): revision_id = approval.payload.get("revision_id") if revision_id: contract = self._ensure_contract(ChapterRevision.objects.get(id=revision_id)) contract.approved_at = timezone.now() contract.save(update_fields=["approved_at", "updated_at"]) @transaction.atomic def commit_chapter(self, state: dict[str, Any]) -> dict[str, Any]: revision = ChapterRevision.objects.select_for_update().select_related( "chapter__story" ).get(id=state["revision_id"]) chapter = revision.chapter state_document = ChapterStateDocument.objects.select_for_update().get(revision=revision) if ( state_document.status != StateDocumentStatus.VALIDATED or state_document.verdict != "PASS" ): raise RuntimeError("chapter state document must pass contract validation before approval") if revision.findings.filter( status=FindingStatus.OPEN, severity__in=[FindingSeverity.HIGH, FindingSeverity.CRITICAL], ).exists(): raise RuntimeError("chapter has unresolved blocking editorial findings") revision.status = RevisionStatus.APPROVED revision.approved_at = timezone.now() revision.save(update_fields=["status", "approved_at", "updated_at"]) chapter.current_revision = revision chapter.status = ChapterStatus.APPROVED chapter.save(update_fields=["current_revision", "status", "updated_at"]) latest_version = ( CanonSnapshot.objects.filter(story=chapter.story).aggregate(value=Max("version"))["value"] or 0 ) prior_snapshot = ( CanonSnapshot.objects.filter(story=chapter.story, through_chapter__lt=chapter.number) .order_by("-through_chapter", "-version") .first() ) changes = [ { "sequence": change.sequence, "entity_key": change.entity.entity_key if change.entity else "book.state", "entity_kind": change.entity.kind if change.entity else "book", "canonical_name": change.entity.canonical_name if change.entity else "Book State", "related_entity_key": ( change.related_entity.entity_key if change.related_entity else "" ), "predicate": change.predicate, "operation": change.operation, "previous_value": change.previous_value, "new_value": change.new_value, } for change in state_document.changes.filter( status=StateChangeStatus.VALIDATED ).select_related("entity", "related_entity").order_by("sequence") ] state_value = apply_state_changes( prior_snapshot.state if prior_snapshot else {}, changes, through_chapter=chapter.number, chapter_state=state_document.observed_state, ) canonical = json.dumps(state_value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) snapshot = CanonSnapshot.objects.create( story=chapter.story, through_chapter=chapter.number, version=latest_version + 1, state=state_value, source_revision=revision, sha256=text_sha256(canonical), ) state_document.status = StateDocumentStatus.COMMITTED state_document.committed_at = timezone.now() state_document.save(update_fields=["status", "committed_at", "updated_at"]) state_document.changes.filter(status=StateChangeStatus.VALIDATED).update( status=StateChangeStatus.COMMITTED ) self._write_state_artifacts(state_document) return {"canon_snapshot_id": str(snapshot.id)} def publish_story(self, state: dict[str, Any]) -> str: story = StoryProject.objects.get(id=state["story_id"]) chapters = [ {"title": f"Chapter {chapter.number}: {chapter.title}", "content": chapter.current_revision.prose} for chapter in story.chapters.exclude(current_revision=None) .select_related("current_revision") .order_by("number") ] destination = self._artifact_root(story) / f"{story.slug}-current.epub" write_epub( title=story.title, series=story.series, chapters=chapters, destination=destination, ) return str(destination) def state_approval_payload(self, state: dict[str, Any]) -> dict[str, Any]: revision = self._revision(state) document = ChapterStateDocument.objects.filter(revision=revision).first() if document is None: return {} source_revision_id = state.get("patch_source_revision_id") finding_revisions = [revision.id] if source_revision_id: finding_revisions.append(source_revision_id) open_findings = EditorialFinding.objects.filter( revision_id__in=finding_revisions, status=FindingStatus.OPEN ) return { "state_document_id": str(document.id), "state_verdict": document.verdict, "state_status": document.status, "coverage_counts": (document.coverage or {}).get("counts", {}), "state_json": document.json_artifact_uri, "state_markdown": document.markdown_artifact_uri, "patch_attempted": bool(state.get("patch_attempted")), "patch_status": state.get("patch_status", "not_needed"), "patch_change_ratio": state.get("patch_change_ratio", 0), "verification_status": state.get("verification_status", "not_needed"), "open_finding_counts": { severity: open_findings.filter(severity=severity).count() for severity in FindingSeverity.values }, } def _ensure_contract(self, revision: ChapterRevision) -> ChapterContract: plan_hash = json_sha256(revision.scene_plan) requirements = build_contract_requirements(revision.scene_plan) entry_canon = None if revision.context_snapshot_id: entry_canon = revision.context_snapshot.prior_canon contract = ChapterContract.objects.filter(revision=revision).first() if contract is not None and contract.approved_at and contract.scene_plan_sha256 != plan_hash: raise RuntimeError("approved chapter contract cannot be changed") if contract is None: parent_approved_at = None if revision.parent_id: parent_approved_at = ChapterContract.objects.filter( revision=revision.parent ).values_list("approved_at", flat=True).first() return ChapterContract.objects.create( revision=revision, entry_canon=entry_canon, requirements=requirements, scene_plan_sha256=plan_hash, approved_at=parent_approved_at, ) contract.entry_canon = entry_canon contract.requirements = requirements contract.scene_plan_sha256 = plan_hash contract.save( update_fields=["entry_canon", "requirements", "scene_plan_sha256", "updated_at"] ) return contract def _persist_state_changes( self, state_document: ChapterStateDocument, proposed_delta: list[dict[str, Any]], *, start_sequence: int = 1, source: str = "extraction", ) -> list[dict[str, Any]]: revision = state_document.revision story = revision.chapter.story normalized: list[dict[str, Any]] = [] allowed_operations = set(StateOperation.values) for sequence, raw in enumerate(proposed_delta, start=start_sequence): if not isinstance(raw, dict): continue kind = str(raw.get("entity_kind") or "book")[:64] name = str(raw.get("canonical_name") or raw.get("entity_key") or "Book State")[:255] key = normalize_entity_key(kind, name, str(raw.get("entity_key") or "")) entity, _ = StoryEntity.objects.get_or_create( story=story, entity_key=key, defaults={ "kind": kind, "canonical_name": name, "first_revision": revision, }, ) related_key = normalize_entity_key( "unknown", "Related Entity", str(raw.get("related_entity_key") or "") ) if raw.get("related_entity_key") else "" related_entity = None if related_key: related_entity, _ = StoryEntity.objects.get_or_create( story=story, entity_key=related_key, defaults={ "kind": "unknown", "canonical_name": related_key, "first_revision": revision, }, ) operation = str(raw.get("operation") or StateOperation.SET).upper() if operation not in allowed_operations: operation = StateOperation.SET item = { "sequence": sequence, "entity_key": key, "entity_kind": entity.kind, "canonical_name": entity.canonical_name, "related_entity_key": related_key, "change_type": str(raw.get("change_type") or "STATE_CHANGED")[:80], "predicate": str(raw.get("predicate") or "state")[:200], "operation": operation, "previous_value": raw.get("previous_value"), "new_value": raw.get("new_value"), "evidence_quote": str(raw.get("evidence_quote") or ""), "evidence_location": str(raw.get("evidence_location") or "")[:255], "source": source, } StateChange.objects.create( state_document=state_document, story=story, revision=revision, entity=entity, related_entity=related_entity, sequence=sequence, change_type=item["change_type"], predicate=item["predicate"], operation=operation, previous_value=item["previous_value"], new_value=item["new_value"], effective_chapter=revision.chapter.number, evidence_quote=item["evidence_quote"], evidence_location=item["evidence_location"], metadata={"source": source}, sha256=json_sha256(item), ) normalized.append(item) return normalized def _validate_state_change_evidence( self, state_document: ChapterStateDocument, prose: str ) -> list[dict[str, str]]: errors: list[dict[str, str]] = [] for change in state_document.changes.exclude( status=StateChangeStatus.REJECTED ).order_by("sequence"): if not evidence_is_present(prose, change.evidence_quote): errors.append( { "sequence": str(change.sequence), "location": change.evidence_location, "quote": change.evidence_quote, "description": ( f"State change {change.sequence} ({change.predicate}) has no exact " "supporting quotation in the chapter." ), } ) return errors def _write_state_artifacts(self, state_document: ChapterStateDocument) -> None: revision = state_document.revision stem = f"chapter-{revision.chapter.number:02d}-r{revision.revision}.state" root = self._artifact_root(revision.chapter.story) / "states" json_path = root / f"{stem}.json" markdown_path = root / f"{stem}.md" payload = { "schema_version": 2, "through_chapter": revision.chapter.number, "revision_id": str(revision.id), "status": state_document.status, "verdict": state_document.verdict, "start_state": state_document.start_state, "observed_state": state_document.observed_state, "proposed_delta": state_document.proposed_delta, "coverage": state_document.coverage, } atomic_write_text(json_path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n") atomic_write_text(markdown_path, render_state_markdown(payload)) state_document.json_artifact_uri = str(json_path) state_document.markdown_artifact_uri = str(markdown_path) state_document.sha256 = json_sha256(payload) state_document.save( update_fields=[ "json_artifact_uri", "markdown_artifact_uri", "sha256", "updated_at", ] ) def _revision(self, state: dict[str, Any]) -> ChapterRevision: return ChapterRevision.objects.select_related( "chapter__story__project", "story_bible", "outline", "context_snapshot" ).get(id=state["revision_id"]) def _context( self, state: dict[str, Any], revision: ChapterRevision ) -> dict[str, Any]: snapshot_id = state.get("context_snapshot_id") or revision.context_snapshot_id if not snapshot_id: raise RuntimeError("story workflow has no generation context snapshot") return GenerationContextSnapshot.objects.get(id=snapshot_id).content def _chapter_outline(self, revision: ChapterRevision) -> dict[str, Any]: chapters = revision.outline.content.get("chapters") or [] for chapter in chapters: if int(chapter.get("number") or 0) == revision.chapter.number: return chapter raise RuntimeError(f"outline has no Chapter {revision.chapter.number}") def _render_prompt( self, purpose: str, default_system: str, default_template: str, **values: Any, ) -> str: stored = PromptVersion.objects.filter(purpose=purpose, is_active=True).first() system = stored.system_text if stored else default_system template = stored.user_template if stored else default_template return f"{system}\n\n{template.format(**values)}" def _artifact_root(self, story: StoryProject) -> Path: configured = story.artifact_root.strip() return Path(configured) if configured else Path(settings.BASE_DIR) / "artifacts" / "stories" / story.slug def _partial_path(self, revision: ChapterRevision) -> Path: root = self._artifact_root(revision.chapter.story) return root / "drafts" / f"chapter-{revision.chapter.number:02d}-r{revision.revision}.partial.md" def _scene_partial_path(self, revision: ChapterRevision, scene_number: int) -> Path: path = self._partial_path(revision) return path.with_name(f"{path.stem}.scene-{scene_number:02d}.partial.md") def _scene_path(self, revision: ChapterRevision, scene_number: int) -> Path: path = self._partial_path(revision) return path.with_name(f"{path.stem}.scene-{scene_number:02d}.md") def _style_excerpt(self, revision: ChapterRevision, words: int = 350) -> str: style_revision_id = (revision.generation_metadata or {}).get("style_revision_id") if not style_revision_id: return "" prose = ( ChapterRevision.objects.filter(id=style_revision_id, chapter=revision.chapter) .values_list("prose", flat=True) .first() or "" ) return " ".join(prose.split()[:words]) def _tail(self, prose: str, words: int = 900) -> str: return " ".join(prose.split()[-words:])