279 lines
11 KiB
Python
279 lines
11 KiB
Python
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)
|