2026-08-28 23:51:02 +07:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import json
|
|
|
|
|
import re
|
|
|
|
|
from copy import deepcopy
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from django.conf import settings
|
|
|
|
|
from django.db import transaction
|
|
|
|
|
from django.db.models import Max
|
|
|
|
|
from django.utils import timezone
|
|
|
|
|
|
|
|
|
|
from control_plane.authoring.models import (
|
|
|
|
|
BookRun,
|
|
|
|
|
BookRunStatus,
|
|
|
|
|
BookStateStatus,
|
|
|
|
|
BookStateVersion,
|
|
|
|
|
SceneDraftStatus,
|
|
|
|
|
StandaloneScene,
|
|
|
|
|
Work,
|
|
|
|
|
)
|
|
|
|
|
from control_plane.authoring.prompts import (
|
|
|
|
|
BOOK_CONTINUITY_REVIEW_SYSTEM,
|
|
|
|
|
BOOK_CONTINUITY_REVIEW_TEMPLATE,
|
|
|
|
|
BOOK_STRUCTURE_REVIEW_SYSTEM,
|
|
|
|
|
BOOK_STRUCTURE_REVIEW_TEMPLATE,
|
|
|
|
|
)
|
|
|
|
|
from control_plane.authoring.standalone_scenes import render_authoring_prompt
|
|
|
|
|
from control_plane.authoring.streaming import atomic_write_text
|
|
|
|
|
from model_router.providers import extract_json_object
|
|
|
|
|
from model_router.router import ModelCapability, ModelRequestContract, ModelRouter
|
|
|
|
|
|
|
|
|
|
CHAPTER_KEY_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
|
|
|
CONTINUITY_CATEGORIES = {
|
|
|
|
|
"character",
|
|
|
|
|
"reveal",
|
|
|
|
|
"object",
|
|
|
|
|
"injury",
|
|
|
|
|
"route",
|
|
|
|
|
"promise",
|
|
|
|
|
"relationship",
|
|
|
|
|
"location",
|
|
|
|
|
"money",
|
|
|
|
|
"other",
|
|
|
|
|
}
|
|
|
|
|
REVIEW_CATEGORIES = {
|
|
|
|
|
"structure",
|
|
|
|
|
"continuity",
|
|
|
|
|
"chronology",
|
|
|
|
|
"character",
|
|
|
|
|
"plot",
|
|
|
|
|
"pacing",
|
|
|
|
|
"contract",
|
|
|
|
|
"canon",
|
|
|
|
|
"logic",
|
|
|
|
|
"relationship",
|
|
|
|
|
"location",
|
|
|
|
|
"object",
|
|
|
|
|
"injury",
|
|
|
|
|
"route",
|
|
|
|
|
"promise",
|
|
|
|
|
"money",
|
|
|
|
|
"other",
|
|
|
|
|
}
|
|
|
|
|
ROOT_KEYS = {
|
|
|
|
|
"schema_version",
|
|
|
|
|
"title",
|
|
|
|
|
"premise",
|
|
|
|
|
"target_words",
|
|
|
|
|
"constraints",
|
|
|
|
|
"forbidden_events",
|
|
|
|
|
"acts",
|
|
|
|
|
"arcs",
|
|
|
|
|
"threads",
|
|
|
|
|
"continuity",
|
|
|
|
|
"chapters",
|
|
|
|
|
"ending",
|
|
|
|
|
"open_questions",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _canonical(value: Any, *, pretty: bool = False) -> str:
|
|
|
|
|
options: dict[str, Any] = {"ensure_ascii": False, "sort_keys": True}
|
|
|
|
|
if pretty:
|
|
|
|
|
options["indent"] = 2
|
|
|
|
|
else:
|
|
|
|
|
options["separators"] = (",", ":")
|
|
|
|
|
return json.dumps(value, **options)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sha256(value: str) -> str:
|
|
|
|
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _text(value: Any) -> str:
|
|
|
|
|
return value.strip() if isinstance(value, str) else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _integer(value: Any) -> int | None:
|
|
|
|
|
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _range_value(item: dict[str, Any], side: str) -> Any:
|
|
|
|
|
return item.get(f"{side}_chapter", item.get(f"chapter_{side}"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_book_state_content(
|
|
|
|
|
content: Any, *, work: Work, for_approval: bool = False
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Validate schema v1 without changing or normalizing the supplied document."""
|
|
|
|
|
errors: list[str] = []
|
|
|
|
|
warnings: list[str] = []
|
|
|
|
|
|
|
|
|
|
def error(path: str, message: str) -> None:
|
|
|
|
|
errors.append(f"{path}: {message}")
|
|
|
|
|
|
|
|
|
|
def warning(path: str, message: str) -> None:
|
|
|
|
|
warnings.append(f"{path}: {message}")
|
|
|
|
|
|
|
|
|
|
if not isinstance(content, dict):
|
|
|
|
|
return {
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
"valid": False,
|
|
|
|
|
"errors": ["$: must be an object"],
|
|
|
|
|
"warnings": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
unknown = sorted(set(content) - ROOT_KEYS)
|
|
|
|
|
for key in unknown:
|
|
|
|
|
error(f"$.{key}", "unknown root property")
|
|
|
|
|
if content.get("schema_version") != 1:
|
|
|
|
|
error("$.schema_version", "must equal 1")
|
|
|
|
|
for key in ("title", "premise"):
|
|
|
|
|
if not _text(content.get(key)):
|
|
|
|
|
error(f"$.{key}", "must be a nonempty string")
|
|
|
|
|
target_words = _integer(content.get("target_words"))
|
|
|
|
|
if target_words is None or target_words < 300:
|
|
|
|
|
error("$.target_words", "must be an integer of at least 300")
|
|
|
|
|
for key in ("constraints", "forbidden_events", "open_questions"):
|
|
|
|
|
value = content.get(key, [])
|
|
|
|
|
if not isinstance(value, list) or any(not _text(item) for item in value):
|
|
|
|
|
error(f"$.{key}", "must be a list of nonempty strings")
|
|
|
|
|
|
|
|
|
|
chapters = content.get("chapters")
|
|
|
|
|
if not isinstance(chapters, list) or not chapters:
|
|
|
|
|
error("$.chapters", "must be a nonempty list")
|
|
|
|
|
chapters = []
|
|
|
|
|
chapter_keys: list[str] = []
|
|
|
|
|
seen_chapter_keys: set[str] = set()
|
|
|
|
|
chapter_numbers: dict[str, int] = {}
|
|
|
|
|
beat_ids: set[str] = set()
|
|
|
|
|
scene_placements: set[str] = set()
|
|
|
|
|
scene_lookups: list[tuple[str, int, dict[str, Any]]] = []
|
|
|
|
|
for index, raw in enumerate(chapters):
|
|
|
|
|
path = f"$.chapters[{index}]"
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
error(path, "must be an object")
|
|
|
|
|
continue
|
|
|
|
|
key = _text(raw.get("chapter_key"))
|
|
|
|
|
if not key or not CHAPTER_KEY_RE.fullmatch(key):
|
|
|
|
|
error(f"{path}.chapter_key", "must be lowercase hyphen-separated text")
|
|
|
|
|
elif key in chapter_numbers:
|
|
|
|
|
error(f"{path}.chapter_key", "must be unique")
|
|
|
|
|
else:
|
|
|
|
|
chapter_keys.append(key)
|
|
|
|
|
number = _integer(raw.get("number"))
|
|
|
|
|
order = _integer(raw.get("order"))
|
|
|
|
|
expected = index + 1
|
|
|
|
|
if number != expected:
|
|
|
|
|
error(f"{path}.number", f"must equal {expected}")
|
|
|
|
|
if order != expected:
|
|
|
|
|
error(f"{path}.order", f"must equal {expected}")
|
|
|
|
|
for field in ("title", "purpose"):
|
|
|
|
|
if not _text(raw.get(field)):
|
|
|
|
|
error(f"{path}.{field}", "must be a nonempty string")
|
|
|
|
|
words = _integer(raw.get("target_words"))
|
|
|
|
|
if words is None or not 300 <= words <= 20000:
|
|
|
|
|
error(f"{path}.target_words", "must be an integer from 300 through 20000")
|
|
|
|
|
dependencies = raw.get("depends_on", [])
|
|
|
|
|
if not isinstance(dependencies, list):
|
|
|
|
|
error(f"{path}.depends_on", "must be a list")
|
|
|
|
|
else:
|
|
|
|
|
seen_dependencies: set[str] = set()
|
|
|
|
|
for dependency in dependencies:
|
|
|
|
|
if not isinstance(dependency, str) or dependency not in seen_chapter_keys:
|
|
|
|
|
error(f"{path}.depends_on", f"{dependency!r} must name an earlier chapter")
|
|
|
|
|
elif dependency in seen_dependencies:
|
|
|
|
|
error(f"{path}.depends_on", f"{dependency!r} is duplicated")
|
|
|
|
|
seen_dependencies.add(str(dependency))
|
|
|
|
|
if key:
|
|
|
|
|
chapter_numbers[key] = number or expected
|
|
|
|
|
seen_chapter_keys.add(key)
|
|
|
|
|
for field in ("act_id",):
|
|
|
|
|
if not _text(raw.get(field)):
|
|
|
|
|
error(f"{path}.{field}", "must be a nonempty string")
|
|
|
|
|
for field in ("arc_ids", "thread_ids"):
|
|
|
|
|
values = raw.get(field, [])
|
|
|
|
|
if not isinstance(values, list) or any(not _text(item) for item in values):
|
|
|
|
|
error(f"{path}.{field}", "must be a list of nonempty IDs")
|
|
|
|
|
elif len(values) != len(set(values)):
|
|
|
|
|
error(f"{path}.{field}", "must not contain duplicate IDs")
|
|
|
|
|
beats = raw.get("beats")
|
|
|
|
|
if not isinstance(beats, list) or not beats:
|
|
|
|
|
error(f"{path}.beats", "must be a nonempty list")
|
|
|
|
|
else:
|
|
|
|
|
for beat_index, beat in enumerate(beats):
|
|
|
|
|
beat_path = f"{path}.beats[{beat_index}]"
|
|
|
|
|
if not isinstance(beat, dict):
|
|
|
|
|
error(beat_path, "must be an object")
|
|
|
|
|
continue
|
|
|
|
|
beat_id = _text(beat.get("beat_id"))
|
|
|
|
|
if not beat_id:
|
|
|
|
|
error(f"{beat_path}.beat_id", "must be a nonempty string")
|
|
|
|
|
elif beat_id in beat_ids:
|
|
|
|
|
error(f"{beat_path}.beat_id", "must be globally unique")
|
|
|
|
|
beat_ids.add(beat_id)
|
|
|
|
|
if not _text(beat.get("text")):
|
|
|
|
|
error(f"{beat_path}.text", "must be a nonempty string")
|
|
|
|
|
if not isinstance(beat.get("required"), bool):
|
|
|
|
|
error(f"{beat_path}.required", "must be boolean")
|
|
|
|
|
if not _text(raw.get("ending_state")):
|
|
|
|
|
(error if for_approval else warning)(
|
|
|
|
|
f"{path}.ending_state", "must be a nonempty string before approval"
|
|
|
|
|
)
|
|
|
|
|
refs = raw.get("scene_refs", [])
|
|
|
|
|
if not isinstance(refs, list):
|
|
|
|
|
error(f"{path}.scene_refs", "must be a list")
|
|
|
|
|
else:
|
|
|
|
|
for ref_index, ref in enumerate(refs):
|
|
|
|
|
ref_path = f"{path}.scene_refs[{ref_index}]"
|
|
|
|
|
if not isinstance(ref, dict):
|
|
|
|
|
error(ref_path, "must be an object")
|
|
|
|
|
continue
|
|
|
|
|
scene_id = _text(ref.get("scene_id"))
|
|
|
|
|
raw_placement = ref.get("placement", ref.get("order"))
|
|
|
|
|
placement = f"{key}:{raw_placement}" if raw_placement is not None else scene_id
|
|
|
|
|
if not scene_id:
|
|
|
|
|
error(f"{ref_path}.scene_id", "must be a nonempty string")
|
|
|
|
|
continue
|
|
|
|
|
if scene_id in scene_placements or placement in scene_placements:
|
|
|
|
|
error(f"{ref_path}.placement", "must be unique across the book")
|
|
|
|
|
scene_placements.add(scene_id)
|
|
|
|
|
scene_placements.add(placement)
|
|
|
|
|
scene_lookups.append((ref_path, index, ref))
|
|
|
|
|
|
|
|
|
|
chapter_word_total = sum(
|
|
|
|
|
_integer(chapter.get("target_words")) or 0
|
|
|
|
|
for chapter in chapters
|
|
|
|
|
if isinstance(chapter, dict)
|
|
|
|
|
)
|
|
|
|
|
if target_words is not None and target_words != chapter_word_total:
|
|
|
|
|
error("$.target_words", "must equal the sum of chapter target_words")
|
|
|
|
|
if not _text(content.get("ending")):
|
|
|
|
|
error("$.ending", "must be a nonempty string")
|
|
|
|
|
|
|
|
|
|
chapter_key_set = set(chapter_keys)
|
|
|
|
|
acts = content.get("acts")
|
|
|
|
|
if not isinstance(acts, list) or not acts:
|
|
|
|
|
error("$.acts", "must be a nonempty list")
|
|
|
|
|
acts = []
|
|
|
|
|
act_ids: set[str] = set()
|
|
|
|
|
act_ranges: dict[str, tuple[int, int]] = {}
|
|
|
|
|
previous_end = 0
|
|
|
|
|
for index, raw in enumerate(acts):
|
|
|
|
|
path = f"$.acts[{index}]"
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
error(path, "must be an object")
|
|
|
|
|
continue
|
|
|
|
|
act_id = _text(raw.get("act_id"))
|
|
|
|
|
if not act_id:
|
|
|
|
|
error(f"{path}.act_id", "must be a nonempty string")
|
|
|
|
|
elif act_id in act_ids:
|
|
|
|
|
error(f"{path}.act_id", "must be unique")
|
|
|
|
|
act_ids.add(act_id)
|
|
|
|
|
if _integer(raw.get("number")) != index + 1:
|
|
|
|
|
error(f"{path}.number", f"must equal {index + 1}")
|
|
|
|
|
start = _integer(_range_value(raw, "start"))
|
|
|
|
|
end = _integer(_range_value(raw, "end"))
|
|
|
|
|
if start is None or end is None or start > end:
|
|
|
|
|
error(path, "must define a valid inclusive chapter range")
|
|
|
|
|
else:
|
|
|
|
|
if start != previous_end + 1:
|
|
|
|
|
error(path, f"range must start at chapter {previous_end + 1}")
|
|
|
|
|
previous_end = end
|
|
|
|
|
if act_id:
|
|
|
|
|
act_ranges[act_id] = (start, end)
|
|
|
|
|
if chapters and previous_end != len(chapters):
|
|
|
|
|
error("$.acts", "ranges must cover every chapter exactly once")
|
|
|
|
|
|
|
|
|
|
id_sets: dict[str, set[str]] = {"arc_ids": set(), "thread_ids": set()}
|
|
|
|
|
item_ranges: dict[str, dict[str, tuple[int, int]]] = {
|
|
|
|
|
"arc_ids": {},
|
|
|
|
|
"thread_ids": {},
|
|
|
|
|
}
|
|
|
|
|
for collection, id_name, chapter_field in (
|
|
|
|
|
("arcs", "arc_id", "arc_ids"),
|
|
|
|
|
("threads", "thread_id", "thread_ids"),
|
|
|
|
|
):
|
|
|
|
|
values = content.get(collection)
|
|
|
|
|
if not isinstance(values, list):
|
|
|
|
|
error(f"$.{collection}", "must be a list")
|
|
|
|
|
values = []
|
|
|
|
|
for index, raw in enumerate(values):
|
|
|
|
|
path = f"$.{collection}[{index}]"
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
error(path, "must be an object")
|
|
|
|
|
continue
|
|
|
|
|
item_id = _text(raw.get(id_name))
|
|
|
|
|
if not item_id:
|
|
|
|
|
error(f"{path}.{id_name}", "must be a nonempty string")
|
|
|
|
|
elif item_id in id_sets[chapter_field]:
|
|
|
|
|
error(f"{path}.{id_name}", "must be unique")
|
|
|
|
|
id_sets[chapter_field].add(item_id)
|
|
|
|
|
start_value = _range_value(raw, "start")
|
|
|
|
|
end_value = _range_value(raw, "end")
|
|
|
|
|
start = chapter_numbers.get(str(start_value), _integer(start_value))
|
|
|
|
|
end = chapter_numbers.get(str(end_value), _integer(end_value))
|
|
|
|
|
if (
|
|
|
|
|
start is None
|
|
|
|
|
or end is None
|
|
|
|
|
or not 1 <= start <= len(chapters)
|
|
|
|
|
or not 1 <= end <= len(chapters)
|
|
|
|
|
):
|
|
|
|
|
error(path, "chapter range must reference existing chapters")
|
|
|
|
|
elif start > end:
|
|
|
|
|
error(path, "chapter range cannot end before it starts")
|
|
|
|
|
elif item_id:
|
|
|
|
|
item_ranges[chapter_field][item_id] = (start, end)
|
|
|
|
|
|
|
|
|
|
for index, raw in enumerate(chapters):
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
continue
|
|
|
|
|
path = f"$.chapters[{index}]"
|
|
|
|
|
act_id = raw.get("act_id")
|
|
|
|
|
if act_id not in act_ids:
|
|
|
|
|
error(f"{path}.act_id", "must reference an existing act")
|
|
|
|
|
elif act_id in act_ranges and not (
|
|
|
|
|
act_ranges[act_id][0] <= index + 1 <= act_ranges[act_id][1]
|
|
|
|
|
):
|
|
|
|
|
error(f"{path}.act_id", "does not match the act range containing this chapter")
|
|
|
|
|
for field in ("arc_ids", "thread_ids"):
|
|
|
|
|
values = raw.get(field, [])
|
|
|
|
|
if isinstance(values, list):
|
|
|
|
|
for item_id in values:
|
|
|
|
|
if item_id not in id_sets[field]:
|
|
|
|
|
error(f"{path}.{field}", f"{item_id!r} does not exist")
|
|
|
|
|
elif item_id in item_ranges[field] and not (
|
|
|
|
|
item_ranges[field][item_id][0]
|
|
|
|
|
<= index + 1
|
|
|
|
|
<= item_ranges[field][item_id][1]
|
|
|
|
|
):
|
|
|
|
|
error(
|
|
|
|
|
f"{path}.{field}",
|
|
|
|
|
f"{item_id!r} is assigned outside its chapter range",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
continuity = content.get("continuity")
|
|
|
|
|
if not isinstance(continuity, list):
|
|
|
|
|
error("$.continuity", "must be a list")
|
|
|
|
|
continuity = []
|
|
|
|
|
fact_ids: set[str] = set()
|
|
|
|
|
for index, raw in enumerate(continuity):
|
|
|
|
|
path = f"$.continuity[{index}]"
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
error(path, "must be an object")
|
|
|
|
|
continue
|
|
|
|
|
fact_id = _text(raw.get("fact_id"))
|
|
|
|
|
if not fact_id:
|
|
|
|
|
error(f"{path}.fact_id", "must be a nonempty string")
|
|
|
|
|
elif fact_id in fact_ids:
|
|
|
|
|
error(f"{path}.fact_id", "must be unique")
|
|
|
|
|
fact_ids.add(fact_id)
|
|
|
|
|
if not _text(raw.get("entity_key")):
|
|
|
|
|
error(f"{path}.entity_key", "must be a nonempty string")
|
|
|
|
|
if raw.get("category") not in CONTINUITY_CATEGORIES:
|
|
|
|
|
error(f"{path}.category", f"must be one of {', '.join(sorted(CONTINUITY_CATEGORIES))}")
|
|
|
|
|
if not _text(raw.get("description")):
|
|
|
|
|
error(f"{path}.description", "must be a nonempty string")
|
|
|
|
|
established = raw.get("established_in")
|
|
|
|
|
resolved = raw.get("resolved_in")
|
|
|
|
|
if established not in chapter_key_set:
|
|
|
|
|
error(f"{path}.established_in", "must reference an existing chapter")
|
|
|
|
|
if resolved is not None:
|
|
|
|
|
if resolved not in chapter_key_set:
|
|
|
|
|
error(f"{path}.resolved_in", "must reference an existing chapter")
|
|
|
|
|
elif (
|
|
|
|
|
established in chapter_numbers
|
|
|
|
|
and chapter_numbers[str(resolved)] < chapter_numbers[str(established)]
|
|
|
|
|
):
|
|
|
|
|
error(f"{path}.resolved_in", "cannot precede establishment")
|
|
|
|
|
|
|
|
|
|
for path, _chapter_index, ref in scene_lookups:
|
|
|
|
|
try:
|
|
|
|
|
scene = StandaloneScene.objects.get(pk=ref["scene_id"])
|
|
|
|
|
except (StandaloneScene.DoesNotExist, ValueError, TypeError):
|
|
|
|
|
error(f"{path}.scene_id", "does not resolve to a standalone scene")
|
|
|
|
|
continue
|
|
|
|
|
if scene.work_id != work.pk:
|
|
|
|
|
error(f"{path}.scene_id", "scene belongs to a different work")
|
|
|
|
|
if scene.book_state_id:
|
|
|
|
|
error(f"{path}.scene_id", "scene_refs must reference unbound assembly scenes")
|
|
|
|
|
revision = _integer(ref.get("revision"))
|
|
|
|
|
if revision is None or scene.revision != revision:
|
|
|
|
|
error(f"{path}.revision", "does not match the scene revision")
|
|
|
|
|
expected_hash = _text(ref.get("sha256"))
|
|
|
|
|
if not expected_hash or scene.sha256 != expected_hash:
|
|
|
|
|
error(f"{path}.sha256", "does not match the scene content hash")
|
|
|
|
|
if for_approval and scene.status != SceneDraftStatus.APPROVED:
|
|
|
|
|
error(f"{path}.scene_id", "scene must be approved")
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
"valid": not errors,
|
|
|
|
|
"errors": sorted(set(errors)),
|
|
|
|
|
"warnings": sorted(set(warnings)),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _field_names(instance_or_model: Any) -> set[str]:
|
|
|
|
|
model = instance_or_model if isinstance(instance_or_model, type) else type(instance_or_model)
|
|
|
|
|
return {name for field in model._meta.concrete_fields for name in (field.name, field.attname)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _value(instance: Any, *names: str, default: Any = None) -> Any:
|
|
|
|
|
fields = _field_names(instance)
|
|
|
|
|
for name in names:
|
|
|
|
|
if name in fields:
|
|
|
|
|
return getattr(instance, name)
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assign(instance: Any, value: Any, *names: str) -> str | None:
|
|
|
|
|
fields = _field_names(instance)
|
|
|
|
|
for name in names:
|
|
|
|
|
if name in fields:
|
|
|
|
|
setattr(instance, name, value)
|
|
|
|
|
return name
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _status(enum: Any, name: str, fallback: str) -> str:
|
|
|
|
|
return str(getattr(enum, name, fallback))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BookStateService:
|
|
|
|
|
def __init__(self, router: ModelRouter) -> None:
|
|
|
|
|
self.router = router
|
|
|
|
|
|
|
|
|
|
@transaction.atomic
|
|
|
|
|
def create(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
work: Work,
|
|
|
|
|
content: dict[str, Any],
|
|
|
|
|
actor: str = "",
|
|
|
|
|
context_pack: dict[str, Any] | None = None,
|
|
|
|
|
generation_metadata: dict[str, Any] | None = None,
|
|
|
|
|
) -> BookStateVersion:
|
|
|
|
|
self._validate_metadata(context_pack, "context_pack")
|
|
|
|
|
self._validate_metadata(generation_metadata, "generation_metadata")
|
|
|
|
|
locked_work = Work.objects.select_for_update().get(pk=work.pk)
|
|
|
|
|
version = (
|
|
|
|
|
BookStateVersion.objects.filter(work=locked_work).aggregate(value=Max("version"))[
|
|
|
|
|
"value"
|
|
|
|
|
]
|
|
|
|
|
or 0
|
|
|
|
|
) + 1
|
|
|
|
|
validation = validate_book_state_content(content, work=locked_work)
|
|
|
|
|
if not validation["valid"]:
|
|
|
|
|
raise ValueError("invalid book state: " + "; ".join(validation["errors"]))
|
|
|
|
|
summary = {
|
|
|
|
|
"type": "create",
|
|
|
|
|
"chapter_count": len(content["chapters"]),
|
|
|
|
|
"changed_from_chapter": 1,
|
|
|
|
|
}
|
|
|
|
|
state = self._new_state(
|
|
|
|
|
locked_work,
|
|
|
|
|
version,
|
|
|
|
|
content,
|
|
|
|
|
None,
|
|
|
|
|
summary,
|
|
|
|
|
validation,
|
|
|
|
|
actor,
|
|
|
|
|
context_pack,
|
|
|
|
|
generation_metadata,
|
|
|
|
|
)
|
|
|
|
|
self._write_artifacts(state)
|
|
|
|
|
return state
|
|
|
|
|
|
|
|
|
|
@transaction.atomic
|
|
|
|
|
def revise(
|
|
|
|
|
self,
|
|
|
|
|
state: BookStateVersion,
|
|
|
|
|
*,
|
|
|
|
|
content: dict[str, Any],
|
|
|
|
|
actor: str = "",
|
|
|
|
|
context_pack: dict[str, Any] | None = None,
|
|
|
|
|
generation_metadata: dict[str, Any] | None = None,
|
|
|
|
|
) -> BookStateVersion:
|
|
|
|
|
self._validate_metadata(context_pack, "context_pack")
|
|
|
|
|
self._validate_metadata(generation_metadata, "generation_metadata")
|
|
|
|
|
locked_work = Work.objects.select_for_update().get(pk=state.work_id)
|
|
|
|
|
parent = (
|
|
|
|
|
BookStateVersion.objects.select_for_update().select_related("work").get(pk=state.pk)
|
|
|
|
|
)
|
|
|
|
|
version = (
|
|
|
|
|
BookStateVersion.objects.filter(work=locked_work).aggregate(value=Max("version"))[
|
|
|
|
|
"value"
|
|
|
|
|
]
|
|
|
|
|
or 0
|
|
|
|
|
) + 1
|
|
|
|
|
validation = validate_book_state_content(content, work=locked_work)
|
|
|
|
|
if not validation["valid"]:
|
|
|
|
|
raise ValueError("invalid book state: " + "; ".join(validation["errors"]))
|
|
|
|
|
summary = self._change_summary(_value(parent, "content", default={}), content)
|
|
|
|
|
child = self._new_state(
|
|
|
|
|
locked_work,
|
|
|
|
|
version,
|
|
|
|
|
content,
|
|
|
|
|
parent,
|
|
|
|
|
summary,
|
|
|
|
|
validation,
|
|
|
|
|
actor,
|
|
|
|
|
context_pack,
|
|
|
|
|
generation_metadata,
|
|
|
|
|
)
|
|
|
|
|
self._write_artifacts(child)
|
|
|
|
|
return child
|
|
|
|
|
|
|
|
|
|
def validate(self, state: BookStateVersion, *, for_approval: bool = False) -> dict[str, Any]:
|
|
|
|
|
result = validate_book_state_content(
|
|
|
|
|
_value(state, "content", default={}), work=state.work, for_approval=for_approval
|
|
|
|
|
)
|
|
|
|
|
field = _assign(state, result, "validation", "validation_result")
|
|
|
|
|
if field:
|
|
|
|
|
state.save(update_fields=[field, "updated_at"])
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
def review(
|
|
|
|
|
self,
|
|
|
|
|
state: BookStateVersion,
|
|
|
|
|
level: str | None = None,
|
|
|
|
|
*,
|
|
|
|
|
review_level: str | None = None,
|
|
|
|
|
model_hint: str | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
level = _text(level or review_level)
|
|
|
|
|
content = _value(state, "content", default={})
|
|
|
|
|
if _value(state, "status") not in {
|
|
|
|
|
_status(BookStateStatus, "DRAFT", "draft"),
|
|
|
|
|
_status(BookStateStatus, "REVIEW", "review"),
|
|
|
|
|
}:
|
|
|
|
|
raise ValueError("only draft or review book states may be reviewed")
|
|
|
|
|
valid_levels = {
|
|
|
|
|
"manuscript",
|
|
|
|
|
"continuity",
|
|
|
|
|
*{f"act:{item.get('act_id')}" for item in content.get("acts", [])},
|
|
|
|
|
}
|
|
|
|
|
if level not in valid_levels:
|
|
|
|
|
raise ValueError(f"invalid review level: {level}")
|
|
|
|
|
if level == "continuity":
|
|
|
|
|
scene_packets = self._scene_packets(state)
|
|
|
|
|
prompt, prompt_version_id = render_authoring_prompt(
|
|
|
|
|
"BOOK_CONTINUITY_REVIEW",
|
|
|
|
|
BOOK_CONTINUITY_REVIEW_SYSTEM,
|
|
|
|
|
BOOK_CONTINUITY_REVIEW_TEMPLATE,
|
|
|
|
|
state=_canonical(self._compact_state(content), pretty=True),
|
|
|
|
|
scene_packets=_canonical(scene_packets, pretty=True),
|
|
|
|
|
)
|
|
|
|
|
purpose = ModelCapability.STORY_CONTINUITY
|
|
|
|
|
else:
|
|
|
|
|
review_state = (
|
|
|
|
|
content
|
|
|
|
|
if level == "manuscript"
|
|
|
|
|
else self._act_slice(content, level.partition(":")[2])
|
|
|
|
|
)
|
|
|
|
|
prompt, prompt_version_id = render_authoring_prompt(
|
|
|
|
|
"BOOK_STRUCTURE_REVIEW",
|
|
|
|
|
BOOK_STRUCTURE_REVIEW_SYSTEM,
|
|
|
|
|
BOOK_STRUCTURE_REVIEW_TEMPLATE,
|
|
|
|
|
review_level=level,
|
|
|
|
|
state=_canonical(self._compact_state(review_state), pretty=True),
|
|
|
|
|
)
|
|
|
|
|
purpose = ModelCapability.STORY_REVIEW
|
|
|
|
|
story = getattr(state.work, "story_project", None)
|
|
|
|
|
response = self.router.complete(
|
|
|
|
|
ModelRequestContract(
|
|
|
|
|
purpose=purpose,
|
|
|
|
|
prompt=prompt,
|
|
|
|
|
model_hint=model_hint,
|
|
|
|
|
token_budget=6000,
|
|
|
|
|
project=story.project if story else None,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
review = self._normalize_review(extract_json_object(response.content), content)
|
|
|
|
|
review.update(
|
|
|
|
|
{
|
|
|
|
|
"level": level,
|
|
|
|
|
"state_sha256": self._state_hash(state),
|
|
|
|
|
"model": response.model,
|
|
|
|
|
"prompt_version_id": prompt_version_id,
|
|
|
|
|
"prompt_sha256": _sha256(prompt),
|
|
|
|
|
"response_sha256": _sha256(response.content),
|
|
|
|
|
"reviewed_at": timezone.now().isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
with transaction.atomic():
|
|
|
|
|
locked = BookStateVersion.objects.select_for_update().get(pk=state.pk)
|
|
|
|
|
if _value(locked, "status") not in {
|
|
|
|
|
_status(BookStateStatus, "DRAFT", "draft"),
|
|
|
|
|
_status(BookStateStatus, "REVIEW", "review"),
|
|
|
|
|
}:
|
|
|
|
|
raise ValueError("book state changed while review was running")
|
|
|
|
|
reviews = deepcopy(_value(locked, "reviews", "review", default={}) or {})
|
|
|
|
|
reviews[level] = review
|
|
|
|
|
fields = []
|
|
|
|
|
field = _assign(locked, reviews, "reviews", "review")
|
|
|
|
|
if field:
|
|
|
|
|
fields.append(field)
|
|
|
|
|
field = _assign(locked, _status(BookStateStatus, "REVIEW", "review"), "status")
|
|
|
|
|
if field:
|
|
|
|
|
fields.append(field)
|
|
|
|
|
if not fields:
|
|
|
|
|
raise ValueError("BookStateVersion must provide a reviews or review JSON field")
|
|
|
|
|
locked.save(update_fields=[*fields, "updated_at"])
|
|
|
|
|
self._write_artifacts(locked)
|
|
|
|
|
return review
|
|
|
|
|
|
|
|
|
|
@transaction.atomic
|
|
|
|
|
def approve(
|
|
|
|
|
self,
|
|
|
|
|
state: BookStateVersion,
|
|
|
|
|
*,
|
|
|
|
|
actor: str = "",
|
|
|
|
|
force: bool = False,
|
|
|
|
|
notes: str = "",
|
|
|
|
|
) -> BookStateVersion:
|
|
|
|
|
work = Work.objects.select_for_update().get(pk=state.work_id)
|
|
|
|
|
locked = (
|
|
|
|
|
BookStateVersion.objects.select_for_update().select_related("work").get(pk=state.pk)
|
|
|
|
|
)
|
|
|
|
|
if _value(locked, "status") != _status(BookStateStatus, "REVIEW", "review"):
|
|
|
|
|
raise ValueError("book state must be in review before approval")
|
|
|
|
|
validation = validate_book_state_content(
|
|
|
|
|
_value(locked, "content", default={}), work=work, for_approval=True
|
|
|
|
|
)
|
|
|
|
|
if not validation["valid"]:
|
|
|
|
|
raise ValueError("book state is not approval-valid: " + "; ".join(validation["errors"]))
|
|
|
|
|
current_id = _value(work, "current_book_state_id")
|
2026-08-29 00:14:42 +07:00
|
|
|
approved_base_id = self._approved_base_id(locked)
|
|
|
|
|
if approved_base_id != current_id:
|
2026-08-28 23:51:02 +07:00
|
|
|
raise ValueError("book state parent is stale relative to the work's approved state")
|
|
|
|
|
if force and not notes.strip():
|
|
|
|
|
raise ValueError("forced approval requires notes")
|
|
|
|
|
content = _value(locked, "content", default={})
|
|
|
|
|
required = ["manuscript", *[f"act:{act['act_id']}" for act in content.get("acts", [])]]
|
|
|
|
|
if any(chapter.get("scene_refs") for chapter in content.get("chapters", [])):
|
|
|
|
|
required.append("continuity")
|
|
|
|
|
reviews = _value(locked, "reviews", "review", default={}) or {}
|
|
|
|
|
state_hash = self._state_hash(locked)
|
|
|
|
|
for level in required:
|
|
|
|
|
review = reviews.get(level) or {}
|
|
|
|
|
if (not review.get("passed") or review.get("state_sha256") != state_hash) and not force:
|
|
|
|
|
raise ValueError(f"passing current {level} review is required")
|
|
|
|
|
fields = []
|
|
|
|
|
for value, names in (
|
|
|
|
|
(_status(BookStateStatus, "APPROVED", "approved"), ("status",)),
|
|
|
|
|
(timezone.now(), ("approved_at",)),
|
|
|
|
|
(actor.strip(), ("approved_by",)),
|
|
|
|
|
(notes.strip(), ("approval_notes",)),
|
|
|
|
|
(force, ("approval_forced",)),
|
|
|
|
|
(validation, ("validation", "validation_result")),
|
|
|
|
|
):
|
|
|
|
|
field = _assign(locked, value, *names)
|
|
|
|
|
if field:
|
|
|
|
|
fields.append(field)
|
|
|
|
|
locked.save(update_fields=[*fields, "updated_at"])
|
|
|
|
|
current_field = _assign(work, locked, "current_book_state")
|
|
|
|
|
if not current_field:
|
|
|
|
|
raise ValueError("Work must provide current_book_state")
|
|
|
|
|
work.save(update_fields=[current_field, "updated_at"])
|
|
|
|
|
self._write_artifacts(locked)
|
|
|
|
|
return locked
|
|
|
|
|
|
2026-08-29 00:14:42 +07:00
|
|
|
@staticmethod
|
|
|
|
|
def _approved_base_id(state: BookStateVersion) -> Any:
|
|
|
|
|
ancestor_id = state.parent_id
|
|
|
|
|
while ancestor_id:
|
|
|
|
|
ancestor = BookStateVersion.objects.select_for_update().only(
|
|
|
|
|
"id", "parent_id", "status"
|
|
|
|
|
).get(pk=ancestor_id)
|
|
|
|
|
if ancestor.status == BookStateStatus.APPROVED:
|
|
|
|
|
return ancestor.id
|
|
|
|
|
ancestor_id = ancestor.parent_id
|
|
|
|
|
return None
|
|
|
|
|
|
2026-08-28 23:51:02 +07:00
|
|
|
@transaction.atomic
|
|
|
|
|
def reject(
|
|
|
|
|
self, state: BookStateVersion, *, actor: str = "", notes: str = ""
|
|
|
|
|
) -> BookStateVersion:
|
|
|
|
|
locked = BookStateVersion.objects.select_for_update().get(pk=state.pk)
|
|
|
|
|
if _value(locked, "status") not in {
|
|
|
|
|
_status(BookStateStatus, "DRAFT", "draft"),
|
|
|
|
|
_status(BookStateStatus, "REVIEW", "review"),
|
|
|
|
|
}:
|
|
|
|
|
raise ValueError("only draft or review book states may be rejected")
|
|
|
|
|
fields = []
|
|
|
|
|
for value, names in (
|
|
|
|
|
(_status(BookStateStatus, "REJECTED", "rejected"), ("status",)),
|
|
|
|
|
(timezone.now(), ("rejected_at",)),
|
|
|
|
|
(actor.strip(), ("rejected_by",)),
|
|
|
|
|
(notes.strip(), ("rejection_notes",)),
|
|
|
|
|
):
|
|
|
|
|
field = _assign(locked, value, *names)
|
|
|
|
|
if field:
|
|
|
|
|
fields.append(field)
|
|
|
|
|
locked.save(update_fields=[*fields, "updated_at"])
|
|
|
|
|
self._write_artifacts(locked)
|
|
|
|
|
return locked
|
|
|
|
|
|
|
|
|
|
def impact(self, state: BookStateVersion) -> dict[str, Any]:
|
|
|
|
|
parent = _value(state, "parent")
|
|
|
|
|
if parent is None:
|
|
|
|
|
chapter_keys = [
|
|
|
|
|
item.get("chapter_key")
|
|
|
|
|
for item in _value(state, "content", default={}).get("chapters", [])
|
|
|
|
|
]
|
|
|
|
|
return {
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
"from_chapter": 1 if chapter_keys else None,
|
|
|
|
|
"replan_required": chapter_keys,
|
|
|
|
|
"continuity_review_required": chapter_keys[1:],
|
|
|
|
|
"reasons": ["initial_state"],
|
|
|
|
|
}
|
|
|
|
|
return self._impact_content(
|
|
|
|
|
_value(parent, "content", default={}), _value(state, "content", default={})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@transaction.atomic
|
|
|
|
|
def start_run(
|
|
|
|
|
self, state: BookStateVersion, *, policy: dict[str, Any] | None = None
|
|
|
|
|
) -> BookRun:
|
|
|
|
|
self._validate_metadata(policy, "policy")
|
|
|
|
|
locked = BookStateVersion.objects.select_for_update().get(pk=state.pk)
|
|
|
|
|
if _value(locked, "status") != _status(BookStateStatus, "APPROVED", "approved"):
|
|
|
|
|
raise ValueError("book runs require an approved book state")
|
|
|
|
|
chapters = _value(locked, "content", default={}).get("chapters", [])
|
|
|
|
|
progress = {item["chapter_key"]: "needs_scene" for item in chapters}
|
|
|
|
|
run_policy = deepcopy(policy or {})
|
|
|
|
|
run_policy["required_reviews"] = self._required_reviews(
|
|
|
|
|
_value(locked, "content", default={})
|
|
|
|
|
)
|
|
|
|
|
run_policy["run_required_reviews"] = ["continuity"]
|
|
|
|
|
values: dict[str, Any] = {}
|
|
|
|
|
fields = _field_names(BookRun)
|
|
|
|
|
for names, value in (
|
|
|
|
|
(("state", "book_state"), locked),
|
|
|
|
|
(("work",), locked.work),
|
|
|
|
|
(("status",), _status(BookRunStatus, "RUNNING", "running")),
|
|
|
|
|
(("progress",), progress),
|
|
|
|
|
(
|
|
|
|
|
("current_chapter_key", "cursor_chapter_key", "cursor"),
|
|
|
|
|
chapters[0]["chapter_key"] if chapters else "",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
("policy",),
|
|
|
|
|
run_policy,
|
|
|
|
|
),
|
|
|
|
|
(("started_at",), timezone.now()),
|
|
|
|
|
):
|
|
|
|
|
for name in names:
|
|
|
|
|
if name in fields:
|
|
|
|
|
values[name] = value
|
|
|
|
|
break
|
|
|
|
|
return BookRun.objects.create(**values)
|
|
|
|
|
|
|
|
|
|
@transaction.atomic
|
|
|
|
|
def sync_run(self, run: BookRun) -> BookRun:
|
|
|
|
|
run_scope = BookRun.objects.select_related("book_state").get(pk=run.pk)
|
|
|
|
|
Work.objects.select_for_update().get(pk=run_scope.book_state.work_id)
|
|
|
|
|
locked = BookRun.objects.select_for_update().select_related("book_state").get(pk=run.pk)
|
|
|
|
|
if locked.status == BookRunStatus.COMPLETE:
|
|
|
|
|
return locked
|
|
|
|
|
if locked.status in {
|
|
|
|
|
BookRunStatus.FAILED,
|
|
|
|
|
BookRunStatus.CANCELLED,
|
|
|
|
|
}:
|
|
|
|
|
raise ValueError(f"cannot synchronize a {locked.status} book run")
|
|
|
|
|
state = _value(locked, "state", "book_state")
|
|
|
|
|
chapters = _value(state, "content", default={}).get("chapters", [])
|
|
|
|
|
progress: dict[str, str] = {}
|
|
|
|
|
for chapter in chapters:
|
|
|
|
|
key = chapter["chapter_key"]
|
|
|
|
|
progress[key] = self._chapter_progress(state, chapter)
|
|
|
|
|
first_incomplete = next(
|
|
|
|
|
(key for key, status in progress.items() if status != "complete"), ""
|
|
|
|
|
)
|
|
|
|
|
if first_incomplete:
|
|
|
|
|
status = _status(BookRunStatus, "PAUSED", "paused")
|
|
|
|
|
elif self._run_reviews_pass(locked, state):
|
|
|
|
|
status = _status(BookRunStatus, "COMPLETE", "complete")
|
|
|
|
|
else:
|
|
|
|
|
status = _status(BookRunStatus, "REVIEW", "review")
|
|
|
|
|
fields = []
|
|
|
|
|
finished_at = (
|
|
|
|
|
timezone.now() if status == _status(BookRunStatus, "COMPLETE", "complete") else None
|
|
|
|
|
)
|
|
|
|
|
for value, names in (
|
|
|
|
|
(progress, ("progress",)),
|
|
|
|
|
(first_incomplete, ("current_chapter_key", "cursor_chapter_key", "cursor")),
|
|
|
|
|
(status, ("status",)),
|
|
|
|
|
(finished_at, ("finished_at",)),
|
|
|
|
|
):
|
|
|
|
|
field = _assign(locked, value, *names)
|
|
|
|
|
if field:
|
|
|
|
|
fields.append(field)
|
|
|
|
|
locked.save(update_fields=[*fields, "updated_at"])
|
|
|
|
|
return locked
|
|
|
|
|
|
|
|
|
|
def review_run(self, run: BookRun, *, model_hint: str | None = None) -> dict[str, Any]:
|
|
|
|
|
run = BookRun.objects.select_related("book_state__work").get(pk=run.pk)
|
|
|
|
|
if run.status != BookRunStatus.REVIEW:
|
|
|
|
|
raise ValueError("book run must have all chapters complete before continuity review")
|
|
|
|
|
state = run.book_state
|
|
|
|
|
content = state.content or {}
|
|
|
|
|
scene_packets = self._run_scene_packets(state)
|
|
|
|
|
scene_set_sha256 = _sha256(_canonical(scene_packets))
|
|
|
|
|
prompt, prompt_version_id = render_authoring_prompt(
|
|
|
|
|
"BOOK_CONTINUITY_REVIEW",
|
|
|
|
|
BOOK_CONTINUITY_REVIEW_SYSTEM,
|
|
|
|
|
BOOK_CONTINUITY_REVIEW_TEMPLATE,
|
|
|
|
|
state=_canonical(self._compact_state(content), pretty=True),
|
|
|
|
|
scene_packets=_canonical(scene_packets, pretty=True),
|
|
|
|
|
)
|
|
|
|
|
story = getattr(state.work, "story_project", None)
|
|
|
|
|
response = self.router.complete(
|
|
|
|
|
ModelRequestContract(
|
|
|
|
|
purpose=ModelCapability.STORY_CONTINUITY,
|
|
|
|
|
prompt=prompt,
|
|
|
|
|
model_hint=model_hint,
|
|
|
|
|
token_budget=6000,
|
|
|
|
|
project=story.project if story else None,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
review = self._normalize_review(extract_json_object(response.content), content)
|
|
|
|
|
review.update(
|
|
|
|
|
{
|
|
|
|
|
"level": "continuity",
|
|
|
|
|
"state_sha256": self._state_hash(state),
|
|
|
|
|
"scene_set_sha256": scene_set_sha256,
|
|
|
|
|
"scene_manifest": self._scene_manifest(scene_packets),
|
|
|
|
|
"model": response.model,
|
|
|
|
|
"prompt_version_id": prompt_version_id,
|
|
|
|
|
"prompt_sha256": _sha256(prompt),
|
|
|
|
|
"response_sha256": _sha256(response.content),
|
|
|
|
|
"reviewed_at": timezone.now().isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
with transaction.atomic():
|
|
|
|
|
locked = BookRun.objects.select_for_update().select_related("book_state").get(pk=run.pk)
|
|
|
|
|
if locked.status != BookRunStatus.REVIEW:
|
|
|
|
|
raise ValueError("book run changed while continuity review was running")
|
|
|
|
|
current_packets = self._run_scene_packets(locked.book_state)
|
|
|
|
|
if _sha256(_canonical(current_packets)) != scene_set_sha256:
|
|
|
|
|
raise ValueError("book run scenes changed while continuity review was running")
|
|
|
|
|
reviews = deepcopy(locked.reviews or {})
|
|
|
|
|
reviews["continuity"] = review
|
|
|
|
|
locked.reviews = reviews
|
|
|
|
|
locked.save(update_fields=["reviews", "updated_at"])
|
|
|
|
|
self.sync_run(locked)
|
|
|
|
|
return review
|
|
|
|
|
|
|
|
|
|
def _new_state(
|
|
|
|
|
self,
|
|
|
|
|
work: Work,
|
|
|
|
|
version: int,
|
|
|
|
|
content: dict[str, Any],
|
|
|
|
|
parent: BookStateVersion | None,
|
|
|
|
|
summary: dict[str, Any],
|
|
|
|
|
validation: dict[str, Any],
|
|
|
|
|
actor: str,
|
|
|
|
|
context_pack: dict[str, Any] | None,
|
|
|
|
|
generation_metadata: dict[str, Any] | None,
|
|
|
|
|
) -> BookStateVersion:
|
|
|
|
|
fields = _field_names(BookStateVersion)
|
|
|
|
|
values: dict[str, Any] = {"work": work, "version": version, "content": deepcopy(content)}
|
|
|
|
|
candidates = (
|
|
|
|
|
("parent", parent),
|
|
|
|
|
("status", _status(BookStateStatus, "DRAFT", "draft")),
|
|
|
|
|
("change_summary", summary),
|
|
|
|
|
("validation", validation),
|
|
|
|
|
("validation_result", validation),
|
|
|
|
|
("sha256", _sha256(_canonical(content))),
|
|
|
|
|
("created_by", actor.strip()),
|
|
|
|
|
("context_pack", deepcopy(context_pack or {})),
|
|
|
|
|
("generation_metadata", deepcopy(generation_metadata or {})),
|
|
|
|
|
)
|
|
|
|
|
for name, value in candidates:
|
|
|
|
|
if name in fields and name not in values:
|
|
|
|
|
values[name] = value
|
|
|
|
|
return BookStateVersion.objects.create(**values)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _validate_metadata(value: Any, name: str) -> None:
|
|
|
|
|
if value is not None and not isinstance(value, dict):
|
|
|
|
|
raise ValueError(f"{name} must be an object")
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _change_summary(parent: dict[str, Any], child: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
changed = sorted(key for key in ROOT_KEYS if parent.get(key) != child.get(key))
|
|
|
|
|
impact = BookStateService._impact_content(parent, child)
|
|
|
|
|
return {
|
|
|
|
|
"type": "revision",
|
|
|
|
|
"changed_sections": changed,
|
|
|
|
|
"changed_from_chapter": impact["from_chapter"],
|
|
|
|
|
"replan_required": impact["replan_required"],
|
|
|
|
|
"continuity_review_required": impact["continuity_review_required"],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _impact_content(parent: dict[str, Any], child: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
old_chapters = parent.get("chapters", [])
|
|
|
|
|
new_chapters = child.get("chapters", [])
|
|
|
|
|
new_keys = [item.get("chapter_key") for item in new_chapters]
|
|
|
|
|
reasons: list[str] = []
|
|
|
|
|
earliest: int | None = None
|
|
|
|
|
global_fields = (
|
|
|
|
|
"premise",
|
|
|
|
|
"target_words",
|
|
|
|
|
"constraints",
|
|
|
|
|
"forbidden_events",
|
|
|
|
|
"acts",
|
|
|
|
|
"arcs",
|
|
|
|
|
"threads",
|
|
|
|
|
"continuity",
|
|
|
|
|
"ending",
|
|
|
|
|
)
|
|
|
|
|
for field in global_fields:
|
|
|
|
|
if parent.get(field) != child.get(field):
|
|
|
|
|
earliest = 1
|
|
|
|
|
reasons.append(field)
|
|
|
|
|
old_keys = [item.get("chapter_key") for item in old_chapters]
|
|
|
|
|
directly_changed: list[str] = []
|
|
|
|
|
limit = max(len(old_chapters), len(new_chapters))
|
|
|
|
|
for index in range(limit):
|
|
|
|
|
old = old_chapters[index] if index < len(old_chapters) else None
|
|
|
|
|
new = new_chapters[index] if index < len(new_chapters) else None
|
|
|
|
|
if old != new:
|
|
|
|
|
candidate = index + 1
|
|
|
|
|
earliest = candidate if earliest is None else min(earliest, candidate)
|
|
|
|
|
if old is None:
|
|
|
|
|
reasons.append("chapter_inserted")
|
|
|
|
|
elif new is None:
|
|
|
|
|
reasons.append("chapter_deleted")
|
|
|
|
|
elif old.get("chapter_key") != new.get("chapter_key"):
|
|
|
|
|
reasons.append("chapter_reordered")
|
|
|
|
|
else:
|
|
|
|
|
reasons.append(f"chapter_contract:{new.get('chapter_key')}")
|
|
|
|
|
directly_changed.append(new.get("chapter_key"))
|
|
|
|
|
if (
|
|
|
|
|
old_keys != new_keys
|
|
|
|
|
and "chapter_reordered" not in reasons
|
|
|
|
|
and len(old_keys) == len(new_keys)
|
|
|
|
|
):
|
|
|
|
|
reasons.append("chapter_reordered")
|
|
|
|
|
structural = any(
|
|
|
|
|
reason in {"chapter_inserted", "chapter_deleted", "chapter_reordered"}
|
|
|
|
|
for reason in reasons
|
|
|
|
|
)
|
|
|
|
|
global_change = any(field in reasons for field in global_fields)
|
|
|
|
|
if global_change or structural:
|
|
|
|
|
replan = new_keys[earliest - 1 :] if earliest else []
|
|
|
|
|
else:
|
|
|
|
|
affected = set(directly_changed)
|
|
|
|
|
changed = True
|
|
|
|
|
while changed:
|
|
|
|
|
changed = False
|
|
|
|
|
for chapter in new_chapters:
|
|
|
|
|
key = chapter.get("chapter_key")
|
|
|
|
|
if key not in affected and affected.intersection(chapter.get("depends_on", [])):
|
|
|
|
|
affected.add(key)
|
|
|
|
|
changed = True
|
|
|
|
|
replan = [key for key in new_keys if key in affected]
|
|
|
|
|
continuity = new_keys[earliest:] if earliest else []
|
|
|
|
|
return {
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
"from_chapter": earliest,
|
|
|
|
|
"replan_required": replan,
|
|
|
|
|
"continuity_review_required": continuity,
|
|
|
|
|
"reasons": sorted(set(reasons)),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _compact_state(content: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
return deepcopy(content)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _act_slice(content: dict[str, Any], act_id: str) -> dict[str, Any]:
|
|
|
|
|
act = next(item for item in content.get("acts", []) if item.get("act_id") == act_id)
|
|
|
|
|
start = int(_range_value(act, "start"))
|
|
|
|
|
end = int(_range_value(act, "end"))
|
|
|
|
|
sliced = {key: deepcopy(value) for key, value in content.items() if key != "chapters"}
|
|
|
|
|
sliced["acts"] = [deepcopy(act)]
|
|
|
|
|
sliced["chapters"] = deepcopy(content.get("chapters", [])[start - 1 : end])
|
|
|
|
|
keys = {item["chapter_key"] for item in sliced["chapters"]}
|
|
|
|
|
sliced["continuity"] = [
|
|
|
|
|
deepcopy(item)
|
|
|
|
|
for item in content.get("continuity", [])
|
|
|
|
|
if item.get("established_in") in keys or item.get("resolved_in") in keys
|
|
|
|
|
]
|
|
|
|
|
return sliced
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _normalize_review(data: dict[str, Any], content: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
raw_findings = data.get("findings")
|
|
|
|
|
if not isinstance(raw_findings, list):
|
|
|
|
|
raise ValueError("review response must contain a findings list")
|
|
|
|
|
chapter_keys = {item.get("chapter_key") for item in content.get("chapters", [])}
|
|
|
|
|
findings = []
|
|
|
|
|
for raw in raw_findings:
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
raise ValueError("each review finding must be an object")
|
|
|
|
|
severity = str(raw.get("severity") or "").upper()
|
|
|
|
|
if severity not in {"INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"}:
|
|
|
|
|
raise ValueError("each review finding must have a valid severity")
|
|
|
|
|
category = str(raw.get("category") or "").lower()
|
|
|
|
|
if category not in REVIEW_CATEGORIES:
|
|
|
|
|
raise ValueError("each review finding must have a valid category")
|
|
|
|
|
chapter_key = _text(raw.get("chapter_key"))
|
|
|
|
|
if chapter_key not in chapter_keys:
|
|
|
|
|
chapter_key = ""
|
|
|
|
|
description = _text(raw.get("description"))
|
|
|
|
|
if not description:
|
|
|
|
|
raise ValueError("each review finding must have a description")
|
|
|
|
|
findings.append(
|
|
|
|
|
{
|
|
|
|
|
"severity": severity,
|
|
|
|
|
"category": category,
|
|
|
|
|
"chapter_key": chapter_key,
|
|
|
|
|
"description": description,
|
|
|
|
|
"suggested_revision": _text(raw.get("suggested_revision")),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
findings.sort(
|
|
|
|
|
key=lambda item: (
|
|
|
|
|
item["chapter_key"],
|
|
|
|
|
item["severity"],
|
|
|
|
|
item["category"],
|
|
|
|
|
item["description"],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
blocking = sum(item["severity"] in {"HIGH", "CRITICAL"} for item in findings)
|
|
|
|
|
return {
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
"passed": blocking == 0,
|
|
|
|
|
"blocking_count": blocking,
|
|
|
|
|
"findings": findings,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _scene_packets(self, state: BookStateVersion) -> list[dict[str, Any]]:
|
|
|
|
|
packets = []
|
|
|
|
|
for chapter in _value(state, "content", default={}).get("chapters", []):
|
|
|
|
|
for placement, ref in enumerate(chapter.get("scene_refs", []), start=1):
|
|
|
|
|
scene = StandaloneScene.objects.get(pk=ref["scene_id"], work=state.work)
|
|
|
|
|
packets.append(
|
|
|
|
|
{
|
|
|
|
|
"chapter_key": chapter["chapter_key"],
|
|
|
|
|
"placement": ref.get("placement", placement),
|
|
|
|
|
"scene_key": scene.scene_key,
|
|
|
|
|
"revision": scene.revision,
|
|
|
|
|
"sha256": scene.sha256,
|
|
|
|
|
"title": scene.title,
|
|
|
|
|
"prose": scene.prose,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return packets
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _referenced_scenes(
|
|
|
|
|
state: BookStateVersion, chapter: dict[str, Any]
|
|
|
|
|
) -> list[StandaloneScene]:
|
|
|
|
|
references = chapter.get("scene_refs") or []
|
|
|
|
|
if not references:
|
|
|
|
|
return []
|
|
|
|
|
scenes = {
|
|
|
|
|
str(scene.id): scene
|
|
|
|
|
for scene in StandaloneScene.objects.filter(
|
|
|
|
|
work=state.work,
|
|
|
|
|
id__in=[reference["scene_id"] for reference in references],
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
missing = [
|
|
|
|
|
str(reference["scene_id"])
|
|
|
|
|
for reference in references
|
|
|
|
|
if str(reference["scene_id"]) not in scenes
|
|
|
|
|
]
|
|
|
|
|
if missing:
|
|
|
|
|
raise ValueError("referenced scenes no longer exist: " + ", ".join(missing))
|
|
|
|
|
return [scenes[str(reference["scene_id"])] for reference in references]
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _latest_bound_scenes(state: BookStateVersion, chapter_key: str) -> list[StandaloneScene]:
|
|
|
|
|
scenes = StandaloneScene.objects.filter(
|
|
|
|
|
work=state.work,
|
|
|
|
|
book_state=state,
|
|
|
|
|
book_chapter_key=chapter_key,
|
|
|
|
|
).order_by("scene_key", "-revision", "-created_at")
|
|
|
|
|
latest: dict[str, StandaloneScene] = {}
|
|
|
|
|
for scene in scenes:
|
|
|
|
|
latest.setdefault(scene.scene_key, scene)
|
|
|
|
|
return list(latest.values())
|
|
|
|
|
|
|
|
|
|
def _chapter_progress(self, state: BookStateVersion, chapter: dict[str, Any]) -> str:
|
|
|
|
|
scenes = self._referenced_scenes(state, chapter)
|
|
|
|
|
if not scenes:
|
|
|
|
|
scenes = self._latest_bound_scenes(state, chapter["chapter_key"])
|
|
|
|
|
if not scenes:
|
|
|
|
|
return "needs_scene"
|
|
|
|
|
if len(scenes) > 1 and not chapter.get("scene_refs"):
|
|
|
|
|
return "ambiguous_scenes"
|
|
|
|
|
if all(scene.status == SceneDraftStatus.APPROVED for scene in scenes):
|
|
|
|
|
return "complete"
|
|
|
|
|
statuses = sorted({str(scene.status) for scene in scenes})
|
|
|
|
|
return statuses[0] if len(statuses) == 1 else "mixed_scene_status"
|
|
|
|
|
|
|
|
|
|
def _run_scene_packets(self, state: BookStateVersion) -> list[dict[str, Any]]:
|
|
|
|
|
packets = []
|
|
|
|
|
for chapter in (state.content or {}).get("chapters", []):
|
|
|
|
|
scenes = self._referenced_scenes(state, chapter)
|
|
|
|
|
if not scenes:
|
|
|
|
|
scenes = self._latest_bound_scenes(state, chapter["chapter_key"])
|
|
|
|
|
for placement, scene in enumerate(scenes, start=1):
|
|
|
|
|
packets.append(
|
|
|
|
|
{
|
|
|
|
|
"chapter_key": chapter["chapter_key"],
|
|
|
|
|
"placement": placement,
|
|
|
|
|
"scene_id": str(scene.id),
|
|
|
|
|
"scene_key": scene.scene_key,
|
|
|
|
|
"revision": scene.revision,
|
|
|
|
|
"sha256": scene.sha256,
|
|
|
|
|
"title": scene.title,
|
|
|
|
|
"prose": scene.prose,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return packets
|
|
|
|
|
|
|
|
|
|
def _run_reviews_pass(self, run: BookRun, state: BookStateVersion) -> bool:
|
|
|
|
|
review = (run.reviews or {}).get("continuity") or {}
|
|
|
|
|
scene_set_sha256 = _sha256(_canonical(self._run_scene_packets(state)))
|
|
|
|
|
return bool(
|
|
|
|
|
review.get("passed")
|
|
|
|
|
and review.get("state_sha256") == self._state_hash(state)
|
|
|
|
|
and review.get("scene_set_sha256") == scene_set_sha256
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _scene_manifest(scene_packets: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"chapter_key": packet["chapter_key"],
|
|
|
|
|
"placement": packet["placement"],
|
|
|
|
|
"scene_id": packet["scene_id"],
|
|
|
|
|
"scene_key": packet["scene_key"],
|
|
|
|
|
"revision": packet["revision"],
|
|
|
|
|
"sha256": packet["sha256"],
|
|
|
|
|
}
|
|
|
|
|
for packet in scene_packets
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _required_reviews(content: dict[str, Any]) -> list[str]:
|
|
|
|
|
levels = [*[f"act:{item['act_id']}" for item in content.get("acts", [])], "manuscript"]
|
|
|
|
|
if any(item.get("scene_refs") for item in content.get("chapters", [])):
|
|
|
|
|
levels.append("continuity")
|
|
|
|
|
return levels
|
|
|
|
|
|
|
|
|
|
def _reviews_pass(self, state: BookStateVersion, levels: list[str]) -> bool:
|
|
|
|
|
reviews = _value(state, "reviews", "review", default={}) or {}
|
|
|
|
|
state_hash = self._state_hash(state)
|
|
|
|
|
return all(
|
|
|
|
|
reviews.get(level, {}).get("passed")
|
|
|
|
|
and reviews[level].get("state_sha256") == state_hash
|
|
|
|
|
for level in levels
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _state_hash(state: BookStateVersion) -> str:
|
|
|
|
|
return _value(state, "sha256", default="") or _sha256(
|
|
|
|
|
_canonical(_value(state, "content", default={}))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _artifact_root(self, state: BookStateVersion) -> Path:
|
|
|
|
|
story = getattr(state.work, "story_project", None)
|
|
|
|
|
if story and story.artifact_root.strip():
|
|
|
|
|
return Path(story.artifact_root) / "book-states"
|
|
|
|
|
return (
|
|
|
|
|
Path(settings.BASE_DIR)
|
|
|
|
|
/ "artifacts"
|
|
|
|
|
/ "stories"
|
|
|
|
|
/ state.work.series.slug
|
|
|
|
|
/ state.work.slug
|
|
|
|
|
/ "book-states"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _write_artifacts(self, state: BookStateVersion) -> None:
|
|
|
|
|
stem = f"v{state.version:04d}"
|
|
|
|
|
content = _value(state, "content", default={})
|
|
|
|
|
payload = {
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
"work_id": str(state.work_id),
|
|
|
|
|
"version": state.version,
|
|
|
|
|
"status": str(_value(state, "status", default="")),
|
|
|
|
|
"sha256": self._state_hash(state),
|
|
|
|
|
"parent_id": str(_value(state, "parent_id", default="") or ""),
|
|
|
|
|
"change_summary": _value(state, "change_summary", default={}) or {},
|
|
|
|
|
"context_pack": _value(state, "context_pack", default={}) or {},
|
|
|
|
|
"context_pack_sha256": _value(state, "context_pack_sha256", default=""),
|
|
|
|
|
"generation_metadata": _value(state, "generation_metadata", default={}) or {},
|
|
|
|
|
"created_by": _value(state, "created_by", default=""),
|
|
|
|
|
"validation": _value(state, "validation", "validation_result", default={}) or {},
|
|
|
|
|
"reviews": _value(state, "reviews", "review", default={}) or {},
|
|
|
|
|
"approved_at": str(_value(state, "approved_at", default="") or ""),
|
|
|
|
|
"approved_by": _value(state, "approved_by", default=""),
|
|
|
|
|
"approval_notes": _value(state, "approval_notes", default=""),
|
|
|
|
|
"approval_forced": bool(_value(state, "approval_forced", default=False)),
|
|
|
|
|
"rejected_at": str(_value(state, "rejected_at", default="") or ""),
|
|
|
|
|
"rejected_by": _value(state, "rejected_by", default=""),
|
|
|
|
|
"rejection_notes": _value(state, "rejection_notes", default=""),
|
|
|
|
|
"content": content,
|
|
|
|
|
}
|
|
|
|
|
json_path = (
|
|
|
|
|
Path(state.json_artifact_uri)
|
|
|
|
|
if state.json_artifact_uri
|
|
|
|
|
else self._artifact_root(state) / f"{stem}.json"
|
|
|
|
|
)
|
|
|
|
|
markdown_path = (
|
|
|
|
|
Path(state.markdown_artifact_uri)
|
|
|
|
|
if state.markdown_artifact_uri
|
|
|
|
|
else self._artifact_root(state) / f"{stem}.md"
|
|
|
|
|
)
|
|
|
|
|
atomic_write_text(json_path, _canonical(payload, pretty=True) + "\n")
|
|
|
|
|
lines = [
|
|
|
|
|
f"# {content.get('title') or state.work.title}",
|
|
|
|
|
"",
|
|
|
|
|
f"Book state version: {state.version}",
|
|
|
|
|
f"Status: {payload['status']}",
|
|
|
|
|
f"SHA-256: `{payload['sha256']}`",
|
|
|
|
|
"",
|
|
|
|
|
"## Premise",
|
|
|
|
|
"",
|
|
|
|
|
str(content.get("premise") or ""),
|
|
|
|
|
"",
|
|
|
|
|
"## Chapters",
|
|
|
|
|
"",
|
|
|
|
|
]
|
|
|
|
|
for chapter in content.get("chapters", []):
|
|
|
|
|
lines.extend(
|
|
|
|
|
[
|
|
|
|
|
f"### {chapter.get('number')}. {chapter.get('title')}",
|
|
|
|
|
"",
|
|
|
|
|
str(chapter.get("purpose") or ""),
|
|
|
|
|
"",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
atomic_write_text(markdown_path, "\n".join(lines).rstrip() + "\n")
|
|
|
|
|
fields = []
|
|
|
|
|
for value, names in (
|
|
|
|
|
(str(json_path), ("json_artifact_uri", "artifact_uri")),
|
|
|
|
|
(str(markdown_path), ("markdown_artifact_uri",)),
|
|
|
|
|
):
|
|
|
|
|
field = _assign(state, value, *names)
|
|
|
|
|
if field:
|
|
|
|
|
fields.append(field)
|
|
|
|
|
if fields:
|
|
|
|
|
state.save(update_fields=[*fields, "updated_at"])
|