| """
|
| vn_validate.py β single source of truth for "is this a well-formed VN turn".
|
|
|
| Used two ways:
|
| 1. Dataset filter β tools/build_sft_dataset.py gates every trace record
|
| (and later every DeepSeek-transformed example) through validate_turn.
|
| 2. Eval metric β held-out prompts are scored on format validity,
|
| cast-lock violations, beat length, and cross-turn n-gram overlap.
|
|
|
| All parsing reuses the engine's own parsers (vn_engine) and the canonical
|
| enums (vn_prompt / vn_contracts) β nothing is reimplemented, so a record
|
| that validates here is exactly a record the engine would accept at runtime.
|
| """
|
| from __future__ import annotations
|
| import json
|
| import re
|
| from dataclasses import dataclass, field
|
| from typing import Optional, Union
|
|
|
| from vn_contracts import STANDARD_EXPRESSIONS
|
| from vn_prompt import MOOD_TAGS, BACKGROUND_KEYS
|
| from vn_engine import (parse_tool_calls, _check_cast_lock,
|
| _find_cast_violations, _loads_lenient, _TOOL_HANDLERS)
|
|
|
|
|
| _CAST_TOOLS = {"set_expression", "enter_character", "exit_character"}
|
|
|
|
|
| _CAST_LINE_RE = re.compile(r'^- (.+?) \([^)]*\):', re.MULTILINE)
|
|
|
|
|
| @dataclass
|
| class ValidationResult:
|
| ok: bool
|
| reasons: list[str] = field(default_factory=list)
|
| stats: dict = field(default_factory=dict)
|
|
|
|
|
| def cast_names_from_system(system_text: str) -> list[str]:
|
| """Extract the locked cast names from a rendered system prompt.
|
|
|
| The cast block renders as "- Name (role): personality" lines (see
|
| vn_prompt.build_cast_descriptions). Used by the dataset exporter, where
|
| only the trace's message text is available β not a live cast dict.
|
| """
|
| return _CAST_LINE_RE.findall(system_text or "")
|
|
|
|
|
| def _as_cast(system_or_cast: Union[str, dict, list, set, None]) -> dict:
|
| """Normalize the cast argument to a name->member dict.
|
|
|
| Accepts a cast dict (name -> CastMember), an iterable of names, or a
|
| rendered system-prompt string (names are parsed out of it). Values may
|
| be None when only names are known β expression checks then fall back to
|
| STANDARD_EXPRESSIONS instead of the member's expression_map.
|
| """
|
| if isinstance(system_or_cast, dict):
|
| return system_or_cast
|
| if isinstance(system_or_cast, str):
|
| return {name: None for name in cast_names_from_system(system_or_cast)}
|
| if system_or_cast:
|
| return {name: None for name in system_or_cast}
|
| return {}
|
|
|
|
|
| def ngram_overlap(text: str, history: str, n: int = 4) -> Optional[float]:
|
| """Fraction of `text`'s word n-grams that already appear in `history`.
|
|
|
| The repetition signal: high overlap means the turn re-narrates what the
|
| player already read. None when either side is too short to compare.
|
| """
|
| def grams(s: str) -> set[tuple[str, ...]]:
|
| words = re.findall(r"[\w']+", s.lower())
|
| return {tuple(words[i:i + n]) for i in range(len(words) - n + 1)}
|
|
|
| text_grams = grams(text)
|
| hist_grams = grams(history)
|
| if not text_grams or not hist_grams:
|
| return None
|
| return len(text_grams & hist_grams) / len(text_grams)
|
|
|
|
|
| def _strip_tools(completion: str) -> str:
|
| """The narration-only text β balanced [TOOL: ...] spans removed (a tool
|
| call may span lines when the model pretty-prints its choices JSON)."""
|
| out: list[str] = []
|
| i, n = 0, len(completion)
|
| while i < n:
|
| start = completion.find("[TOOL:", i)
|
| if start == -1:
|
| out.append(completion[i:])
|
| break
|
| out.append(completion[i:start])
|
|
|
|
|
| depth, in_str, esc = 1, False, False
|
| end = -1
|
| for j in range(start + len("[TOOL:"), n):
|
| c = completion[j]
|
| if esc:
|
| esc = False
|
| elif c == '\\':
|
| esc = True
|
| elif in_str:
|
| if c == '"':
|
| in_str = False
|
| elif c == '"':
|
| in_str = True
|
| elif c == '[':
|
| depth += 1
|
| elif c == ']':
|
| depth -= 1
|
| if depth == 0:
|
| end = j + 1
|
| break
|
| if end == -1:
|
| nl = completion.find("\n", start)
|
| end = n if nl == -1 else nl
|
| i = end
|
| return "".join(out)
|
|
|
|
|
| def validate_turn(system_or_cast, completion: str,
|
| history: str = "") -> ValidationResult:
|
| """Validate one assistant turn against the VN protocol.
|
|
|
| Args:
|
| system_or_cast: the locked cast β a name->CastMember dict, an
|
| iterable of names, or the rendered system prompt (names parsed).
|
| completion: the assistant's raw turn text.
|
| history: optional prior story text; when given, the cross-turn
|
| n-gram overlap is recorded in stats (non-fatal).
|
|
|
| Returns:
|
| ValidationResult(ok, reasons, stats). `ok` is False only on protocol
|
| violations; style metrics are recorded in stats but never fail.
|
| """
|
| cast = _as_cast(system_or_cast)
|
| reasons: list[str] = []
|
| stats: dict = {}
|
|
|
| if not (completion or "").strip():
|
| return ValidationResult(False, ["empty completion"], stats)
|
| if not cast:
|
| reasons.append("no cast available to validate against")
|
|
|
|
|
|
|
|
|
| if re.search('\u00e2\u20ac|[\u00c2\u00c3\u00e2][\u0080-\u00bf]', completion):
|
| reasons.append("mojibake: UTF-8/Latin-1 double-decoding artifacts")
|
|
|
|
|
| calls = parse_tool_calls(completion)
|
| n_openers = completion.count("[TOOL:")
|
| if len(calls) < n_openers:
|
| reasons.append(f"malformed tool call: {n_openers - len(calls)} of "
|
| f"{n_openers} [TOOL: openers failed to parse")
|
| stats["tool_calls"] = len(calls)
|
|
|
| choices_ok = False
|
| has_choices = False
|
| for call in calls:
|
| tool = call.get("tool", "")
|
| if tool not in _TOOL_HANDLERS:
|
| reasons.append(f"unknown tool: {tool!r}")
|
| continue
|
|
|
| if tool in _CAST_TOOLS:
|
| char = str(call.get("character", ""))
|
| if cast and not _check_cast_lock(char, cast):
|
| reasons.append(f"{tool}: character {char!r} not in cast")
|
|
|
| if tool == "set_expression":
|
| expr = str(call.get("expression", ""))
|
| char = _check_cast_lock(str(call.get("character", "")), cast) if cast else None
|
| member = cast.get(char) if char else None
|
| valid = (member.expression_map.keys()
|
| if member is not None and getattr(member, "expression_map", None)
|
| else STANDARD_EXPRESSIONS)
|
| if expr not in valid:
|
| reasons.append(f"set_expression: invalid expression {expr!r}")
|
|
|
| if tool in ("advance_scene", "set_background"):
|
| bg = str(call.get("background", "")) or None
|
| if bg and bg not in BACKGROUND_KEYS:
|
| reasons.append(f"{tool}: invalid background {bg!r}")
|
| if tool == "advance_scene":
|
| mood = str(call.get("mood", "")) or None
|
| if mood and mood not in MOOD_TAGS:
|
| reasons.append(f"advance_scene: invalid mood {mood!r}")
|
|
|
| if tool == "offer_choices":
|
| has_choices = True
|
| blob = call.get("choices", "")
|
| try:
|
| data = _loads_lenient(blob) if blob else None
|
| except json.JSONDecodeError:
|
| data = None
|
| if not isinstance(data, list) or not data:
|
| reasons.append("offer_choices: choices JSON unparseable or empty")
|
| continue
|
| ids = [str(c.get("id", "")) for c in data if isinstance(c, dict)]
|
| if len(ids) != len(data):
|
| reasons.append("offer_choices: non-object entries in choices")
|
| if len(set(ids)) != len(ids):
|
| reasons.append("offer_choices: duplicate choice ids")
|
| if any(not (c.get("text") or "").strip()
|
| for c in data if isinstance(c, dict)):
|
| reasons.append("offer_choices: choice with empty text")
|
| if not [r for r in reasons if r.startswith("offer_choices:")]:
|
| choices_ok = True
|
| stats["choice_count"] = len(data)
|
| stats["has_choices"] = has_choices
|
|
|
|
|
| if cast:
|
| violations = _find_cast_violations(completion, cast)
|
| if violations:
|
| reasons.append(f"cast-lock: non-cast speakers {violations}")
|
| speakers = []
|
| for m in re.finditer(r'^\[(\w+)\]:\s*"', completion, re.MULTILINE):
|
| canon = _check_cast_lock(m.group(1), cast)
|
| if canon and canon not in speakers:
|
| speakers.append(canon)
|
| stats["speakers"] = speakers
|
|
|
|
|
| narration = _strip_tools(completion).strip()
|
| if has_choices and not choices_ok:
|
| pass
|
| elif not has_choices and not narration:
|
| reasons.append("turn has neither narration nor choices")
|
|
|
|
|
| beats = [b.strip() for b in narration.split("\n")
|
| if b.strip() and not re.match(r'^\[\w+\]:', b.strip())]
|
| stats["beats"] = len(beats)
|
| stats["max_beat_sentences"] = max(
|
| (len(re.findall(r'[.!?]+(?:\s|$)', b)) for b in beats), default=0)
|
| if history:
|
| stats["ngram_overlap"] = ngram_overlap(narration, history)
|
|
|
| return ValidationResult(ok=not reasons, reasons=reasons, stats=stats)
|
|
|