Spaces:
Sleeping
Sleeping
feat(phase0): add run_critic_gate CLI with rich output and fixture saving
Browse files
viral_script_engine/scripts/run_critic_gate.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
import argparse
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
from rich.console import Console
|
| 9 |
+
from rich.table import Table
|
| 10 |
+
from rich import box
|
| 11 |
+
from rich.panel import Panel
|
| 12 |
+
|
| 13 |
+
load_dotenv()
|
| 14 |
+
|
| 15 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 16 |
+
|
| 17 |
+
from viral_script_engine.agents.critic import CriticAgent, CriticParseError
|
| 18 |
+
from viral_script_engine.evaluation.critic_evaluator import CriticEvaluator
|
| 19 |
+
|
| 20 |
+
console = Console()
|
| 21 |
+
BASE_DIR = Path(__file__).parent.parent
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_scripts(dry_run: bool) -> list:
|
| 25 |
+
scripts_path = BASE_DIR / "data" / "test_scripts" / "scripts.json"
|
| 26 |
+
with open(scripts_path) as f:
|
| 27 |
+
scripts = json.load(f)
|
| 28 |
+
if dry_run:
|
| 29 |
+
scripts = scripts[:2]
|
| 30 |
+
return scripts
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def run_gate(max_retries: int = 3, dry_run: bool = False) -> bool:
|
| 34 |
+
agent = CriticAgent()
|
| 35 |
+
evaluator = CriticEvaluator()
|
| 36 |
+
scripts = load_scripts(dry_run)
|
| 37 |
+
|
| 38 |
+
table = Table(title="Critic Gate Results", box=box.ROUNDED)
|
| 39 |
+
table.add_column("Script ID", style="cyan", no_wrap=True)
|
| 40 |
+
table.add_column("Claims", justify="center")
|
| 41 |
+
table.add_column("Specificity", justify="center")
|
| 42 |
+
table.add_column("Falsifiability", justify="center")
|
| 43 |
+
table.add_column("Gate", justify="center")
|
| 44 |
+
|
| 45 |
+
outputs = []
|
| 46 |
+
all_results = []
|
| 47 |
+
|
| 48 |
+
with console.status("[bold green]Running CriticAgent on scripts...") as status:
|
| 49 |
+
for entry in scripts:
|
| 50 |
+
sid = entry["script_id"]
|
| 51 |
+
status.update(f"[bold green]Processing {sid}...")
|
| 52 |
+
|
| 53 |
+
critique_output = None
|
| 54 |
+
for attempt in range(max_retries):
|
| 55 |
+
try:
|
| 56 |
+
critique_output = agent.critique(
|
| 57 |
+
script=entry["script_text"],
|
| 58 |
+
region=entry["region"],
|
| 59 |
+
platform=entry["platform"],
|
| 60 |
+
niche=entry["niche"],
|
| 61 |
+
)
|
| 62 |
+
break
|
| 63 |
+
except CriticParseError as e:
|
| 64 |
+
if attempt == max_retries - 1:
|
| 65 |
+
console.print(f"[red]FAILED {sid} after {max_retries} attempts: {e}")
|
| 66 |
+
else:
|
| 67 |
+
console.print(f"[yellow]Retry {attempt + 1} for {sid}")
|
| 68 |
+
|
| 69 |
+
if critique_output is None:
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
result = evaluator.evaluate(critique_output, entry["script_text"], script_id=sid)
|
| 73 |
+
all_results.append(result)
|
| 74 |
+
outputs.append((sid, entry, critique_output))
|
| 75 |
+
|
| 76 |
+
gate_str = "[green]PASS[/green]" if result.passes_gate else "[red]FAIL[/red]"
|
| 77 |
+
table.add_row(
|
| 78 |
+
sid,
|
| 79 |
+
str(result.claim_count),
|
| 80 |
+
f"{result.specificity_score:.2f}",
|
| 81 |
+
f"{result.falsifiability_score:.2f}",
|
| 82 |
+
gate_str,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
console.print(table)
|
| 86 |
+
|
| 87 |
+
pass_count = sum(1 for r in all_results if r.passes_gate)
|
| 88 |
+
pass_rate = pass_count / len(all_results) if all_results else 0.0
|
| 89 |
+
overall_pass = pass_rate >= 0.8
|
| 90 |
+
|
| 91 |
+
if overall_pass:
|
| 92 |
+
fixtures_dir = BASE_DIR / "data" / "golden_fixtures"
|
| 93 |
+
fixtures_dir.mkdir(exist_ok=True)
|
| 94 |
+
for sid, entry, critique_output in outputs:
|
| 95 |
+
fixture_path = fixtures_dir / f"fixture_{sid}.json"
|
| 96 |
+
fixture_data = {
|
| 97 |
+
"script_id": sid,
|
| 98 |
+
"region": entry["region"],
|
| 99 |
+
"platform": entry["platform"],
|
| 100 |
+
"niche": entry["niche"],
|
| 101 |
+
"critique": critique_output.model_dump(),
|
| 102 |
+
}
|
| 103 |
+
with open(fixture_path, "w") as f:
|
| 104 |
+
json.dump(fixture_data, f, indent=2)
|
| 105 |
+
console.print(f"[green]Golden fixtures saved to {fixtures_dir}")
|
| 106 |
+
|
| 107 |
+
failing = [r.script_id for r in all_results if not r.passes_gate]
|
| 108 |
+
if failing:
|
| 109 |
+
console.print(f"[red]Failing scripts: {', '.join(failing)}")
|
| 110 |
+
for r in all_results:
|
| 111 |
+
if not r.passes_gate:
|
| 112 |
+
console.print(
|
| 113 |
+
f" [yellow]{r.script_id}[/yellow]: "
|
| 114 |
+
f"claims={r.claim_count}, specificity={r.specificity_score:.2f}, "
|
| 115 |
+
f"falsifiability={r.falsifiability_score:.2f}"
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
gate_label = f"PHASE 0 GATE: {'PASS' if overall_pass else 'FAIL'}"
|
| 119 |
+
style = "bold green" if overall_pass else "bold red"
|
| 120 |
+
console.print(Panel(f"[{style}]{gate_label}[/{style}] ({pass_count}/{len(all_results)} scripts passed)"))
|
| 121 |
+
|
| 122 |
+
return overall_pass
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def main():
|
| 126 |
+
parser = argparse.ArgumentParser(description="Run Critic quality gate")
|
| 127 |
+
parser.add_argument("--max-retries", type=int, default=3)
|
| 128 |
+
parser.add_argument("--dry-run", action="store_true")
|
| 129 |
+
args = parser.parse_args()
|
| 130 |
+
|
| 131 |
+
passed = run_gate(max_retries=args.max_retries, dry_run=args.dry_run)
|
| 132 |
+
sys.exit(0 if passed else 1)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
if __name__ == "__main__":
|
| 136 |
+
main()
|
viral_script_engine/tests/test_critic.py
CHANGED
|
@@ -87,3 +87,24 @@ def test_evaluator_fails_low_specificity():
|
|
| 87 |
evaluator = CriticEvaluator()
|
| 88 |
result = evaluator.evaluate(output, SCRIPT_TEXT)
|
| 89 |
assert result.passes_gate is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
evaluator = CriticEvaluator()
|
| 88 |
result = evaluator.evaluate(output, SCRIPT_TEXT)
|
| 89 |
assert result.passes_gate is False
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ββ Task 5: CLI exit-code test ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 93 |
+
|
| 94 |
+
import os
|
| 95 |
+
import subprocess
|
| 96 |
+
import sys
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_cli_dry_run_exits_zero_or_one():
|
| 100 |
+
"""CLI must exit 0 (pass) or 1 (fail) β never crash with unhandled exception."""
|
| 101 |
+
result = subprocess.run(
|
| 102 |
+
[sys.executable, "scripts/run_critic_gate.py", "--dry-run"],
|
| 103 |
+
capture_output=True,
|
| 104 |
+
text=True,
|
| 105 |
+
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
|
| 106 |
+
env={**os.environ, "ANTHROPIC_API_KEY": "sk-fake-key-for-test"},
|
| 107 |
+
)
|
| 108 |
+
assert result.returncode in (0, 1), (
|
| 109 |
+
f"Unexpected exit code: {result.returncode}\nSTDERR: {result.stderr}"
|
| 110 |
+
)
|