from __future__ import annotations import copy import hashlib import json import re from typing import Any def canonical_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) def json_sha256(value: Any) -> str: return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest() def normalize_entity_key(kind: str, name: str, supplied: str = "") -> str: value = supplied.strip().lower() or f"{kind}.{name}" value = re.sub(r"[^a-z0-9]+", ".", value).strip(".") return value[:200] or "book.state" def build_contract_requirements( scene_plan: dict[str, Any], *, max_required_per_scene: int | None = 3 ) -> list[dict[str, Any]]: requirements: list[dict[str, Any]] = [] for scene_index, scene in enumerate(scene_plan.get("scenes") or [], start=1): number = int(scene.get("number") or scene_index) beats = scene.get("beats") or [] requested_required = [ index for index, beat in enumerate(beats) if isinstance(beat, dict) and bool(beat.get("required")) ] allowed_required = set(requested_required) if ( max_required_per_scene is not None and len(requested_required) > max_required_per_scene ): if max_required_per_scene < 1: allowed_required = set() elif max_required_per_scene == 1: allowed_required = {requested_required[0]} elif max_required_per_scene == 2: allowed_required = {requested_required[0], requested_required[-1]} else: step = (len(requested_required) - 1) / (max_required_per_scene - 1) selected = { requested_required[round(index * step)] for index in range(max_required_per_scene) } allowed_required = selected for beat_index, beat in enumerate(beats, start=1): if isinstance(beat, dict): text = str(beat.get("text") or "") required = (beat_index - 1) in allowed_required else: text = str(beat) required = False requirements.append( { "id": f"S{number:02d}-B{beat_index:02d}", "type": "BEAT", "text": text, "severity": "HIGH" if required else "MEDIUM", "blocking": required, "required": required, } ) ending = str(scene.get("ending_state") or "").strip() if ending: requirements.append( { "id": f"S{number:02d}-END", "type": "ENDING_STATE", "text": ending, "severity": "MEDIUM", "blocking": False, "required": False, } ) groups = [ ("VALUE", "EXACT_VALUE", "exact_values", "CRITICAL", True), ("FORBID", "FORBIDDEN_EVENT", "forbidden_events", "CRITICAL", True), ("BOUNDARY", "SCENE_BOUNDARY", "boundary_constraints", "HIGH", True), ("SHORTCUT", "FORBIDDEN_SHORTCUT", "forbidden_shortcuts", "MEDIUM", False), ("CONSTRAINT", "CHAPTER_CONSTRAINT", "chapter_constraints", "MEDIUM", False), ] for prefix, kind, field, severity, blocking in groups: for index, value in enumerate(scene_plan.get(field) or [], start=1): requirements.append( { "id": f"{prefix}-{index:02d}", "type": kind, "text": str(value), "severity": severity, "blocking": blocking, "required": blocking, } ) for requirement_id, field in [("TIME-START", "day_start"), ("TIME-END", "day_end")]: value = str(scene_plan.get(field) or "").strip() if value: requirements.append( { "id": requirement_id, "type": "CHRONOLOGY", "text": value, "severity": "CRITICAL", "blocking": True, "required": True, } ) final_image = str(scene_plan.get("final_image") or "").strip() if final_image: requirements.append( { "id": "FINAL-IMAGE", "type": "FINAL_IMAGE", "text": final_image, "severity": "MEDIUM", "blocking": False, "required": False, } ) return requirements def requirement_is_blocking(requirement: dict[str, Any]) -> bool: return bool(requirement.get("blocking")) def evidence_is_present(prose: str, quote: str) -> bool: quote = quote.strip() if not quote: return False if quote in prose: return True normalized_quote = re.sub(r"[\W_]+", " ", quote.casefold()).strip() normalized_prose = re.sub(r"[\W_]+", " ", prose.casefold()).strip() if len(normalized_quote.split()) >= 4 and normalized_quote in normalized_prose: return True fragments = [ re.sub(r"[\W_]+", " ", fragment.casefold()).strip() for fragment in re.split(r"[.!?]+", quote) ] fragments = [fragment for fragment in fragments if len(fragment.split()) >= 2] if len(fragments) < 2: return False first = normalized_prose.find(fragments[0]) if first < 0: return False cursor = first + len(fragments[0]) for fragment in fragments[1:]: position = normalized_prose.find(fragment, cursor) if position < 0: return False cursor = position + len(fragment) return cursor - first <= len(normalized_quote) * 2 + 120 def apply_state_changes( prior_state: dict[str, Any], changes: list[dict[str, Any]], *, through_chapter: int, chapter_state: dict[str, Any], ) -> dict[str, Any]: if prior_state.get("schema_version") == 2 and isinstance(prior_state.get("entities"), dict): state = copy.deepcopy(prior_state) else: state = { "schema_version": 2, "through_chapter": max(0, through_chapter - 1), "entities": {}, "book": {"legacy_state": copy.deepcopy(prior_state)} if prior_state else {}, } entities = state.setdefault("entities", {}) missing = object() for change in sorted(changes, key=lambda item: int(item.get("sequence") or 0)): key = str(change.get("entity_key") or "book.state") entity = entities.setdefault( key, { "kind": str(change.get("entity_kind") or "book"), "name": str(change.get("canonical_name") or key), "facts": {}, }, ) facts = entity.setdefault("facts", {}) path = [part for part in str(change.get("predicate") or "state").split(".") if part] target = facts for part in path[:-1]: target = target.setdefault(part, {}) leaf = path[-1] if path else "state" current = target.get(leaf, missing) previous = change.get("previous_value") if current is not missing and previous is not None and current != previous: raise ValueError( f"state change {change.get('sequence')} expected {key}.{'.'.join(path)} " f"to be {previous!r}, found {current!r}" ) operation = str(change.get("operation") or "SET").upper() new_value = copy.deepcopy(change.get("new_value")) related = str(change.get("related_entity_key") or "").strip() if operation == "ADD": values = [] if current is missing or current is None else list(current) additions = new_value if isinstance(new_value, list) else [new_value] for value in additions: if value not in values: values.append(value) target[leaf] = values elif operation == "REMOVE": values = [] if current is missing or current is None else list(current) removals = new_value if isinstance(new_value, list) else [new_value] target[leaf] = [value for value in values if value not in removals] elif operation == "OPEN": target[leaf] = new_value if new_value is not None else "OPEN" elif operation == "CLOSE": target[leaf] = new_value if new_value is not None else "CLOSED" elif operation == "TRANSFER": if not related: raise ValueError( f"state change {change.get('sequence')} cannot TRANSFER without " "related_entity_key" ) if new_value not in (None, "", related): raise ValueError( f"state change {change.get('sequence')} TRANSFER destination " f"{new_value!r} does not match related entity {related!r}" ) target[leaf] = related else: target[leaf] = new_value if related: entity.setdefault("relations", {})[str(change.get("predicate") or "related")] = related state["through_chapter"] = through_chapter state["chapter_state"] = copy.deepcopy(chapter_state) return state def render_state_markdown(document: dict[str, Any]) -> str: coverage = document.get("coverage") or {} changes = document.get("proposed_delta") or [] lines = [ f"# Chapter {document.get('through_chapter', '')} State", "", f"Verdict: **{document.get('verdict') or 'PENDING'}**", "", "## Requirement Coverage", "", ] for check in coverage.get("requirements") or []: lines.append( f"- `{check.get('requirement_id', '')}` **{check.get('status', '')}**: " f"{check.get('requirement_text', '')}" ) if check.get("evidence_quote"): lines.append(f" Evidence: {check['evidence_quote']}") lines.extend(["", "## State Changes", ""]) for change in changes: lines.append( f"- `{change.get('entity_key', 'book.state')}.{change.get('predicate', 'state')}` " f"{change.get('operation', 'SET')}: {change.get('previous_value')!r} -> " f"{change.get('new_value')!r}" ) lines.extend( [ "", "## Observed State", "", "```json", json.dumps(document.get("observed_state") or {}, ensure_ascii=False, indent=2), "```", "", ] ) return "\n".join(lines)