Artifex/control_plane/authoring/state_management.py

280 lines
11 KiB
Python
Raw Normal View History

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)