AI-Puppet-Theater / finetune /scripts /audit_actor_sft.py
ShubhamSetia's picture
finetune: add Actor SFT v1 audit and eval hardening
34e3cf7
Raw
History Blame
11.9 kB
#!/usr/bin/env python3
"""Strict local audit for Actor SFT assistant JSON completions."""
from __future__ import annotations
import argparse
import json
from collections import Counter
from pathlib import Path
from typing import Any
DEFAULT_PATH = Path("finetune/data/actor_sft_v1.jsonl")
REQUIRED_FIELDS = [
"intent",
"line",
"emotion",
"gesture",
"stage_effect",
"memory_update",
"tool_request",
]
REQUIRED_FIELD_SET = set(REQUIRED_FIELDS)
FORBIDDEN_TOP_LEVEL_FIELDS = {
"memory_record",
"memory_effect",
"recent_transcript",
"show_state",
"held_props",
"mood",
"name",
"latest_prop",
"latest_audience_action",
"tool_results",
"status",
"result",
"notes",
"current_show_phase",
}
ALLOWED_TOOLS = {"inspect_prop", "consult_stage_oracle", "change_lighting"}
SUMMARY_ISSUES = [
"assistant_invalid_json",
"assistant_non_object_json",
"assistant_json_does_not_start_with_object",
"assistant_json_does_not_end_with_object",
"assistant_contains_markdown",
"assistant_continuation_detected",
"assistant_extra_whitespace_before_or_after_json",
"missing_required_fields",
"extra_top_level_fields",
"duplicate_top_level_keys",
"forbidden_top_level_fields",
"line_missing_or_empty",
"tool_request_keys_not_exact",
"tool_request_not_object_or_null",
"tool_request_invalid_tool",
"tool_request_args_not_object",
"tool_request_args_not_exact_for_inspect_prop",
"tool_request_args_not_exact_for_consult_stage_oracle",
"tool_request_args_not_exact_for_change_lighting",
"tool_request_reason_missing_or_empty",
"finale_output_outside_finale_context",
"finale_context_unavailable",
]
# Match the current app runtime schema in puppet_theater/tools.py.
TOOL_ARGS = {
"inspect_prop": {"prop"},
"consult_stage_oracle": {"question"},
"change_lighting": {"mood"},
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path", nargs="?", type=Path, default=DEFAULT_PATH)
parser.add_argument("--max-examples", type=int, default=20)
args = parser.parse_args()
stats = audit_file(args.path, args.max_examples)
print_summary(args.path, stats)
if stats["failure_rows"]:
raise SystemExit(1)
def audit_file(path: Path, max_examples: int) -> dict[str, Any]:
stats: dict[str, Any] = {
"total_rows": 0,
"failure_rows": 0,
"issue_counts": Counter(),
"row_type_counts": Counter(),
"tool_counts": Counter(),
"examples": [],
}
with path.open("r", encoding="utf-8") as handle:
for line_number, line in enumerate(handle, start=1):
if not line.strip():
continue
stats["total_rows"] += 1
row_errors: list[str] = []
try:
row = json.loads(line)
except json.JSONDecodeError as exc:
row_errors.append(f"row_invalid_json: {exc}")
record_row_errors(stats, max_examples, line_number, None, None, row_errors)
continue
row_id = row.get("id") if isinstance(row, dict) else None
row_type = row.get("row_type") if isinstance(row, dict) else None
if isinstance(row_type, str):
stats["row_type_counts"][row_type] += 1
assistant_content = extract_assistant_content(row, row_errors)
show_state = extract_show_state(row)
if assistant_content is None:
record_row_errors(stats, max_examples, line_number, row_id, row_type, row_errors)
continue
row_errors.extend(validate_exact_json_text(assistant_content))
parsed, duplicates, parse_error = parse_json_object_with_duplicate_keys(assistant_content)
if parse_error is not None:
row_errors.append(f"assistant_invalid_json: {parse_error}")
elif not isinstance(parsed, dict):
row_errors.append("assistant_non_object_json")
else:
row_errors.extend(validate_assistant_object(parsed, duplicates, show_state))
tool_request = parsed.get("tool_request")
if isinstance(tool_request, dict):
stats["tool_counts"][tool_request.get("tool", "invalid")] += 1
elif tool_request is None:
stats["tool_counts"]["none"] += 1
else:
stats["tool_counts"]["invalid"] += 1
record_row_errors(stats, max_examples, line_number, row_id, row_type, row_errors)
return stats
def extract_assistant_content(row: Any, errors: list[str]) -> str | None:
if not isinstance(row, dict):
errors.append("row_non_object")
return None
messages = row.get("messages")
if not isinstance(messages, list) or len(messages) < 3:
errors.append("messages_missing_assistant")
return None
assistant_message = messages[2]
if not isinstance(assistant_message, dict) or assistant_message.get("role") != "assistant":
errors.append("assistant_message_invalid")
return None
content = assistant_message.get("content")
if not isinstance(content, str):
errors.append("assistant_content_not_string")
return None
return content
def validate_exact_json_text(content: str) -> list[str]:
errors: list[str] = []
stripped = content.strip()
if content != stripped:
errors.append("assistant_extra_whitespace_before_or_after_json")
if not stripped.startswith("{"):
errors.append("assistant_json_does_not_start_with_object")
if not stripped.endswith("}"):
errors.append("assistant_json_does_not_end_with_object")
if "```" in stripped or stripped.startswith("`"):
errors.append("assistant_contains_markdown")
lowered = stripped.lower()
if "\nassistant" in lowered or "### assistant" in lowered or "<|assistant" in lowered:
errors.append("assistant_continuation_detected")
return errors
def parse_json_object_with_duplicate_keys(content: str) -> tuple[Any, list[str], str | None]:
duplicate_keys: list[str] = []
def hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
seen: set[str] = set()
for key, _value in pairs:
if key in seen and key not in duplicate_keys:
duplicate_keys.append(key)
seen.add(key)
return dict(pairs)
try:
return json.loads(content, object_pairs_hook=hook), duplicate_keys, None
except json.JSONDecodeError as exc:
return None, duplicate_keys, str(exc)
def validate_assistant_object(value: dict[str, Any], duplicate_keys: list[str], show_state: dict[str, Any] | None) -> list[str]:
errors: list[str] = []
keys = set(value)
missing = sorted(REQUIRED_FIELD_SET - keys)
extra = sorted(keys - REQUIRED_FIELD_SET)
forbidden = sorted(FORBIDDEN_TOP_LEVEL_FIELDS & keys)
if missing:
errors.append(f"missing_required_fields={missing}")
if extra:
errors.append(f"extra_top_level_fields={extra}")
if duplicate_keys:
errors.append(f"duplicate_top_level_keys={sorted(duplicate_keys)}")
if forbidden:
errors.append(f"forbidden_top_level_fields={forbidden}")
if "line" in value and (not isinstance(value["line"], str) or not value["line"].strip()):
errors.append("line_missing_or_empty")
errors.extend(validate_tool_request(value.get("tool_request")))
if value.get("intent") == "deliver_finale" or value.get("stage_effect") == "final_bow_lights":
errors.extend(validate_finale_context(show_state))
return errors
def validate_tool_request(value: Any) -> list[str]:
if value is None:
return []
if not isinstance(value, dict):
return ["tool_request_not_object_or_null"]
errors: list[str] = []
keys = set(value)
if keys != {"tool", "args", "reason"}:
errors.append(f"tool_request_keys_not_exact={sorted(keys)}")
tool = value.get("tool")
if tool not in ALLOWED_TOOLS:
errors.append(f"tool_request_invalid_tool={tool!r}")
return errors
args = value.get("args")
if not isinstance(args, dict):
errors.append("tool_request_args_not_object")
return errors
expected_args = TOOL_ARGS[tool]
if set(args) != expected_args:
errors.append(f"tool_request_args_not_exact_for_{tool}: expected={sorted(expected_args)} got={sorted(args)}")
for key, arg_value in args.items():
if not isinstance(arg_value, str) or not arg_value.strip():
errors.append(f"tool_request_arg_invalid={key}")
reason = value.get("reason")
if not isinstance(reason, str) or not reason.strip():
errors.append("tool_request_reason_missing_or_empty")
return errors
def validate_finale_context(show_state: dict[str, Any] | None) -> list[str]:
if show_state is None:
return ["finale_context_unavailable"]
if show_state.get("story_phase") == "finale" or show_state.get("finale_requested") is True:
return []
return ["finale_output_outside_finale_context"]
def extract_show_state(row: Any) -> dict[str, Any] | None:
if not isinstance(row, dict):
return None
messages = row.get("messages")
if not isinstance(messages, list) or len(messages) < 2 or not isinstance(messages[1], dict):
return None
content = messages[1].get("content")
if not isinstance(content, str):
return None
marker = "show_state JSON:"
next_marker = "\nactor JSON:"
if marker not in content:
return None
start = content.index(marker) + len(marker)
end = content.find(next_marker, start)
raw_json = content[start:end if end != -1 else None].strip()
try:
value = json.loads(raw_json)
except json.JSONDecodeError:
return None
return value if isinstance(value, dict) else None
def record_row_errors(
stats: dict[str, Any],
max_examples: int,
line_number: int,
row_id: Any,
row_type: Any,
errors: list[str],
) -> None:
if not errors:
return
stats["failure_rows"] += 1
for error in errors:
stats["issue_counts"][issue_name(error)] += 1
if len(stats["examples"]) < max_examples:
stats["examples"].append(
{
"line_number": line_number,
"id": row_id,
"row_type": row_type,
"errors": errors,
}
)
def issue_name(error: str) -> str:
return error.split("=", 1)[0].split(":", 1)[0]
def print_summary(path: Path, stats: dict[str, Any]) -> None:
print(f"file: {path}")
print(f"total rows: {stats['total_rows']}")
print(f"strict failure rows: {stats['failure_rows']}")
print_distribution("row_type distribution", stats["row_type_counts"])
print_distribution("tool_request distribution", stats["tool_counts"])
print_issue_distribution(stats["issue_counts"])
if stats["examples"]:
print("examples:")
for example in stats["examples"]:
print(
f"- line {example['line_number']} id={example['id']} "
f"row_type={example['row_type']}: {'; '.join(example['errors'])}"
)
def print_distribution(title: str, values: Counter) -> None:
print(f"{title}:")
if not values:
print(" none")
return
for key, count in sorted(values.items()):
print(f" {key}: {count}")
def print_issue_distribution(values: Counter) -> None:
print("issue distribution:")
for key in SUMMARY_ISSUES:
print(f" {key}: {values.get(key, 0)}")
extra_keys = sorted(key for key in values if key not in SUMMARY_ISSUES)
for key in extra_keys:
print(f" {key}: {values[key]}")
if __name__ == "__main__":
main()