Spaces:
Sleeping
Sleeping
Phase 7 implemented
Browse files- demo/run_demo.py +63 -21
- docs/progress.md +13 -2
- session/phase-log.md +1 -0
- session/summary.md +20 -22
- viral_script_engine/agents/reasoning_parser.py +116 -0
- viral_script_engine/environment/env.py +26 -1
- viral_script_engine/environment/observations.py +2 -0
- viral_script_engine/rewards/process_reward.py +87 -0
- viral_script_engine/rewards/process_verifier.py +150 -0
- viral_script_engine/rewards/reward_aggregator.py +3 -0
- viral_script_engine/scripts/run_baseline.py +2 -1
- viral_script_engine/scripts/run_dummy_episode.py +52 -5
- viral_script_engine/tests/test_phase7.py +420 -0
- viral_script_engine/training/rollout_function.py +13 -6
demo/run_demo.py
CHANGED
|
@@ -165,23 +165,47 @@ def act3_defender_responds(defender_out, critique_claims):
|
|
| 165 |
console.print()
|
| 166 |
|
| 167 |
|
| 168 |
-
def act4_arbitrator_decides(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
console.print(Rule("[bold blue]ACT 4 — THE ARBITRATOR DECIDES[/bold blue]", style="blue"))
|
| 170 |
if compare:
|
| 171 |
-
|
|
|
|
|
|
|
| 172 |
f"[bold]Action:[/bold] {untrained_action.get('action_type')}\n"
|
| 173 |
f"[bold]Target:[/bold] {untrained_action.get('target_section')}\n"
|
| 174 |
-
f"[bold]Instruction:[/bold] {untrained_action.get('instruction', '')[:120]}
|
| 175 |
-
f"[bold]Reasoning:[/bold] {untrained_action.get('reasoning', '')}"
|
| 176 |
)
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
u_act = untrained_action.get("action_type")
|
| 187 |
t_act = trained_action.get("action_type")
|
|
@@ -317,6 +341,7 @@ class TrainedArbitratorStub(BaselineArbitratorAgent):
|
|
| 317 |
Stand-in for the GRPO-trained Arbitrator.
|
| 318 |
Uses a richer chain-of-thought system prompt to simulate trained behaviour
|
| 319 |
when the GRPO checkpoint is not yet available.
|
|
|
|
| 320 |
"""
|
| 321 |
|
| 322 |
_TRAINED_SYSTEM = """You are an expert Arbitrator agent trained with GRPO reinforcement learning
|
|
@@ -326,10 +351,13 @@ to improve short-form video scripts. You have learned through hundreds of debate
|
|
| 326 |
3. Always balance improvement against the defender's core_strength.
|
| 327 |
4. Give specific, actionable instructions — not generic advice.
|
| 328 |
|
| 329 |
-
|
| 330 |
|
| 331 |
-
Respond ONLY with valid JSON:
|
| 332 |
{
|
|
|
|
|
|
|
|
|
|
| 333 |
"action_type": "hook_rewrite",
|
| 334 |
"target_section": "hook",
|
| 335 |
"instruction": "specific instruction for the rewriter",
|
|
@@ -337,17 +365,19 @@ Respond ONLY with valid JSON:
|
|
| 337 |
"reasoning": "detailed chain-of-thought reasoning"
|
| 338 |
}"""
|
| 339 |
|
| 340 |
-
def act(self, observation: dict) ->
|
|
|
|
| 341 |
from viral_script_engine.agents.llm_backend import LLMBackend
|
| 342 |
-
llm = LLMBackend(backend="anthropic", model_name="claude-haiku-4-5-20251001")
|
| 343 |
import json as _json
|
|
|
|
| 344 |
user_prompt = self._build_user_prompt(observation)
|
| 345 |
-
raw = llm.generate(self._TRAINED_SYSTEM, user_prompt, max_tokens=
|
| 346 |
try:
|
| 347 |
-
|
|
|
|
| 348 |
except Exception:
|
| 349 |
from viral_script_engine.agents.baseline_arbitrator import _FALLBACK_ACTION
|
| 350 |
-
return _FALLBACK_ACTION.copy()
|
| 351 |
|
| 352 |
|
| 353 |
# ---------------------------------------------------------------------------
|
|
@@ -405,8 +435,20 @@ def run_compare(script_id: str):
|
|
| 405 |
console.print("[dim]Running Untrained Arbitrator…[/dim]")
|
| 406 |
untrained_action = baseline_agent.act(fake_obs)
|
| 407 |
console.print("[dim]Running Trained Arbitrator…[/dim]")
|
| 408 |
-
trained_action = trained_agent.act(fake_obs)
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
|
| 411 |
# Act 5 — use trained action for rewrite
|
| 412 |
from viral_script_engine.environment.actions import ArbitratorAction
|
|
|
|
| 165 |
console.print()
|
| 166 |
|
| 167 |
|
| 168 |
+
def act4_arbitrator_decides(
|
| 169 |
+
untrained_action: dict,
|
| 170 |
+
trained_action: dict,
|
| 171 |
+
compare: bool,
|
| 172 |
+
trained_reasoning: dict = None,
|
| 173 |
+
):
|
| 174 |
console.print(Rule("[bold blue]ACT 4 — THE ARBITRATOR DECIDES[/bold blue]", style="blue"))
|
| 175 |
if compare:
|
| 176 |
+
# Untrained panel — no reasoning chain
|
| 177 |
+
untrained_body = (
|
| 178 |
+
"[dim][No reasoning chain — zero-shot decision][/dim]\n\n"
|
| 179 |
f"[bold]Action:[/bold] {untrained_action.get('action_type')}\n"
|
| 180 |
f"[bold]Target:[/bold] {untrained_action.get('target_section')}\n"
|
| 181 |
+
f"[bold]Instruction:[/bold] {untrained_action.get('instruction', '')[:120]}"
|
|
|
|
| 182 |
)
|
| 183 |
+
|
| 184 |
+
# Trained panel — show reasoning chain if available
|
| 185 |
+
if trained_reasoning:
|
| 186 |
+
priority = trained_reasoning.get("priority_assessment", "")
|
| 187 |
+
cf_ans = trained_reasoning.get("conflict_check_answer", "")
|
| 188 |
+
cf_rsn = trained_reasoning.get("conflict_check_reason", "")
|
| 189 |
+
df_ans = trained_reasoning.get("defender_consideration_answer", "")
|
| 190 |
+
df_rsn = trained_reasoning.get("defender_consideration_reason", "")
|
| 191 |
+
trained_body = (
|
| 192 |
+
f"[bold]Priority:[/bold] {priority}\n"
|
| 193 |
+
f"[bold]Conflict check:[/bold] {cf_ans.upper()} — {cf_rsn}\n"
|
| 194 |
+
f"[bold]Defender:[/bold] {df_ans.upper()} — {df_rsn}\n\n"
|
| 195 |
+
f"[bold]Action:[/bold] {trained_action.get('action_type')}\n"
|
| 196 |
+
f"[bold]Target:[/bold] {trained_action.get('target_section')}\n"
|
| 197 |
+
f"[bold]Instruction:[/bold] {trained_action.get('instruction', '')[:120]}"
|
| 198 |
+
)
|
| 199 |
+
else:
|
| 200 |
+
trained_body = (
|
| 201 |
+
f"[bold]Action:[/bold] {trained_action.get('action_type')}\n"
|
| 202 |
+
f"[bold]Target:[/bold] {trained_action.get('target_section')}\n"
|
| 203 |
+
f"[bold]Instruction:[/bold] {trained_action.get('instruction', '')[:120]}\n\n"
|
| 204 |
+
f"[bold]Reasoning:[/bold] {trained_action.get('reasoning', '')}"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
console.print(Panel(untrained_body, title="[dim]UNTRAINED ARBITRATOR[/dim]", border_style="dim", padding=(1, 2)))
|
| 208 |
+
console.print(Panel(trained_body, title="[blue]TRAINED ARBITRATOR[/blue]", border_style="blue", padding=(1, 2)))
|
| 209 |
|
| 210 |
u_act = untrained_action.get("action_type")
|
| 211 |
t_act = trained_action.get("action_type")
|
|
|
|
| 341 |
Stand-in for the GRPO-trained Arbitrator.
|
| 342 |
Uses a richer chain-of-thought system prompt to simulate trained behaviour
|
| 343 |
when the GRPO checkpoint is not yet available.
|
| 344 |
+
Produces the Phase 7 extended JSON format with reasoning chain fields.
|
| 345 |
"""
|
| 346 |
|
| 347 |
_TRAINED_SYSTEM = """You are an expert Arbitrator agent trained with GRPO reinforcement learning
|
|
|
|
| 351 |
3. Always balance improvement against the defender's core_strength.
|
| 352 |
4. Give specific, actionable instructions — not generic advice.
|
| 353 |
|
| 354 |
+
Before choosing your action, reason through the debate explicitly.
|
| 355 |
|
| 356 |
+
Respond ONLY with valid JSON in this exact order:
|
| 357 |
{
|
| 358 |
+
"priority_assessment": "which critique is most urgent and why — one sentence",
|
| 359 |
+
"conflict_check": "does acting on this critique risk harming any other reward signal? yes/no + reason",
|
| 360 |
+
"defender_consideration": "is the Defender's flagged concern relevant to this decision? yes/no + reason",
|
| 361 |
"action_type": "hook_rewrite",
|
| 362 |
"target_section": "hook",
|
| 363 |
"instruction": "specific instruction for the rewriter",
|
|
|
|
| 365 |
"reasoning": "detailed chain-of-thought reasoning"
|
| 366 |
}"""
|
| 367 |
|
| 368 |
+
def act(self, observation: dict) -> tuple:
|
| 369 |
+
"""Returns (action_dict, raw_output) so the caller can extract reasoning chain."""
|
| 370 |
from viral_script_engine.agents.llm_backend import LLMBackend
|
|
|
|
| 371 |
import json as _json
|
| 372 |
+
llm = LLMBackend(backend="anthropic", model_name="claude-haiku-4-5-20251001")
|
| 373 |
user_prompt = self._build_user_prompt(observation)
|
| 374 |
+
raw = llm.generate(self._TRAINED_SYSTEM, user_prompt, max_tokens=768)
|
| 375 |
try:
|
| 376 |
+
action = _json.loads(raw)
|
| 377 |
+
return action, raw
|
| 378 |
except Exception:
|
| 379 |
from viral_script_engine.agents.baseline_arbitrator import _FALLBACK_ACTION
|
| 380 |
+
return _FALLBACK_ACTION.copy(), raw
|
| 381 |
|
| 382 |
|
| 383 |
# ---------------------------------------------------------------------------
|
|
|
|
| 435 |
console.print("[dim]Running Untrained Arbitrator…[/dim]")
|
| 436 |
untrained_action = baseline_agent.act(fake_obs)
|
| 437 |
console.print("[dim]Running Trained Arbitrator…[/dim]")
|
| 438 |
+
trained_action, trained_raw = trained_agent.act(fake_obs)
|
| 439 |
+
|
| 440 |
+
# Parse reasoning chain from trained output for display
|
| 441 |
+
trained_reasoning = None
|
| 442 |
+
try:
|
| 443 |
+
from viral_script_engine.agents.reasoning_parser import ReasoningParser
|
| 444 |
+
parser = ReasoningParser()
|
| 445 |
+
chain = parser.parse(trained_raw)
|
| 446 |
+
trained_reasoning = chain.model_dump()
|
| 447 |
+
trained_reasoning.pop("action", None) # action shown separately
|
| 448 |
+
except Exception:
|
| 449 |
+
trained_reasoning = None
|
| 450 |
+
|
| 451 |
+
act4_arbitrator_decides(untrained_action, trained_action, compare=True, trained_reasoning=trained_reasoning)
|
| 452 |
|
| 453 |
# Act 5 — use trained action for rewrite
|
| 454 |
from viral_script_engine.environment.actions import ArbitratorAction
|
docs/progress.md
CHANGED
|
@@ -80,8 +80,19 @@ Do not read entire codebase to understand progress — read this file.
|
|
| 80 |
✅ test_phase6.py — 16 tests, all passing
|
| 81 |
✅ Phase 6 gate — PHASE 6 GATE: PASS, R6+R7 active, 7 total reward components
|
| 82 |
|
| 83 |
-
## Phase 7 —
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
## Phase 8 — [Pending]
|
| 87 |
⏳ [feature name] — [one line description]
|
|
|
|
| 80 |
✅ test_phase6.py — 16 tests, all passing
|
| 81 |
✅ Phase 6 gate — PHASE 6 GATE: PASS, R6+R7 active, 7 total reward components
|
| 82 |
|
| 83 |
+
## Phase 7 — Process-Aware Reward Shaping
|
| 84 |
+
✅ ReasoningParser — parses extended Arbitrator JSON with reasoning chain, graceful fallback
|
| 85 |
+
✅ ProcessVerifier — rule-based checks: priority_assessment, conflict_check, defender_consideration
|
| 86 |
+
✅ ProcessReward — weighted process score (0.40/0.35/0.25), PROCESS_WEIGHT=0.15
|
| 87 |
+
✅ RewardComponents — process_reward field added; DebateRound.reasoning_chain added
|
| 88 |
+
✅ env.py — reasoning_parser + process_reward_calc wired into step(); raw_output param
|
| 89 |
+
✅ reward_aggregator.py — process_reward added additively before anti-gaming checks
|
| 90 |
+
✅ rollout_function.py — updated ARBITRATOR_SYSTEM prompt with reasoning chain fields
|
| 91 |
+
✅ run_baseline.py — captures process_reward per step, saves to baseline_results_v2.json
|
| 92 |
+
✅ run_dummy_episode.py — shows Process Reward row + Reasoning Chain panel, Phase 7 gate
|
| 93 |
+
✅ demo/run_demo.py — Act 4 shows reasoning chain for trained vs untrained comparison
|
| 94 |
+
✅ test_phase7.py — 21 tests, all passing
|
| 95 |
+
✅ Phase 7 gate — PHASE 7 GATE: PASS, process rewards active, reasoning chain verified
|
| 96 |
|
| 97 |
## Phase 8 — [Pending]
|
| 98 |
⏳ [feature name] — [one line description]
|
session/phase-log.md
CHANGED
|
@@ -25,6 +25,7 @@ ROLLED BACK — changes reverted, reason in line
|
|
| 25 |
[2026-04-26] [Phase 4] COMPLETE — DifficultyTracker, CriticEscalationEngine, env wiring, 6 tests pass, gate PASS
|
| 26 |
[2026-04-26] [Phase 5] COMPLETE — HF deploy infra, demo, README, submission_check 10/10 PASS, demo end-to-end ok
|
| 27 |
[2026-04-26] [Phase 6] COMPLETE — ModerationAgent, OriginalityAgent, R6/R7 rewards, 16 tests PASS, gate PASS
|
|
|
|
| 28 |
|
| 29 |
---
|
| 30 |
|
|
|
|
| 25 |
[2026-04-26] [Phase 4] COMPLETE — DifficultyTracker, CriticEscalationEngine, env wiring, 6 tests pass, gate PASS
|
| 26 |
[2026-04-26] [Phase 5] COMPLETE — HF deploy infra, demo, README, submission_check 10/10 PASS, demo end-to-end ok
|
| 27 |
[2026-04-26] [Phase 6] COMPLETE — ModerationAgent, OriginalityAgent, R6/R7 rewards, 16 tests PASS, gate PASS
|
| 28 |
+
[2026-04-26] [Phase 7] COMPLETE — ReasoningParser, ProcessVerifier, ProcessReward, 21 tests PASS, gate PASS
|
| 29 |
|
| 30 |
---
|
| 31 |
|
session/summary.md
CHANGED
|
@@ -13,38 +13,36 @@ One session = one summary. Previous summaries live in phase-log.md.
|
|
| 13 |
2026-04-26
|
| 14 |
|
| 15 |
### Phase
|
| 16 |
-
Phase
|
| 17 |
|
| 18 |
### What Was Done
|
| 19 |
-
- Created
|
| 20 |
-
- Created
|
| 21 |
-
- Created
|
| 22 |
-
-
|
| 23 |
-
-
|
| 24 |
-
-
|
| 25 |
-
-
|
| 26 |
-
-
|
| 27 |
-
-
|
| 28 |
-
-
|
| 29 |
-
-
|
| 30 |
-
- Phase
|
| 31 |
|
| 32 |
### What Was NOT Done (carry over)
|
| 33 |
-
- Real GRPO training — requires GPU (Colab)
|
| 34 |
-
-
|
| 35 |
-
- Team name update in README.md and openenv.yaml
|
| 36 |
|
| 37 |
### Errors Encountered
|
| 38 |
-
-
|
| 39 |
-
-
|
| 40 |
-
-
|
| 41 |
-
- test_escalation.py / test_training_pipeline.py: class-level monkey-patches leaked into later tests → fixed with monkeypatch fixture
|
| 42 |
|
| 43 |
### Tests Status
|
| 44 |
-
Phase
|
| 45 |
|
| 46 |
### Commit Messages Generated
|
| 47 |
-
feat(
|
| 48 |
|
| 49 |
---
|
| 50 |
|
|
|
|
| 13 |
2026-04-26
|
| 14 |
|
| 15 |
### Phase
|
| 16 |
+
Phase 7 — Process-Aware Reward Shaping
|
| 17 |
|
| 18 |
### What Was Done
|
| 19 |
+
- Created agents/reasoning_parser.py — ReasoningChain Pydantic model + ReasoningParser; graceful fallback when fields absent
|
| 20 |
+
- Created rewards/process_verifier.py — 3 rule-based checks (priority, conflict, defender), no LLM calls
|
| 21 |
+
- Created rewards/process_reward.py — ProcessReward with PROCESS_WEIGHT=0.15, weights 0.40/0.35/0.25
|
| 22 |
+
- Updated environment/observations.py — process_reward field in RewardComponents; reasoning_chain in DebateRound
|
| 23 |
+
- Updated environment/env.py — reasoning_parser + process_reward_calc in __init__; step() takes raw_output kwarg
|
| 24 |
+
- Updated rewards/reward_aggregator.py — adds process_reward to total before anti-gaming checks
|
| 25 |
+
- Updated training/rollout_function.py — ARBITRATOR_SYSTEM prompt now includes reasoning chain fields
|
| 26 |
+
- Updated scripts/run_baseline.py — captures process_reward, saves to baseline_results_v2.json
|
| 27 |
+
- Updated scripts/run_dummy_episode.py — Process Reward row, Reasoning Chain panel, Phase 7 gate
|
| 28 |
+
- Updated demo/run_demo.py — Act 4 shows reasoning chain; TrainedArbitratorStub uses extended format
|
| 29 |
+
- Created tests/test_phase7.py — 21 tests, all passing
|
| 30 |
+
- Phase 7 gate: PHASE 7 GATE: PASS
|
| 31 |
|
| 32 |
### What Was NOT Done (carry over)
|
| 33 |
+
- Real GRPO training — requires GPU (Colab)
|
| 34 |
+
- Baseline v2 run — requires Anthropic API key (run separately)
|
|
|
|
| 35 |
|
| 36 |
### Errors Encountered
|
| 37 |
+
- env integration tests needed multi-mock (Critic vs Defender return different schemas) — fixed with _multi_mock
|
| 38 |
+
- run_dummy_episode lacked cultural_kb_path — fixed inline
|
| 39 |
+
- Unicode crash in --verbose diff panel (pre-existing Windows cp1252 issue) — gate check works without --verbose
|
|
|
|
| 40 |
|
| 41 |
### Tests Status
|
| 42 |
+
Phase 7: 21 passed
|
| 43 |
|
| 44 |
### Commit Messages Generated
|
| 45 |
+
feat(phase7): process-aware reward shaping — ReasoningParser, ProcessVerifier, ProcessReward, 21 tests PASS, gate PASS
|
| 46 |
|
| 47 |
---
|
| 48 |
|
viral_script_engine/agents/reasoning_parser.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from typing import Tuple
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
|
| 7 |
+
from viral_script_engine.environment.actions import ArbitratorAction
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ArbitratorParseError(Exception):
|
| 11 |
+
pass
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ReasoningChain(BaseModel):
|
| 15 |
+
priority_assessment: str
|
| 16 |
+
conflict_check_answer: str # "yes" or "no" (empty string = missing)
|
| 17 |
+
conflict_check_reason: str
|
| 18 |
+
defender_consideration_answer: str # "yes" or "no" (empty string = missing)
|
| 19 |
+
defender_consideration_reason: str
|
| 20 |
+
action: ArbitratorAction
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ReasoningParser:
|
| 24 |
+
"""
|
| 25 |
+
Parses the extended Arbitrator JSON output into a ReasoningChain.
|
| 26 |
+
Falls back gracefully if reasoning fields are missing (backward compatible
|
| 27 |
+
with the untrained baseline model which does not produce reasoning fields).
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
_VALID_ACTIONS = {"hook_rewrite", "section_reorder", "cultural_ref_sub", "cta_placement"}
|
| 31 |
+
|
| 32 |
+
@staticmethod
|
| 33 |
+
def _extract_json(text: str) -> dict:
|
| 34 |
+
text = text.strip()
|
| 35 |
+
text = re.sub(r"^```(?:json)?", "", text).strip()
|
| 36 |
+
text = re.sub(r"```$", "", text).strip()
|
| 37 |
+
try:
|
| 38 |
+
return json.loads(text)
|
| 39 |
+
except json.JSONDecodeError:
|
| 40 |
+
pass
|
| 41 |
+
# Walk to find balanced braces
|
| 42 |
+
start = text.find("{")
|
| 43 |
+
if start != -1:
|
| 44 |
+
depth, in_str, esc = 0, False, False
|
| 45 |
+
for i, c in enumerate(text[start:], start):
|
| 46 |
+
if esc:
|
| 47 |
+
esc = False
|
| 48 |
+
continue
|
| 49 |
+
if c == "\\" and in_str:
|
| 50 |
+
esc = True
|
| 51 |
+
continue
|
| 52 |
+
if c == '"':
|
| 53 |
+
in_str = not in_str
|
| 54 |
+
elif not in_str:
|
| 55 |
+
if c == "{":
|
| 56 |
+
depth += 1
|
| 57 |
+
elif c == "}":
|
| 58 |
+
depth -= 1
|
| 59 |
+
if depth == 0:
|
| 60 |
+
try:
|
| 61 |
+
return json.loads(text[start: i + 1])
|
| 62 |
+
except json.JSONDecodeError:
|
| 63 |
+
break
|
| 64 |
+
raise ArbitratorParseError(f"No valid JSON found in: {text[:200]}")
|
| 65 |
+
|
| 66 |
+
@staticmethod
|
| 67 |
+
def _parse_yes_no_field(field_value: str) -> Tuple[str, str]:
|
| 68 |
+
"""Extract yes/no answer and optional reason from a field string."""
|
| 69 |
+
if not field_value:
|
| 70 |
+
return "", ""
|
| 71 |
+
lower = field_value.lower().strip()
|
| 72 |
+
if lower.startswith("yes"):
|
| 73 |
+
answer = "yes"
|
| 74 |
+
rest = field_value[3:].strip(" —-:,")
|
| 75 |
+
elif lower.startswith("no"):
|
| 76 |
+
answer = "no"
|
| 77 |
+
rest = field_value[2:].strip(" —-:,")
|
| 78 |
+
else:
|
| 79 |
+
return "", field_value
|
| 80 |
+
return answer, rest
|
| 81 |
+
|
| 82 |
+
def parse(self, raw_output: str) -> ReasoningChain:
|
| 83 |
+
data = self._extract_json(raw_output)
|
| 84 |
+
|
| 85 |
+
# Action fields are required — raise if missing/invalid
|
| 86 |
+
action_type = data.get("action_type")
|
| 87 |
+
if not action_type or action_type not in self._VALID_ACTIONS:
|
| 88 |
+
raise ArbitratorParseError(
|
| 89 |
+
f"Missing or invalid action_type: {action_type!r}"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
action = ArbitratorAction(
|
| 93 |
+
action_type=data["action_type"],
|
| 94 |
+
target_section=data.get("target_section", "hook"),
|
| 95 |
+
instruction=data.get("instruction", ""),
|
| 96 |
+
critique_claim_id=data.get("critique_claim_id", "C1"),
|
| 97 |
+
reasoning=data.get("reasoning", ""),
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# Reasoning fields are optional — fall back to empty strings if absent
|
| 101 |
+
priority_assessment = data.get("priority_assessment", "")
|
| 102 |
+
|
| 103 |
+
conflict_raw = data.get("conflict_check", "")
|
| 104 |
+
conflict_answer, conflict_reason = self._parse_yes_no_field(conflict_raw)
|
| 105 |
+
|
| 106 |
+
defender_raw = data.get("defender_consideration", "")
|
| 107 |
+
defender_answer, defender_reason = self._parse_yes_no_field(defender_raw)
|
| 108 |
+
|
| 109 |
+
return ReasoningChain(
|
| 110 |
+
priority_assessment=priority_assessment,
|
| 111 |
+
conflict_check_answer=conflict_answer,
|
| 112 |
+
conflict_check_reason=conflict_reason,
|
| 113 |
+
defender_consideration_answer=defender_answer,
|
| 114 |
+
defender_consideration_reason=defender_reason,
|
| 115 |
+
action=action,
|
| 116 |
+
)
|
viral_script_engine/environment/env.py
CHANGED
|
@@ -6,6 +6,7 @@ from typing import Optional, Tuple
|
|
| 6 |
from viral_script_engine.agents.critic import CriticAgent
|
| 7 |
from viral_script_engine.agents.defender import DefenderAgent
|
| 8 |
from viral_script_engine.agents.rewriter import RewriterAgent
|
|
|
|
| 9 |
from viral_script_engine.environment.actions import ArbitratorAction
|
| 10 |
from viral_script_engine.environment.episode_state import EpisodeState
|
| 11 |
from viral_script_engine.environment.observations import (
|
|
@@ -21,6 +22,7 @@ from viral_script_engine.rewards.r5_defender_preservation import DefenderPreserv
|
|
| 21 |
from viral_script_engine.rewards.r6_safety import SafetyReward
|
| 22 |
from viral_script_engine.rewards.r7_originality import OriginalityReward
|
| 23 |
from viral_script_engine.rewards.reward_aggregator import RewardAggregator
|
|
|
|
| 24 |
|
| 25 |
_TIERS = {
|
| 26 |
"easy": ["S01", "S02", "S03", "S04"],
|
|
@@ -68,6 +70,8 @@ class ViralScriptEnv:
|
|
| 68 |
self.moderation_agent = ModerationAgent()
|
| 69 |
self.originality_agent = OriginalityAgent()
|
| 70 |
self.aggregator = RewardAggregator()
|
|
|
|
|
|
|
| 71 |
self._state: Optional[EpisodeState] = None
|
| 72 |
|
| 73 |
if use_escalation:
|
|
@@ -154,7 +158,7 @@ class ViralScriptEnv:
|
|
| 154 |
)
|
| 155 |
return self._build_observation().model_dump(), {}
|
| 156 |
|
| 157 |
-
def step(self, action: dict) -> Tuple[dict, float, bool, bool, dict]:
|
| 158 |
if self._state is None:
|
| 159 |
raise RuntimeError("Call reset() before step()")
|
| 160 |
|
|
@@ -178,6 +182,23 @@ class ViralScriptEnv:
|
|
| 178 |
platform=self._state.platform,
|
| 179 |
)
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
rewrite_result = self.rewriter.rewrite(self._state.current_script, arb_action)
|
| 182 |
new_script = rewrite_result.rewritten_script
|
| 183 |
|
|
@@ -213,6 +234,7 @@ class ViralScriptEnv:
|
|
| 213 |
r5_defender_preservation=r5_result.score,
|
| 214 |
r6_safety=r6_result.score,
|
| 215 |
r7_originality=r7_result.score,
|
|
|
|
| 216 |
)
|
| 217 |
|
| 218 |
self._state.action_history.append(arb_action.action_type)
|
|
@@ -245,6 +267,7 @@ class ViralScriptEnv:
|
|
| 245 |
reward_components=components,
|
| 246 |
moderation_output=moderation_out.model_dump(),
|
| 247 |
originality_output=originality_out.model_dump(),
|
|
|
|
| 248 |
)
|
| 249 |
self._state.debate_history.append(round_)
|
| 250 |
self._state.current_script = new_script
|
|
@@ -276,6 +299,8 @@ class ViralScriptEnv:
|
|
| 276 |
"anti_gaming_log": anti_log.model_dump(),
|
| 277 |
"moderation_output": moderation_out.model_dump(),
|
| 278 |
"originality_output": originality_out.model_dump(),
|
|
|
|
|
|
|
| 279 |
}
|
| 280 |
return self._build_observation().model_dump(), components.total, terminated, False, info
|
| 281 |
|
|
|
|
| 6 |
from viral_script_engine.agents.critic import CriticAgent
|
| 7 |
from viral_script_engine.agents.defender import DefenderAgent
|
| 8 |
from viral_script_engine.agents.rewriter import RewriterAgent
|
| 9 |
+
from viral_script_engine.agents.reasoning_parser import ReasoningParser, ArbitratorParseError
|
| 10 |
from viral_script_engine.environment.actions import ArbitratorAction
|
| 11 |
from viral_script_engine.environment.episode_state import EpisodeState
|
| 12 |
from viral_script_engine.environment.observations import (
|
|
|
|
| 22 |
from viral_script_engine.rewards.r6_safety import SafetyReward
|
| 23 |
from viral_script_engine.rewards.r7_originality import OriginalityReward
|
| 24 |
from viral_script_engine.rewards.reward_aggregator import RewardAggregator
|
| 25 |
+
from viral_script_engine.rewards.process_reward import ProcessReward, ProcessRewardResult
|
| 26 |
|
| 27 |
_TIERS = {
|
| 28 |
"easy": ["S01", "S02", "S03", "S04"],
|
|
|
|
| 70 |
self.moderation_agent = ModerationAgent()
|
| 71 |
self.originality_agent = OriginalityAgent()
|
| 72 |
self.aggregator = RewardAggregator()
|
| 73 |
+
self.reasoning_parser = ReasoningParser()
|
| 74 |
+
self.process_reward_calc = ProcessReward()
|
| 75 |
self._state: Optional[EpisodeState] = None
|
| 76 |
|
| 77 |
if use_escalation:
|
|
|
|
| 158 |
)
|
| 159 |
return self._build_observation().model_dump(), {}
|
| 160 |
|
| 161 |
+
def step(self, action: dict, raw_output: str = None) -> Tuple[dict, float, bool, bool, dict]:
|
| 162 |
if self._state is None:
|
| 163 |
raise RuntimeError("Call reset() before step()")
|
| 164 |
|
|
|
|
| 182 |
platform=self._state.platform,
|
| 183 |
)
|
| 184 |
|
| 185 |
+
# Phase 7: parse reasoning chain and compute process reward before rewrite
|
| 186 |
+
reasoning_chain = None
|
| 187 |
+
process_result = None
|
| 188 |
+
if raw_output:
|
| 189 |
+
try:
|
| 190 |
+
reasoning_chain = self.reasoning_parser.parse(raw_output)
|
| 191 |
+
process_result = self.process_reward_calc.score(
|
| 192 |
+
reasoning_chain=reasoning_chain,
|
| 193 |
+
critic_claims=critique.claims,
|
| 194 |
+
defender_output=defender_output,
|
| 195 |
+
current_reward_components=self._state.last_reward_components,
|
| 196 |
+
episode_start_components=self._state.episode_start_rewards,
|
| 197 |
+
)
|
| 198 |
+
except ArbitratorParseError:
|
| 199 |
+
reasoning_chain = None
|
| 200 |
+
process_result = None
|
| 201 |
+
|
| 202 |
rewrite_result = self.rewriter.rewrite(self._state.current_script, arb_action)
|
| 203 |
new_script = rewrite_result.rewritten_script
|
| 204 |
|
|
|
|
| 234 |
r5_defender_preservation=r5_result.score,
|
| 235 |
r6_safety=r6_result.score,
|
| 236 |
r7_originality=r7_result.score,
|
| 237 |
+
process_reward=process_result.weighted_contribution if process_result else None,
|
| 238 |
)
|
| 239 |
|
| 240 |
self._state.action_history.append(arb_action.action_type)
|
|
|
|
| 267 |
reward_components=components,
|
| 268 |
moderation_output=moderation_out.model_dump(),
|
| 269 |
originality_output=originality_out.model_dump(),
|
| 270 |
+
reasoning_chain=reasoning_chain.model_dump() if reasoning_chain else None,
|
| 271 |
)
|
| 272 |
self._state.debate_history.append(round_)
|
| 273 |
self._state.current_script = new_script
|
|
|
|
| 299 |
"anti_gaming_log": anti_log.model_dump(),
|
| 300 |
"moderation_output": moderation_out.model_dump(),
|
| 301 |
"originality_output": originality_out.model_dump(),
|
| 302 |
+
"process_reward_result": process_result.model_dump() if process_result else None,
|
| 303 |
+
"reasoning_chain": reasoning_chain.model_dump() if reasoning_chain else None,
|
| 304 |
}
|
| 305 |
return self._build_observation().model_dump(), components.total, terminated, False, info
|
| 306 |
|
viral_script_engine/environment/observations.py
CHANGED
|
@@ -19,6 +19,7 @@ class RewardComponents(BaseModel):
|
|
| 19 |
r5_defender_preservation: Optional[float] = None
|
| 20 |
r6_safety: Optional[float] = None
|
| 21 |
r7_originality: Optional[float] = None
|
|
|
|
| 22 |
anti_gaming_penalty: float = 0.0
|
| 23 |
total: float = 0.0
|
| 24 |
|
|
@@ -51,6 +52,7 @@ class DebateRound(BaseModel):
|
|
| 51 |
reward_components: Optional[RewardComponents] = None
|
| 52 |
moderation_output: Optional[Any] = None
|
| 53 |
originality_output: Optional[Any] = None
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
class Observation(BaseModel):
|
|
|
|
| 19 |
r5_defender_preservation: Optional[float] = None
|
| 20 |
r6_safety: Optional[float] = None
|
| 21 |
r7_originality: Optional[float] = None
|
| 22 |
+
process_reward: Optional[float] = None # fired before rewrite (Phase 7)
|
| 23 |
anti_gaming_penalty: float = 0.0
|
| 24 |
total: float = 0.0
|
| 25 |
|
|
|
|
| 52 |
reward_components: Optional[RewardComponents] = None
|
| 53 |
moderation_output: Optional[Any] = None
|
| 54 |
originality_output: Optional[Any] = None
|
| 55 |
+
reasoning_chain: Optional[Any] = None # Phase 7: parsed reasoning chain dict
|
| 56 |
|
| 57 |
|
| 58 |
class Observation(BaseModel):
|
viral_script_engine/rewards/process_reward.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
from viral_script_engine.agents.critic import CritiqueClaim
|
| 6 |
+
from viral_script_engine.agents.defender import DefenderOutput
|
| 7 |
+
from viral_script_engine.agents.reasoning_parser import ReasoningChain
|
| 8 |
+
from viral_script_engine.environment.observations import RewardComponents
|
| 9 |
+
from viral_script_engine.rewards.process_verifier import ProcessVerifier
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ProcessRewardResult(BaseModel):
|
| 13 |
+
process_score: float
|
| 14 |
+
priority_score: float
|
| 15 |
+
conflict_score: float
|
| 16 |
+
defender_score: float
|
| 17 |
+
weighted_contribution: float
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ProcessReward:
|
| 21 |
+
"""
|
| 22 |
+
Combines the three process verification scores into a single
|
| 23 |
+
process reward signal that fires BEFORE the rewrite executes.
|
| 24 |
+
|
| 25 |
+
Weights:
|
| 26 |
+
- priority_assessment: 0.40
|
| 27 |
+
- conflict_check: 0.35
|
| 28 |
+
- defender_consideration: 0.25
|
| 29 |
+
|
| 30 |
+
The process reward contributes 0.15 of total step reward so outcome
|
| 31 |
+
still dominates and the Arbitrator cannot game process rewards alone.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
PROCESS_WEIGHT = 0.15
|
| 35 |
+
|
| 36 |
+
_PRIORITY_W = 0.40
|
| 37 |
+
_CONFLICT_W = 0.35
|
| 38 |
+
_DEFENDER_W = 0.25
|
| 39 |
+
|
| 40 |
+
def __init__(self):
|
| 41 |
+
self.verifier = ProcessVerifier()
|
| 42 |
+
|
| 43 |
+
def score(
|
| 44 |
+
self,
|
| 45 |
+
reasoning_chain: ReasoningChain,
|
| 46 |
+
critic_claims: List[CritiqueClaim],
|
| 47 |
+
defender_output: DefenderOutput,
|
| 48 |
+
current_reward_components: RewardComponents,
|
| 49 |
+
episode_start_components: RewardComponents,
|
| 50 |
+
) -> ProcessRewardResult:
|
| 51 |
+
"""
|
| 52 |
+
Returns ProcessRewardResult with individual check scores and
|
| 53 |
+
the weighted_contribution to add to the total step reward.
|
| 54 |
+
"""
|
| 55 |
+
priority_score = self.verifier.verify_priority_assessment(
|
| 56 |
+
priority_assessment=reasoning_chain.priority_assessment,
|
| 57 |
+
critic_claims=critic_claims,
|
| 58 |
+
current_reward_components=current_reward_components,
|
| 59 |
+
)
|
| 60 |
+
conflict_score = self.verifier.verify_conflict_check(
|
| 61 |
+
conflict_check_answer=reasoning_chain.conflict_check_answer,
|
| 62 |
+
conflict_check_reason=reasoning_chain.conflict_check_reason,
|
| 63 |
+
action=reasoning_chain.action,
|
| 64 |
+
current_reward_components=current_reward_components,
|
| 65 |
+
episode_start_components=episode_start_components,
|
| 66 |
+
)
|
| 67 |
+
defender_score = self.verifier.verify_defender_consideration(
|
| 68 |
+
defender_consideration_answer=reasoning_chain.defender_consideration_answer,
|
| 69 |
+
defender_consideration_reason=reasoning_chain.defender_consideration_reason,
|
| 70 |
+
action=reasoning_chain.action,
|
| 71 |
+
defender_output=defender_output,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
process_score = (
|
| 75 |
+
self._PRIORITY_W * priority_score
|
| 76 |
+
+ self._CONFLICT_W * conflict_score
|
| 77 |
+
+ self._DEFENDER_W * defender_score
|
| 78 |
+
)
|
| 79 |
+
weighted_contribution = process_score * self.PROCESS_WEIGHT
|
| 80 |
+
|
| 81 |
+
return ProcessRewardResult(
|
| 82 |
+
process_score=process_score,
|
| 83 |
+
priority_score=priority_score,
|
| 84 |
+
conflict_score=conflict_score,
|
| 85 |
+
defender_score=defender_score,
|
| 86 |
+
weighted_contribution=weighted_contribution,
|
| 87 |
+
)
|
viral_script_engine/rewards/process_verifier.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
|
| 3 |
+
from viral_script_engine.agents.critic import CritiqueClaim
|
| 4 |
+
from viral_script_engine.agents.defender import DefenderOutput
|
| 5 |
+
from viral_script_engine.environment.actions import ArbitratorAction
|
| 6 |
+
from viral_script_engine.environment.observations import RewardComponents
|
| 7 |
+
|
| 8 |
+
_SEVERITY_RANK = {"high": 3, "medium": 2, "low": 1}
|
| 9 |
+
|
| 10 |
+
_SECTION_KEYWORDS = {
|
| 11 |
+
"hook": ["hook", "opening", "start", "first", "beginning", "intro"],
|
| 12 |
+
"body": ["body", "middle", "main", "content"],
|
| 13 |
+
"cta": ["cta", "call to action", "call-to-action", "ending", "end", "conclusion"],
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ProcessVerifier:
|
| 18 |
+
"""
|
| 19 |
+
Checks whether the Arbitrator's reasoning chain is correct BEFORE
|
| 20 |
+
the action is executed. This is process supervision.
|
| 21 |
+
|
| 22 |
+
Three checks, each independently scored.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def verify_priority_assessment(
|
| 26 |
+
self,
|
| 27 |
+
priority_assessment: str,
|
| 28 |
+
critic_claims: List[CritiqueClaim],
|
| 29 |
+
current_reward_components: RewardComponents,
|
| 30 |
+
) -> float:
|
| 31 |
+
"""
|
| 32 |
+
Checks: does priority_assessment mention the critique_class with
|
| 33 |
+
the highest severity in the current Critic output?
|
| 34 |
+
|
| 35 |
+
Score:
|
| 36 |
+
- 1.0: mentions the highest-severity critique_class
|
| 37 |
+
- 0.5: mentions a medium-severity class (not the worst, but not random)
|
| 38 |
+
- 0.0: mentions only a low-severity class or is empty
|
| 39 |
+
"""
|
| 40 |
+
if not priority_assessment or not critic_claims:
|
| 41 |
+
return 0.0
|
| 42 |
+
|
| 43 |
+
sorted_claims = sorted(
|
| 44 |
+
critic_claims,
|
| 45 |
+
key=lambda c: _SEVERITY_RANK.get(c.severity.lower(), 0),
|
| 46 |
+
reverse=True,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
assessment_lower = priority_assessment.lower()
|
| 50 |
+
highest_class = sorted_claims[0].critique_class.lower()
|
| 51 |
+
|
| 52 |
+
if highest_class in assessment_lower:
|
| 53 |
+
return 1.0
|
| 54 |
+
|
| 55 |
+
medium_classes = {
|
| 56 |
+
c.critique_class.lower()
|
| 57 |
+
for c in sorted_claims
|
| 58 |
+
if c.severity.lower() == "medium"
|
| 59 |
+
}
|
| 60 |
+
if any(cls in assessment_lower for cls in medium_classes):
|
| 61 |
+
return 0.5
|
| 62 |
+
|
| 63 |
+
return 0.0
|
| 64 |
+
|
| 65 |
+
def verify_conflict_check(
|
| 66 |
+
self,
|
| 67 |
+
conflict_check_answer: str,
|
| 68 |
+
conflict_check_reason: str,
|
| 69 |
+
action: ArbitratorAction,
|
| 70 |
+
current_reward_components: RewardComponents,
|
| 71 |
+
episode_start_components: RewardComponents,
|
| 72 |
+
) -> float:
|
| 73 |
+
"""
|
| 74 |
+
Checks: is conflict_check_answer consistent with the actual risk?
|
| 75 |
+
|
| 76 |
+
Known conflict patterns:
|
| 77 |
+
- hook_rewrite when r3 >= 0.7 → conflict likely
|
| 78 |
+
- section_reorder when r2 <= 0.6 → conflict likely
|
| 79 |
+
- cultural_ref_sub when r5 <= 0.5 → conflict likely
|
| 80 |
+
- cta_placement when r1 <= 0.4 → conflict likely
|
| 81 |
+
|
| 82 |
+
Score:
|
| 83 |
+
- 1.0: answer matches rule-based assessment
|
| 84 |
+
- 0.0: answer contradicts it or is empty
|
| 85 |
+
"""
|
| 86 |
+
if not conflict_check_answer:
|
| 87 |
+
return 0.0
|
| 88 |
+
|
| 89 |
+
action_type = action.action_type.value
|
| 90 |
+
r1 = current_reward_components.r1_hook_strength or 0.0
|
| 91 |
+
r2 = current_reward_components.r2_coherence or 0.0
|
| 92 |
+
r3 = current_reward_components.r3_cultural_alignment or 0.0
|
| 93 |
+
r5 = current_reward_components.r5_defender_preservation or 0.0
|
| 94 |
+
|
| 95 |
+
conflict_exists = False
|
| 96 |
+
if action_type == "hook_rewrite" and r3 >= 0.7:
|
| 97 |
+
conflict_exists = True
|
| 98 |
+
elif action_type == "section_reorder" and r2 <= 0.6:
|
| 99 |
+
conflict_exists = True
|
| 100 |
+
elif action_type == "cultural_ref_sub" and r5 <= 0.5:
|
| 101 |
+
conflict_exists = True
|
| 102 |
+
elif action_type == "cta_placement" and r1 <= 0.4:
|
| 103 |
+
conflict_exists = True
|
| 104 |
+
|
| 105 |
+
model_says_conflict = conflict_check_answer.lower().strip().startswith("yes")
|
| 106 |
+
return 1.0 if model_says_conflict == conflict_exists else 0.0
|
| 107 |
+
|
| 108 |
+
def verify_defender_consideration(
|
| 109 |
+
self,
|
| 110 |
+
defender_consideration_answer: str,
|
| 111 |
+
defender_consideration_reason: str,
|
| 112 |
+
action: ArbitratorAction,
|
| 113 |
+
defender_output: DefenderOutput,
|
| 114 |
+
) -> float:
|
| 115 |
+
"""
|
| 116 |
+
Checks: if the action targets the same section as the Defender's
|
| 117 |
+
core_strength_quote, did the Arbitrator say defender_consideration = "yes"?
|
| 118 |
+
|
| 119 |
+
Score:
|
| 120 |
+
- 1.0: answer is correct
|
| 121 |
+
- 0.0: answer is wrong or empty
|
| 122 |
+
"""
|
| 123 |
+
if not defender_consideration_answer:
|
| 124 |
+
return 0.0
|
| 125 |
+
|
| 126 |
+
core_quote_lower = defender_output.core_strength_quote.lower()
|
| 127 |
+
target_section = action.target_section.lower()
|
| 128 |
+
|
| 129 |
+
# Infer which section the core strength resides in
|
| 130 |
+
core_section = None
|
| 131 |
+
for section, keywords in _SECTION_KEYWORDS.items():
|
| 132 |
+
if any(kw in core_quote_lower for kw in keywords):
|
| 133 |
+
core_section = section
|
| 134 |
+
break
|
| 135 |
+
|
| 136 |
+
# target_section "full" always overlaps with core strength
|
| 137 |
+
if target_section == "full":
|
| 138 |
+
target_matches_core = True
|
| 139 |
+
elif core_section is not None:
|
| 140 |
+
target_matches_core = (
|
| 141 |
+
target_section == core_section
|
| 142 |
+
or target_section in core_section
|
| 143 |
+
or core_section in target_section
|
| 144 |
+
)
|
| 145 |
+
else:
|
| 146 |
+
# Cannot determine core section — give benefit of doubt based on exact match
|
| 147 |
+
target_matches_core = target_section in core_quote_lower
|
| 148 |
+
|
| 149 |
+
model_says_yes = defender_consideration_answer.lower().strip().startswith("yes")
|
| 150 |
+
return 1.0 if model_says_yes == target_matches_core else 0.0
|
viral_script_engine/rewards/reward_aggregator.py
CHANGED
|
@@ -58,6 +58,9 @@ class RewardAggregator:
|
|
| 58 |
return components, log
|
| 59 |
|
| 60 |
components.compute_total()
|
|
|
|
|
|
|
|
|
|
| 61 |
pre_penalty_total = components.total
|
| 62 |
|
| 63 |
for field in _COMPONENT_FIELDS:
|
|
|
|
| 58 |
return components, log
|
| 59 |
|
| 60 |
components.compute_total()
|
| 61 |
+
# Phase 7: add process reward additively before anti-gaming checks
|
| 62 |
+
if components.process_reward is not None and components.process_reward > 0:
|
| 63 |
+
components.total = min(1.0, components.total + components.process_reward)
|
| 64 |
pre_penalty_total = components.total
|
| 65 |
|
| 66 |
for field in _COMPONENT_FIELDS:
|
viral_script_engine/scripts/run_baseline.py
CHANGED
|
@@ -63,6 +63,7 @@ def run_episode(ep_num: int, difficulty: str, agent: BaselineArbitratorAgent) ->
|
|
| 63 |
"r3": rc.get("r3_cultural_alignment"),
|
| 64 |
"r4": rc.get("r4_debate_resolution"),
|
| 65 |
"r5": rc.get("r5_defender_preservation"),
|
|
|
|
| 66 |
"total": reward,
|
| 67 |
"anti_gaming_triggered": anti_log.get("triggered", False),
|
| 68 |
"penalty": anti_log.get("penalty_applied", 0.0),
|
|
@@ -117,7 +118,7 @@ def main():
|
|
| 117 |
"error": str(e),
|
| 118 |
})
|
| 119 |
|
| 120 |
-
results_path = LOGS_DIR / "
|
| 121 |
with open(results_path, "w", encoding="utf-8") as f:
|
| 122 |
json.dump(all_episodes, f, indent=2, default=str)
|
| 123 |
|
|
|
|
| 63 |
"r3": rc.get("r3_cultural_alignment"),
|
| 64 |
"r4": rc.get("r4_debate_resolution"),
|
| 65 |
"r5": rc.get("r5_defender_preservation"),
|
| 66 |
+
"process_reward": rc.get("process_reward"), # Phase 7 — expected ~0 for untrained
|
| 67 |
"total": reward,
|
| 68 |
"anti_gaming_triggered": anti_log.get("triggered", False),
|
| 69 |
"penalty": anti_log.get("penalty_applied", 0.0),
|
|
|
|
| 118 |
"error": str(e),
|
| 119 |
})
|
| 120 |
|
| 121 |
+
results_path = LOGS_DIR / "baseline_results_v2.json"
|
| 122 |
with open(results_path, "w", encoding="utf-8") as f:
|
| 123 |
json.dump(all_episodes, f, indent=2, default=str)
|
| 124 |
|
viral_script_engine/scripts/run_dummy_episode.py
CHANGED
|
@@ -45,7 +45,8 @@ def build_random_action(action_type: ActionType) -> dict:
|
|
| 45 |
|
| 46 |
def run_episode(difficulty: str, steps: int, verbose: bool) -> dict:
|
| 47 |
scripts_path = str(BASE_DIR / "data" / "test_scripts" / "scripts.json")
|
| 48 |
-
|
|
|
|
| 49 |
|
| 50 |
obs, _ = env.reset()
|
| 51 |
console.print(Panel(
|
|
@@ -53,7 +54,7 @@ def run_episode(difficulty: str, steps: int, verbose: bool) -> dict:
|
|
| 53 |
f"Difficulty: {difficulty} | Max steps: {steps}\n"
|
| 54 |
f"Region: {obs['region']} | Platform: {obs['platform']} | Niche: {obs['niche']}\n"
|
| 55 |
f"Episode ID: {obs['episode_id']}",
|
| 56 |
-
title="[bold blue]Phase
|
| 57 |
border_style="blue",
|
| 58 |
))
|
| 59 |
|
|
@@ -68,7 +69,7 @@ def run_episode(difficulty: str, steps: int, verbose: bool) -> dict:
|
|
| 68 |
action_type = random.choice(list(ActionType))
|
| 69 |
action = build_random_action(action_type)
|
| 70 |
|
| 71 |
-
obs, reward, terminated, truncated, info = env.step(action)
|
| 72 |
rc = info["reward_components"]
|
| 73 |
mod_out = info.get("moderation_output", {})
|
| 74 |
orig_out = info.get("originality_output", {})
|
|
@@ -91,6 +92,10 @@ def run_episode(difficulty: str, steps: int, verbose: bool) -> dict:
|
|
| 91 |
_row("R4 Resolution", "r4_debate_resolution")
|
| 92 |
_row("R5 Preservation", "r5_defender_preservation")
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
r6_val = rc.get("r6_safety")
|
| 95 |
r6_suffix = " [OK] No flags" if mod_out.get("total_flags", 0) == 0 else f" [!] {mod_out.get('total_flags', 0)} flag(s)"
|
| 96 |
r6_str = (f"{r6_val:.3f}{r6_suffix}" if r6_val is not None else "N/A")
|
|
@@ -125,6 +130,43 @@ def run_episode(difficulty: str, steps: int, verbose: bool) -> dict:
|
|
| 125 |
border_style="red",
|
| 126 |
))
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
if obs.get("debate_history"):
|
| 129 |
latest = obs["debate_history"][-1]
|
| 130 |
if latest.get("rewrite_diff"):
|
|
@@ -143,6 +185,8 @@ def run_episode(difficulty: str, steps: int, verbose: bool) -> dict:
|
|
| 143 |
"originality_output": orig_out,
|
| 144 |
"anti_gaming": info.get("anti_gaming_triggered", False),
|
| 145 |
"terminated": terminated,
|
|
|
|
|
|
|
| 146 |
})
|
| 147 |
|
| 148 |
if terminated:
|
|
@@ -183,16 +227,19 @@ def main():
|
|
| 183 |
console.print(f"[dim]Episode log saved -> {log_path}[/dim]")
|
| 184 |
|
| 185 |
final_rc = episode_log["final_state"]["reward_components"]
|
|
|
|
|
|
|
| 186 |
gate_pass = (
|
| 187 |
final_rc.get("r6_safety") is not None
|
| 188 |
and final_rc.get("r7_originality") is not None
|
|
|
|
| 189 |
and log_path.exists()
|
| 190 |
)
|
| 191 |
style = "bold green" if gate_pass else "bold red"
|
| 192 |
if gate_pass:
|
| 193 |
-
label = "PHASE
|
| 194 |
else:
|
| 195 |
-
label = "PHASE
|
| 196 |
console.print(Panel(f"[{style}]{label}[/{style}]", border_style="green" if gate_pass else "red"))
|
| 197 |
|
| 198 |
|
|
|
|
| 45 |
|
| 46 |
def run_episode(difficulty: str, steps: int, verbose: bool) -> dict:
|
| 47 |
scripts_path = str(BASE_DIR / "data" / "test_scripts" / "scripts.json")
|
| 48 |
+
cultural_kb_path = str(BASE_DIR / "data" / "cultural_kb.json")
|
| 49 |
+
env = ViralScriptEnv(scripts_path=scripts_path, cultural_kb_path=cultural_kb_path, max_steps=steps, difficulty=difficulty)
|
| 50 |
|
| 51 |
obs, _ = env.reset()
|
| 52 |
console.print(Panel(
|
|
|
|
| 54 |
f"Difficulty: {difficulty} | Max steps: {steps}\n"
|
| 55 |
f"Region: {obs['region']} | Platform: {obs['platform']} | Niche: {obs['niche']}\n"
|
| 56 |
f"Episode ID: {obs['episode_id']}",
|
| 57 |
+
title="[bold blue]Phase 7 Demo Episode[/bold blue]",
|
| 58 |
border_style="blue",
|
| 59 |
))
|
| 60 |
|
|
|
|
| 69 |
action_type = random.choice(list(ActionType))
|
| 70 |
action = build_random_action(action_type)
|
| 71 |
|
| 72 |
+
obs, reward, terminated, truncated, info = env.step(action, raw_output=None)
|
| 73 |
rc = info["reward_components"]
|
| 74 |
mod_out = info.get("moderation_output", {})
|
| 75 |
orig_out = info.get("originality_output", {})
|
|
|
|
| 92 |
_row("R4 Resolution", "r4_debate_resolution")
|
| 93 |
_row("R5 Preservation", "r5_defender_preservation")
|
| 94 |
|
| 95 |
+
pr_val = rc.get("process_reward")
|
| 96 |
+
pr_str = f"{pr_val:.3f}" if pr_val is not None else "N/A"
|
| 97 |
+
t.add_row("Process Reward", pr_str, _bar(pr_val) if pr_val is not None else "")
|
| 98 |
+
|
| 99 |
r6_val = rc.get("r6_safety")
|
| 100 |
r6_suffix = " [OK] No flags" if mod_out.get("total_flags", 0) == 0 else f" [!] {mod_out.get('total_flags', 0)} flag(s)"
|
| 101 |
r6_str = (f"{r6_val:.3f}{r6_suffix}" if r6_val is not None else "N/A")
|
|
|
|
| 130 |
border_style="red",
|
| 131 |
))
|
| 132 |
|
| 133 |
+
# Show reasoning chain if present
|
| 134 |
+
rc_chain = info.get("reasoning_chain")
|
| 135 |
+
if rc_chain:
|
| 136 |
+
chain_lines = []
|
| 137 |
+
if rc_chain.get("priority_assessment"):
|
| 138 |
+
chain_lines.append(f"[cyan]Priority:[/cyan] {rc_chain['priority_assessment']}")
|
| 139 |
+
cf = rc_chain.get("conflict_check_answer", "")
|
| 140 |
+
if cf:
|
| 141 |
+
chain_lines.append(
|
| 142 |
+
f"[yellow]Conflict:[/yellow] {cf} — {rc_chain.get('conflict_check_reason', '')}"
|
| 143 |
+
)
|
| 144 |
+
df = rc_chain.get("defender_consideration_answer", "")
|
| 145 |
+
if df:
|
| 146 |
+
chain_lines.append(
|
| 147 |
+
f"[green]Defender:[/green] {df} — {rc_chain.get('defender_consideration_reason', '')}"
|
| 148 |
+
)
|
| 149 |
+
pr_res = info.get("process_reward_result")
|
| 150 |
+
if pr_res:
|
| 151 |
+
chain_lines.append(
|
| 152 |
+
f"[magenta]Process Scores:[/magenta] "
|
| 153 |
+
f"priority={pr_res['priority_score']:.2f} "
|
| 154 |
+
f"conflict={pr_res['conflict_score']:.2f} "
|
| 155 |
+
f"defender={pr_res['defender_score']:.2f} "
|
| 156 |
+
f"total={pr_res['process_score']:.2f}"
|
| 157 |
+
)
|
| 158 |
+
console.print(Panel(
|
| 159 |
+
"\n".join(chain_lines) if chain_lines else "[dim]No reasoning chain[/dim]",
|
| 160 |
+
title="[bold magenta]Reasoning Chain[/bold magenta]",
|
| 161 |
+
border_style="magenta",
|
| 162 |
+
))
|
| 163 |
+
else:
|
| 164 |
+
console.print(Panel(
|
| 165 |
+
"[dim]No reasoning chain — zero-shot decision[/dim]",
|
| 166 |
+
title="[bold magenta]Reasoning Chain[/bold magenta]",
|
| 167 |
+
border_style="dim",
|
| 168 |
+
))
|
| 169 |
+
|
| 170 |
if obs.get("debate_history"):
|
| 171 |
latest = obs["debate_history"][-1]
|
| 172 |
if latest.get("rewrite_diff"):
|
|
|
|
| 185 |
"originality_output": orig_out,
|
| 186 |
"anti_gaming": info.get("anti_gaming_triggered", False),
|
| 187 |
"terminated": terminated,
|
| 188 |
+
"process_reward_result": info.get("process_reward_result"),
|
| 189 |
+
"reasoning_chain": info.get("reasoning_chain"),
|
| 190 |
})
|
| 191 |
|
| 192 |
if terminated:
|
|
|
|
| 227 |
console.print(f"[dim]Episode log saved -> {log_path}[/dim]")
|
| 228 |
|
| 229 |
final_rc = episode_log["final_state"]["reward_components"]
|
| 230 |
+
# Phase 7 gate: process_reward field must exist in reward components (even if 0.0)
|
| 231 |
+
has_process_reward_key = "process_reward" in final_rc
|
| 232 |
gate_pass = (
|
| 233 |
final_rc.get("r6_safety") is not None
|
| 234 |
and final_rc.get("r7_originality") is not None
|
| 235 |
+
and has_process_reward_key
|
| 236 |
and log_path.exists()
|
| 237 |
)
|
| 238 |
style = "bold green" if gate_pass else "bold red"
|
| 239 |
if gate_pass:
|
| 240 |
+
label = "PHASE 7 GATE: PASS — Process rewards active. Reasoning chain verified per step."
|
| 241 |
else:
|
| 242 |
+
label = "PHASE 7 GATE: FAIL — process_reward missing from reward output."
|
| 243 |
console.print(Panel(f"[{style}]{label}[/{style}]", border_style="green" if gate_pass else "red"))
|
| 244 |
|
| 245 |
|
viral_script_engine/tests/test_phase7.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phase 7 tests — Process-Aware Reward Shaping
|
| 3 |
+
"""
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 11 |
+
|
| 12 |
+
from viral_script_engine.agents.critic import CritiqueClaim
|
| 13 |
+
from viral_script_engine.agents.defender import DefenderOutput
|
| 14 |
+
from viral_script_engine.agents.reasoning_parser import (
|
| 15 |
+
ArbitratorParseError,
|
| 16 |
+
ReasoningChain,
|
| 17 |
+
ReasoningParser,
|
| 18 |
+
)
|
| 19 |
+
from viral_script_engine.environment.actions import ArbitratorAction, ActionType
|
| 20 |
+
from viral_script_engine.environment.observations import RewardComponents
|
| 21 |
+
from viral_script_engine.rewards.process_reward import ProcessReward, ProcessRewardResult
|
| 22 |
+
from viral_script_engine.rewards.process_verifier import ProcessVerifier
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Fixtures
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
@pytest.fixture
|
| 30 |
+
def parser():
|
| 31 |
+
return ReasoningParser()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.fixture
|
| 35 |
+
def verifier():
|
| 36 |
+
return ProcessVerifier()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@pytest.fixture
|
| 40 |
+
def process_reward():
|
| 41 |
+
return ProcessReward()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _make_claim(claim_id: str, critique_class: str, severity: str) -> CritiqueClaim:
|
| 45 |
+
return CritiqueClaim(
|
| 46 |
+
claim_id=claim_id,
|
| 47 |
+
critique_class=critique_class,
|
| 48 |
+
claim_text=f"Test claim for {critique_class}",
|
| 49 |
+
timestamp_range="0:00-0:05",
|
| 50 |
+
evidence="test evidence",
|
| 51 |
+
is_falsifiable=True,
|
| 52 |
+
severity=severity,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _make_action(
|
| 57 |
+
action_type: str = "hook_rewrite",
|
| 58 |
+
target_section: str = "hook",
|
| 59 |
+
) -> ArbitratorAction:
|
| 60 |
+
return ArbitratorAction(
|
| 61 |
+
action_type=action_type,
|
| 62 |
+
target_section=target_section,
|
| 63 |
+
instruction="Test instruction",
|
| 64 |
+
critique_claim_id="C1",
|
| 65 |
+
reasoning="Test reasoning",
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _make_defender(core_strength_quote: str = "The hook is strong and engaging") -> DefenderOutput:
|
| 70 |
+
return DefenderOutput(
|
| 71 |
+
core_strength="Great opening hook",
|
| 72 |
+
core_strength_quote=core_strength_quote,
|
| 73 |
+
defense_argument="This element should be preserved",
|
| 74 |
+
flagged_critic_claims=["C2"],
|
| 75 |
+
regional_voice_elements=["local phrase"],
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _make_components(**kwargs) -> RewardComponents:
|
| 80 |
+
rc = RewardComponents(**kwargs)
|
| 81 |
+
rc.compute_total()
|
| 82 |
+
return rc
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# ReasoningParser tests
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
_FULL_JSON = json.dumps({
|
| 90 |
+
"priority_assessment": "hook_weakness is highest severity (high) — opens weakly",
|
| 91 |
+
"conflict_check": "yes — hook rewrite risks R3 cultural alignment",
|
| 92 |
+
"defender_consideration": "yes — core strength is in hook section",
|
| 93 |
+
"action_type": "hook_rewrite",
|
| 94 |
+
"target_section": "hook",
|
| 95 |
+
"instruction": "Replace generic opener with Mumbai local reference",
|
| 96 |
+
"critique_claim_id": "C1",
|
| 97 |
+
"reasoning": "Hook is highest severity, must be fixed first",
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
_MINIMAL_JSON = json.dumps({
|
| 101 |
+
"action_type": "hook_rewrite",
|
| 102 |
+
"target_section": "hook",
|
| 103 |
+
"instruction": "Fix the hook",
|
| 104 |
+
"critique_claim_id": "C1",
|
| 105 |
+
"reasoning": "default",
|
| 106 |
+
})
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_reasoning_parser_full_json(parser):
|
| 110 |
+
chain = parser.parse(_FULL_JSON)
|
| 111 |
+
assert isinstance(chain, ReasoningChain)
|
| 112 |
+
assert "hook_weakness" in chain.priority_assessment
|
| 113 |
+
assert chain.conflict_check_answer == "yes"
|
| 114 |
+
assert chain.defender_consideration_answer == "yes"
|
| 115 |
+
assert chain.action.action_type == ActionType.HOOK_REWRITE
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_reasoning_parser_fallback_missing_reasoning(parser):
|
| 119 |
+
"""Baseline model output without reasoning fields should parse without error."""
|
| 120 |
+
chain = parser.parse(_MINIMAL_JSON)
|
| 121 |
+
assert chain.priority_assessment == ""
|
| 122 |
+
assert chain.conflict_check_answer == ""
|
| 123 |
+
assert chain.defender_consideration_answer == ""
|
| 124 |
+
assert chain.action.action_type == ActionType.HOOK_REWRITE
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_reasoning_parser_raises_on_invalid_action(parser):
|
| 128 |
+
bad_json = json.dumps({"action_type": "invalid_action", "target_section": "hook"})
|
| 129 |
+
with pytest.raises(ArbitratorParseError):
|
| 130 |
+
parser.parse(bad_json)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_reasoning_parser_raises_on_missing_action(parser):
|
| 134 |
+
bad_json = json.dumps({"priority_assessment": "something"})
|
| 135 |
+
with pytest.raises(ArbitratorParseError):
|
| 136 |
+
parser.parse(bad_json)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
# ProcessVerifier.verify_priority_assessment tests
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
|
| 143 |
+
def test_verify_priority_high_severity_mention(verifier):
|
| 144 |
+
claims = [
|
| 145 |
+
_make_claim("C1", "hook_weakness", "high"),
|
| 146 |
+
_make_claim("C2", "pacing_issue", "medium"),
|
| 147 |
+
_make_claim("C3", "cta_buried", "low"),
|
| 148 |
+
]
|
| 149 |
+
rc = _make_components(r1_hook_strength=0.5)
|
| 150 |
+
score = verifier.verify_priority_assessment(
|
| 151 |
+
priority_assessment="hook_weakness is the most urgent issue",
|
| 152 |
+
critic_claims=claims,
|
| 153 |
+
current_reward_components=rc,
|
| 154 |
+
)
|
| 155 |
+
assert score == 1.0
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_verify_priority_medium_severity_mention(verifier):
|
| 159 |
+
claims = [
|
| 160 |
+
_make_claim("C1", "hook_weakness", "high"),
|
| 161 |
+
_make_claim("C2", "pacing_issue", "medium"),
|
| 162 |
+
]
|
| 163 |
+
rc = _make_components(r1_hook_strength=0.5)
|
| 164 |
+
score = verifier.verify_priority_assessment(
|
| 165 |
+
priority_assessment="pacing_issue should be addressed",
|
| 166 |
+
critic_claims=claims,
|
| 167 |
+
current_reward_components=rc,
|
| 168 |
+
)
|
| 169 |
+
assert score == 0.5
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_verify_priority_random_mention_scores_zero(verifier):
|
| 173 |
+
claims = [
|
| 174 |
+
_make_claim("C1", "hook_weakness", "high"),
|
| 175 |
+
_make_claim("C2", "pacing_issue", "medium"),
|
| 176 |
+
]
|
| 177 |
+
rc = _make_components(r1_hook_strength=0.5)
|
| 178 |
+
score = verifier.verify_priority_assessment(
|
| 179 |
+
priority_assessment="we should just make this better",
|
| 180 |
+
critic_claims=claims,
|
| 181 |
+
current_reward_components=rc,
|
| 182 |
+
)
|
| 183 |
+
assert score == 0.0
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_verify_priority_empty_assessment(verifier):
|
| 187 |
+
claims = [_make_claim("C1", "hook_weakness", "high")]
|
| 188 |
+
rc = _make_components()
|
| 189 |
+
score = verifier.verify_priority_assessment("", claims, rc)
|
| 190 |
+
assert score == 0.0
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
# ProcessVerifier.verify_conflict_check tests — all 4 known patterns
|
| 195 |
+
# ---------------------------------------------------------------------------
|
| 196 |
+
|
| 197 |
+
def test_conflict_check_hook_rewrite_with_high_r3(verifier):
|
| 198 |
+
action = _make_action("hook_rewrite", "hook")
|
| 199 |
+
start = _make_components(r1_hook_strength=0.6, r3_cultural_alignment=0.75)
|
| 200 |
+
current = _make_components(r1_hook_strength=0.6, r3_cultural_alignment=0.80)
|
| 201 |
+
# r3 >= 0.7 → conflict exists → correct answer is "yes"
|
| 202 |
+
score = verifier.verify_conflict_check("yes — hook rewrite risks cultural refs", "", action, current, start)
|
| 203 |
+
assert score == 1.0
|
| 204 |
+
score_wrong = verifier.verify_conflict_check("no — no conflict", "", action, current, start)
|
| 205 |
+
assert score_wrong == 0.0
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_conflict_check_section_reorder_with_low_r2(verifier):
|
| 209 |
+
action = _make_action("section_reorder", "body")
|
| 210 |
+
start = _make_components(r2_coherence=0.5)
|
| 211 |
+
current = _make_components(r2_coherence=0.5)
|
| 212 |
+
# r2 <= 0.6 → conflict exists
|
| 213 |
+
score = verifier.verify_conflict_check("yes", "", action, current, start)
|
| 214 |
+
assert score == 1.0
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def test_conflict_check_cultural_ref_sub_with_low_r5(verifier):
|
| 218 |
+
action = _make_action("cultural_ref_sub", "full")
|
| 219 |
+
start = _make_components(r5_defender_preservation=0.4)
|
| 220 |
+
current = _make_components(r5_defender_preservation=0.4)
|
| 221 |
+
# r5 <= 0.5 → conflict exists
|
| 222 |
+
score = verifier.verify_conflict_check("yes", "", action, current, start)
|
| 223 |
+
assert score == 1.0
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def test_conflict_check_cta_placement_with_low_r1(verifier):
|
| 227 |
+
action = _make_action("cta_placement", "cta")
|
| 228 |
+
start = _make_components(r1_hook_strength=0.3)
|
| 229 |
+
current = _make_components(r1_hook_strength=0.3)
|
| 230 |
+
# r1 <= 0.4 → conflict exists
|
| 231 |
+
score = verifier.verify_conflict_check("yes — CTA premature while hook is weak", "", action, current, start)
|
| 232 |
+
assert score == 1.0
|
| 233 |
+
score_wrong = verifier.verify_conflict_check("no conflict detected", "", action, current, start)
|
| 234 |
+
assert score_wrong == 0.0
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def test_conflict_check_no_conflict_scenario(verifier):
|
| 238 |
+
# hook_rewrite when r3 < 0.7 → no conflict → correct answer is "no"
|
| 239 |
+
action = _make_action("hook_rewrite", "hook")
|
| 240 |
+
start = _make_components(r3_cultural_alignment=0.5)
|
| 241 |
+
current = _make_components(r3_cultural_alignment=0.5)
|
| 242 |
+
score = verifier.verify_conflict_check("no — r3 is low, no conflict", "", action, current, start)
|
| 243 |
+
assert score == 1.0
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# ---------------------------------------------------------------------------
|
| 247 |
+
# ProcessVerifier.verify_defender_consideration tests
|
| 248 |
+
# ---------------------------------------------------------------------------
|
| 249 |
+
|
| 250 |
+
def test_defender_consideration_yes_when_core_in_target(verifier):
|
| 251 |
+
# Core strength is in hook, action targets hook → should say yes
|
| 252 |
+
action = _make_action("hook_rewrite", "hook")
|
| 253 |
+
defender = _make_defender(core_strength_quote="The opening hook draws viewers immediately")
|
| 254 |
+
score = verifier.verify_defender_consideration("yes", "", action, defender)
|
| 255 |
+
assert score == 1.0
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def test_defender_consideration_no_when_core_not_in_target(verifier):
|
| 259 |
+
# Core strength is in CTA section, action targets hook → should say no
|
| 260 |
+
action = _make_action("hook_rewrite", "hook")
|
| 261 |
+
defender = _make_defender(core_strength_quote="The ending call to action is very strong")
|
| 262 |
+
score = verifier.verify_defender_consideration("no", "", action, defender)
|
| 263 |
+
assert score == 1.0
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def test_defender_consideration_wrong_answer_scores_zero(verifier):
|
| 267 |
+
action = _make_action("hook_rewrite", "hook")
|
| 268 |
+
defender = _make_defender(core_strength_quote="The opening hook draws viewers immediately")
|
| 269 |
+
score = verifier.verify_defender_consideration("no — no overlap", "", action, defender)
|
| 270 |
+
assert score == 0.0
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def test_defender_consideration_empty_answer(verifier):
|
| 274 |
+
action = _make_action("hook_rewrite", "hook")
|
| 275 |
+
defender = _make_defender()
|
| 276 |
+
score = verifier.verify_defender_consideration("", "", action, defender)
|
| 277 |
+
assert score == 0.0
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# ---------------------------------------------------------------------------
|
| 281 |
+
# ProcessReward.score() weighted total
|
| 282 |
+
# ---------------------------------------------------------------------------
|
| 283 |
+
|
| 284 |
+
def test_process_reward_correct_weighted_total(process_reward):
|
| 285 |
+
claims = [
|
| 286 |
+
_make_claim("C1", "hook_weakness", "high"),
|
| 287 |
+
_make_claim("C2", "pacing_issue", "medium"),
|
| 288 |
+
]
|
| 289 |
+
defender = _make_defender(core_strength_quote="The opening hook draws viewers immediately")
|
| 290 |
+
rc = _make_components(r1_hook_strength=0.5, r2_coherence=0.5, r3_cultural_alignment=0.8)
|
| 291 |
+
start = _make_components(r1_hook_strength=0.5, r2_coherence=0.5, r3_cultural_alignment=0.8)
|
| 292 |
+
|
| 293 |
+
chain = ReasoningChain(
|
| 294 |
+
priority_assessment="hook_weakness is highest severity",
|
| 295 |
+
conflict_check_answer="yes",
|
| 296 |
+
conflict_check_reason="hook rewrite risks r3",
|
| 297 |
+
defender_consideration_answer="yes",
|
| 298 |
+
defender_consideration_reason="core strength is in hook",
|
| 299 |
+
action=_make_action("hook_rewrite", "hook"),
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
result = process_reward.score(chain, claims, defender, rc, start)
|
| 303 |
+
assert isinstance(result, ProcessRewardResult)
|
| 304 |
+
# All three checks should score 1.0 → process_score = 1.0, contribution = 0.15
|
| 305 |
+
assert result.priority_score == 1.0
|
| 306 |
+
assert result.conflict_score == 1.0 # hook_rewrite + r3 >= 0.7 → conflict, model says yes
|
| 307 |
+
assert result.defender_score == 1.0
|
| 308 |
+
assert abs(result.process_score - 1.0) < 1e-6
|
| 309 |
+
assert abs(result.weighted_contribution - 0.15) < 1e-6
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def test_process_reward_zero_for_empty_reasoning(process_reward):
|
| 313 |
+
claims = [_make_claim("C1", "hook_weakness", "high")]
|
| 314 |
+
defender = _make_defender()
|
| 315 |
+
rc = _make_components(r1_hook_strength=0.5)
|
| 316 |
+
start = _make_components(r1_hook_strength=0.5)
|
| 317 |
+
|
| 318 |
+
chain = ReasoningChain(
|
| 319 |
+
priority_assessment="",
|
| 320 |
+
conflict_check_answer="",
|
| 321 |
+
conflict_check_reason="",
|
| 322 |
+
defender_consideration_answer="",
|
| 323 |
+
defender_consideration_reason="",
|
| 324 |
+
action=_make_action("hook_rewrite", "hook"),
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
result = process_reward.score(chain, claims, defender, rc, start)
|
| 328 |
+
assert result.process_score == 0.0
|
| 329 |
+
assert result.weighted_contribution == 0.0
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# ---------------------------------------------------------------------------
|
| 333 |
+
# env.step() integration — process_reward in RewardComponents
|
| 334 |
+
# ---------------------------------------------------------------------------
|
| 335 |
+
|
| 336 |
+
_ACTION = {
|
| 337 |
+
"action_type": "hook_rewrite",
|
| 338 |
+
"target_section": "hook",
|
| 339 |
+
"instruction": "Make the hook more engaging.",
|
| 340 |
+
"critique_claim_id": "C1",
|
| 341 |
+
"reasoning": "Test",
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
_SCRIPTS_PATH = str(Path(__file__).parent.parent / "data" / "test_scripts" / "scripts.json")
|
| 345 |
+
_CULTURAL_KB = str(Path(__file__).parent.parent / "data" / "cultural_kb.json")
|
| 346 |
+
|
| 347 |
+
_MOCK_CRITIC = json.dumps({
|
| 348 |
+
"claims": [
|
| 349 |
+
{
|
| 350 |
+
"claim_id": "C1",
|
| 351 |
+
"critique_class": "hook_weakness",
|
| 352 |
+
"claim_text": "Weak hook.",
|
| 353 |
+
"timestamp_range": "0:00-0:03",
|
| 354 |
+
"evidence": "generic opener",
|
| 355 |
+
"is_falsifiable": True,
|
| 356 |
+
"severity": "high",
|
| 357 |
+
}
|
| 358 |
+
],
|
| 359 |
+
"overall_severity": "high",
|
| 360 |
+
})
|
| 361 |
+
|
| 362 |
+
_MOCK_DEFENDER = json.dumps({
|
| 363 |
+
"core_strength": "Strong regional authenticity",
|
| 364 |
+
"core_strength_quote": "The hook draws viewers immediately",
|
| 365 |
+
"defense_argument": "Regional voice is valuable",
|
| 366 |
+
"flagged_critic_claims": [],
|
| 367 |
+
"regional_voice_elements": ["local phrase"],
|
| 368 |
+
})
|
| 369 |
+
|
| 370 |
+
_MOCK_REWRITER = json.dumps({
|
| 371 |
+
"rewritten_script": "Better script content here.",
|
| 372 |
+
"changes_made": ["improved hook"],
|
| 373 |
+
})
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def _multi_mock(sys_prompt, usr_prompt, **kw):
|
| 377 |
+
"""Return appropriate mock JSON based on which agent is calling."""
|
| 378 |
+
if "core_strength" in sys_prompt or "defender" in sys_prompt.lower():
|
| 379 |
+
return _MOCK_DEFENDER
|
| 380 |
+
if "rewriter" in sys_prompt.lower() or "rewrite" in sys_prompt.lower()[:50]:
|
| 381 |
+
return _MOCK_REWRITER
|
| 382 |
+
return _MOCK_CRITIC
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
@pytest.fixture
|
| 386 |
+
def env_mock_llm(monkeypatch):
|
| 387 |
+
monkeypatch.setattr(
|
| 388 |
+
"viral_script_engine.agents.llm_backend.LLMBackend.generate",
|
| 389 |
+
lambda self, sys_prompt, usr_prompt, **kw: _multi_mock(sys_prompt, usr_prompt, **kw),
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _make_env():
|
| 394 |
+
from viral_script_engine.environment.env import ViralScriptEnv
|
| 395 |
+
return ViralScriptEnv(
|
| 396 |
+
scripts_path=_SCRIPTS_PATH,
|
| 397 |
+
cultural_kb_path=_CULTURAL_KB,
|
| 398 |
+
max_steps=1,
|
| 399 |
+
difficulty="easy",
|
| 400 |
+
use_escalation=False,
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def test_env_step_has_process_reward_key(env_mock_llm):
|
| 405 |
+
"""env.step() must include process_reward key in reward_components."""
|
| 406 |
+
env = _make_env()
|
| 407 |
+
env.reset()
|
| 408 |
+
_, _, _, _, info = env.step(_ACTION)
|
| 409 |
+
rc = info["reward_components"]
|
| 410 |
+
assert "process_reward" in rc
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def test_env_step_process_reward_graceful_zero(env_mock_llm):
|
| 414 |
+
"""process_reward is None when no raw_output is supplied (graceful zero)."""
|
| 415 |
+
env = _make_env()
|
| 416 |
+
env.reset()
|
| 417 |
+
_, _, _, _, info = env.step(_ACTION) # no raw_output
|
| 418 |
+
rc = info["reward_components"]
|
| 419 |
+
assert rc.get("process_reward") is None
|
| 420 |
+
assert info.get("process_reward_result") is None
|
viral_script_engine/training/rollout_function.py
CHANGED
|
@@ -30,12 +30,19 @@ _VALID_ACTIONS = {"hook_rewrite", "section_reorder", "cultural_ref_sub", "cta_pl
|
|
| 30 |
|
| 31 |
ARBITRATOR_SYSTEM = (
|
| 32 |
"You are an expert content strategist acting as an Arbitrator in a script improvement debate.\n"
|
| 33 |
-
"
|
| 34 |
-
"You must choose exactly ONE action to improve the script.\n\n"
|
| 35 |
"AVAILABLE ACTIONS: hook_rewrite | section_reorder | cultural_ref_sub | cta_placement\n\n"
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
'"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
)
|
| 40 |
|
| 41 |
|
|
@@ -171,7 +178,7 @@ def build_rollout_fn(
|
|
| 171 |
episode_completion_parts.append(raw_output)
|
| 172 |
|
| 173 |
try:
|
| 174 |
-
obs, reward, terminated, truncated, info = env.step(action)
|
| 175 |
episode_reward = reward
|
| 176 |
except Exception:
|
| 177 |
# LLM agent (critic/defender) parse error — skip step, keep prior reward
|
|
|
|
| 30 |
|
| 31 |
ARBITRATOR_SYSTEM = (
|
| 32 |
"You are an expert content strategist acting as an Arbitrator in a script improvement debate.\n"
|
| 33 |
+
"Before choosing your action, you must reason through the debate explicitly.\n\n"
|
|
|
|
| 34 |
"AVAILABLE ACTIONS: hook_rewrite | section_reorder | cultural_ref_sub | cta_placement\n\n"
|
| 35 |
+
"OUTPUT FORMAT (JSON only, in this exact order):\n"
|
| 36 |
+
"{\n"
|
| 37 |
+
' "priority_assessment": "which critique is most urgent and why — one sentence",\n'
|
| 38 |
+
' "conflict_check": "does acting on this critique risk harming any other reward signal? yes/no + reason",\n'
|
| 39 |
+
' "defender_consideration": "is the Defender\'s flagged concern relevant to this decision? yes/no + reason",\n'
|
| 40 |
+
' "action_type": "...",\n'
|
| 41 |
+
' "target_section": "...",\n'
|
| 42 |
+
' "instruction": "...",\n'
|
| 43 |
+
' "critique_claim_id": "...",\n'
|
| 44 |
+
' "reasoning": "..."\n'
|
| 45 |
+
"}"
|
| 46 |
)
|
| 47 |
|
| 48 |
|
|
|
|
| 178 |
episode_completion_parts.append(raw_output)
|
| 179 |
|
| 180 |
try:
|
| 181 |
+
obs, reward, terminated, truncated, info = env.step(action, raw_output=raw_output)
|
| 182 |
episode_reward = reward
|
| 183 |
except Exception:
|
| 184 |
# LLM agent (critic/defender) parse error — skip step, keep prior reward
|