1691 lines
74 KiB
Python
1691 lines
74 KiB
Python
|
|
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:])
|