Spaces:
Sleeping
Sleeping
| """ | |
| Phase 7 tests — Process-Aware Reward Shaping | |
| """ | |
| import json | |
| import sys | |
| from pathlib import Path | |
| import pytest | |
| sys.path.insert(0, str(Path(__file__).parent.parent.parent)) | |
| from viral_script_engine.agents.critic import CritiqueClaim | |
| from viral_script_engine.agents.defender import DefenderOutput | |
| from viral_script_engine.agents.reasoning_parser import ( | |
| ArbitratorParseError, | |
| ReasoningChain, | |
| ReasoningParser, | |
| ) | |
| from viral_script_engine.environment.actions import ArbitratorAction, ActionType | |
| from viral_script_engine.environment.observations import RewardComponents | |
| from viral_script_engine.rewards.process_reward import ProcessReward, ProcessRewardResult | |
| from viral_script_engine.rewards.process_verifier import ProcessVerifier | |
| # --------------------------------------------------------------------------- | |
| # Fixtures | |
| # --------------------------------------------------------------------------- | |
| def parser(): | |
| return ReasoningParser() | |
| def verifier(): | |
| return ProcessVerifier() | |
| def process_reward(): | |
| return ProcessReward() | |
| def _make_claim(claim_id: str, critique_class: str, severity: str) -> CritiqueClaim: | |
| return CritiqueClaim( | |
| claim_id=claim_id, | |
| critique_class=critique_class, | |
| claim_text=f"Test claim for {critique_class}", | |
| timestamp_range="0:00-0:05", | |
| evidence="test evidence", | |
| is_falsifiable=True, | |
| severity=severity, | |
| ) | |
| def _make_action( | |
| action_type: str = "hook_rewrite", | |
| target_section: str = "hook", | |
| ) -> ArbitratorAction: | |
| return ArbitratorAction( | |
| action_type=action_type, | |
| target_section=target_section, | |
| instruction="Test instruction", | |
| critique_claim_id="C1", | |
| reasoning="Test reasoning", | |
| ) | |
| def _make_defender(core_strength_quote: str = "The hook is strong and engaging") -> DefenderOutput: | |
| return DefenderOutput( | |
| core_strength="Great opening hook", | |
| core_strength_quote=core_strength_quote, | |
| defense_argument="This element should be preserved", | |
| flagged_critic_claims=["C2"], | |
| regional_voice_elements=["local phrase"], | |
| ) | |
| def _make_components(**kwargs) -> RewardComponents: | |
| rc = RewardComponents(**kwargs) | |
| rc.compute_total() | |
| return rc | |
| # --------------------------------------------------------------------------- | |
| # ReasoningParser tests | |
| # --------------------------------------------------------------------------- | |
| _FULL_JSON = json.dumps({ | |
| "priority_assessment": "hook_weakness is highest severity (high) — opens weakly", | |
| "conflict_check": "yes — hook rewrite risks R3 cultural alignment", | |
| "defender_consideration": "yes — core strength is in hook section", | |
| "action_type": "hook_rewrite", | |
| "target_section": "hook", | |
| "instruction": "Replace generic opener with Mumbai local reference", | |
| "critique_claim_id": "C1", | |
| "reasoning": "Hook is highest severity, must be fixed first", | |
| }) | |
| _MINIMAL_JSON = json.dumps({ | |
| "action_type": "hook_rewrite", | |
| "target_section": "hook", | |
| "instruction": "Fix the hook", | |
| "critique_claim_id": "C1", | |
| "reasoning": "default", | |
| }) | |
| def test_reasoning_parser_full_json(parser): | |
| chain = parser.parse(_FULL_JSON) | |
| assert isinstance(chain, ReasoningChain) | |
| assert "hook_weakness" in chain.priority_assessment | |
| assert chain.conflict_check_answer == "yes" | |
| assert chain.defender_consideration_answer == "yes" | |
| assert chain.action.action_type == ActionType.HOOK_REWRITE | |
| def test_reasoning_parser_fallback_missing_reasoning(parser): | |
| """Baseline model output without reasoning fields should parse without error.""" | |
| chain = parser.parse(_MINIMAL_JSON) | |
| assert chain.priority_assessment == "" | |
| assert chain.conflict_check_answer == "" | |
| assert chain.defender_consideration_answer == "" | |
| assert chain.action.action_type == ActionType.HOOK_REWRITE | |
| def test_reasoning_parser_raises_on_invalid_action(parser): | |
| bad_json = json.dumps({"action_type": "invalid_action", "target_section": "hook"}) | |
| with pytest.raises(ArbitratorParseError): | |
| parser.parse(bad_json) | |
| def test_reasoning_parser_raises_on_missing_action(parser): | |
| bad_json = json.dumps({"priority_assessment": "something"}) | |
| with pytest.raises(ArbitratorParseError): | |
| parser.parse(bad_json) | |
| # --------------------------------------------------------------------------- | |
| # ProcessVerifier.verify_priority_assessment tests | |
| # --------------------------------------------------------------------------- | |
| def test_verify_priority_high_severity_mention(verifier): | |
| claims = [ | |
| _make_claim("C1", "hook_weakness", "high"), | |
| _make_claim("C2", "pacing_issue", "medium"), | |
| _make_claim("C3", "cta_buried", "low"), | |
| ] | |
| rc = _make_components(r1_hook_strength=0.5) | |
| score = verifier.verify_priority_assessment( | |
| priority_assessment="hook_weakness is the most urgent issue", | |
| critic_claims=claims, | |
| current_reward_components=rc, | |
| ) | |
| assert score == 1.0 | |
| def test_verify_priority_medium_severity_mention(verifier): | |
| claims = [ | |
| _make_claim("C1", "hook_weakness", "high"), | |
| _make_claim("C2", "pacing_issue", "medium"), | |
| ] | |
| rc = _make_components(r1_hook_strength=0.5) | |
| score = verifier.verify_priority_assessment( | |
| priority_assessment="pacing_issue should be addressed", | |
| critic_claims=claims, | |
| current_reward_components=rc, | |
| ) | |
| assert score == 0.5 | |
| def test_verify_priority_random_mention_scores_zero(verifier): | |
| claims = [ | |
| _make_claim("C1", "hook_weakness", "high"), | |
| _make_claim("C2", "pacing_issue", "medium"), | |
| ] | |
| rc = _make_components(r1_hook_strength=0.5) | |
| score = verifier.verify_priority_assessment( | |
| priority_assessment="we should just make this better", | |
| critic_claims=claims, | |
| current_reward_components=rc, | |
| ) | |
| assert score == 0.0 | |
| def test_verify_priority_empty_assessment(verifier): | |
| claims = [_make_claim("C1", "hook_weakness", "high")] | |
| rc = _make_components() | |
| score = verifier.verify_priority_assessment("", claims, rc) | |
| assert score == 0.0 | |
| # --------------------------------------------------------------------------- | |
| # ProcessVerifier.verify_conflict_check tests — all 4 known patterns | |
| # --------------------------------------------------------------------------- | |
| def test_conflict_check_hook_rewrite_with_high_r3(verifier): | |
| action = _make_action("hook_rewrite", "hook") | |
| start = _make_components(r1_hook_strength=0.6, r3_cultural_alignment=0.75) | |
| current = _make_components(r1_hook_strength=0.6, r3_cultural_alignment=0.80) | |
| # r3 >= 0.7 → conflict exists → correct answer is "yes" | |
| score = verifier.verify_conflict_check("yes — hook rewrite risks cultural refs", "", action, current, start) | |
| assert score == 1.0 | |
| score_wrong = verifier.verify_conflict_check("no — no conflict", "", action, current, start) | |
| assert score_wrong == 0.0 | |
| def test_conflict_check_section_reorder_with_low_r2(verifier): | |
| action = _make_action("section_reorder", "body") | |
| start = _make_components(r2_coherence=0.5) | |
| current = _make_components(r2_coherence=0.5) | |
| # r2 <= 0.6 → conflict exists | |
| score = verifier.verify_conflict_check("yes", "", action, current, start) | |
| assert score == 1.0 | |
| def test_conflict_check_cultural_ref_sub_with_low_r5(verifier): | |
| action = _make_action("cultural_ref_sub", "full") | |
| start = _make_components(r5_defender_preservation=0.4) | |
| current = _make_components(r5_defender_preservation=0.4) | |
| # r5 <= 0.5 → conflict exists | |
| score = verifier.verify_conflict_check("yes", "", action, current, start) | |
| assert score == 1.0 | |
| def test_conflict_check_cta_placement_with_low_r1(verifier): | |
| action = _make_action("cta_placement", "cta") | |
| start = _make_components(r1_hook_strength=0.3) | |
| current = _make_components(r1_hook_strength=0.3) | |
| # r1 <= 0.4 → conflict exists | |
| score = verifier.verify_conflict_check("yes — CTA premature while hook is weak", "", action, current, start) | |
| assert score == 1.0 | |
| score_wrong = verifier.verify_conflict_check("no conflict detected", "", action, current, start) | |
| assert score_wrong == 0.0 | |
| def test_conflict_check_no_conflict_scenario(verifier): | |
| # hook_rewrite when r3 < 0.7 → no conflict → correct answer is "no" | |
| action = _make_action("hook_rewrite", "hook") | |
| start = _make_components(r3_cultural_alignment=0.5) | |
| current = _make_components(r3_cultural_alignment=0.5) | |
| score = verifier.verify_conflict_check("no — r3 is low, no conflict", "", action, current, start) | |
| assert score == 1.0 | |
| # --------------------------------------------------------------------------- | |
| # ProcessVerifier.verify_defender_consideration tests | |
| # --------------------------------------------------------------------------- | |
| def test_defender_consideration_yes_when_core_in_target(verifier): | |
| # Core strength is in hook, action targets hook → should say yes | |
| action = _make_action("hook_rewrite", "hook") | |
| defender = _make_defender(core_strength_quote="The opening hook draws viewers immediately") | |
| score = verifier.verify_defender_consideration("yes", "", action, defender) | |
| assert score == 1.0 | |
| def test_defender_consideration_no_when_core_not_in_target(verifier): | |
| # Core strength is in CTA section, action targets hook → should say no | |
| action = _make_action("hook_rewrite", "hook") | |
| defender = _make_defender(core_strength_quote="The ending call to action is very strong") | |
| score = verifier.verify_defender_consideration("no", "", action, defender) | |
| assert score == 1.0 | |
| def test_defender_consideration_wrong_answer_scores_zero(verifier): | |
| action = _make_action("hook_rewrite", "hook") | |
| defender = _make_defender(core_strength_quote="The opening hook draws viewers immediately") | |
| score = verifier.verify_defender_consideration("no — no overlap", "", action, defender) | |
| assert score == 0.0 | |
| def test_defender_consideration_empty_answer(verifier): | |
| action = _make_action("hook_rewrite", "hook") | |
| defender = _make_defender() | |
| score = verifier.verify_defender_consideration("", "", action, defender) | |
| assert score == 0.0 | |
| # --------------------------------------------------------------------------- | |
| # ProcessReward.score() weighted total | |
| # --------------------------------------------------------------------------- | |
| def test_process_reward_correct_weighted_total(process_reward): | |
| claims = [ | |
| _make_claim("C1", "hook_weakness", "high"), | |
| _make_claim("C2", "pacing_issue", "medium"), | |
| ] | |
| defender = _make_defender(core_strength_quote="The opening hook draws viewers immediately") | |
| rc = _make_components(r1_hook_strength=0.5, r2_coherence=0.5, r3_cultural_alignment=0.8) | |
| start = _make_components(r1_hook_strength=0.5, r2_coherence=0.5, r3_cultural_alignment=0.8) | |
| chain = ReasoningChain( | |
| priority_assessment="hook_weakness is highest severity", | |
| conflict_check_answer="yes", | |
| conflict_check_reason="hook rewrite risks r3", | |
| defender_consideration_answer="yes", | |
| defender_consideration_reason="core strength is in hook", | |
| action=_make_action("hook_rewrite", "hook"), | |
| ) | |
| result = process_reward.score(chain, claims, defender, rc, start) | |
| assert isinstance(result, ProcessRewardResult) | |
| # All three checks should score 1.0 → process_score = 1.0, contribution = 0.15 | |
| assert result.priority_score == 1.0 | |
| assert result.conflict_score == 1.0 # hook_rewrite + r3 >= 0.7 → conflict, model says yes | |
| assert result.defender_score == 1.0 | |
| assert abs(result.process_score - 1.0) < 1e-6 | |
| assert abs(result.weighted_contribution - 0.15) < 1e-6 | |
| def test_process_reward_zero_for_empty_reasoning(process_reward): | |
| claims = [_make_claim("C1", "hook_weakness", "high")] | |
| defender = _make_defender() | |
| rc = _make_components(r1_hook_strength=0.5) | |
| start = _make_components(r1_hook_strength=0.5) | |
| chain = ReasoningChain( | |
| priority_assessment="", | |
| conflict_check_answer="", | |
| conflict_check_reason="", | |
| defender_consideration_answer="", | |
| defender_consideration_reason="", | |
| action=_make_action("hook_rewrite", "hook"), | |
| ) | |
| result = process_reward.score(chain, claims, defender, rc, start) | |
| assert result.process_score == 0.0 | |
| assert result.weighted_contribution == 0.0 | |
| # --------------------------------------------------------------------------- | |
| # env.step() integration — process_reward in RewardComponents | |
| # --------------------------------------------------------------------------- | |
| _ACTION = { | |
| "action_type": "hook_rewrite", | |
| "target_section": "hook", | |
| "instruction": "Make the hook more engaging.", | |
| "critique_claim_id": "C1", | |
| "reasoning": "Test", | |
| } | |
| _SCRIPTS_PATH = str(Path(__file__).parent.parent / "data" / "test_scripts" / "scripts.json") | |
| _CULTURAL_KB = str(Path(__file__).parent.parent / "data" / "cultural_kb.json") | |
| _MOCK_CRITIC = json.dumps({ | |
| "claims": [ | |
| { | |
| "claim_id": "C1", | |
| "critique_class": "hook_weakness", | |
| "claim_text": "Weak hook.", | |
| "timestamp_range": "0:00-0:03", | |
| "evidence": "generic opener", | |
| "is_falsifiable": True, | |
| "severity": "high", | |
| } | |
| ], | |
| "overall_severity": "high", | |
| }) | |
| _MOCK_DEFENDER = json.dumps({ | |
| "core_strength": "Strong regional authenticity", | |
| "core_strength_quote": "The hook draws viewers immediately", | |
| "defense_argument": "Regional voice is valuable", | |
| "flagged_critic_claims": [], | |
| "regional_voice_elements": ["local phrase"], | |
| }) | |
| _MOCK_REWRITER = json.dumps({ | |
| "rewritten_script": "Better script content here.", | |
| "changes_made": ["improved hook"], | |
| }) | |
| def _multi_mock(sys_prompt, usr_prompt, **kw): | |
| """Return appropriate mock JSON based on which agent is calling.""" | |
| if "core_strength" in sys_prompt or "defender" in sys_prompt.lower(): | |
| return _MOCK_DEFENDER | |
| if "rewriter" in sys_prompt.lower() or "rewrite" in sys_prompt.lower()[:50]: | |
| return _MOCK_REWRITER | |
| return _MOCK_CRITIC | |
| def env_mock_llm(monkeypatch): | |
| monkeypatch.setattr( | |
| "viral_script_engine.agents.llm_backend.LLMBackend.generate", | |
| lambda self, sys_prompt, usr_prompt, **kw: _multi_mock(sys_prompt, usr_prompt, **kw), | |
| ) | |
| def _make_env(): | |
| from viral_script_engine.environment.env import ViralScriptEnv | |
| return ViralScriptEnv( | |
| scripts_path=_SCRIPTS_PATH, | |
| cultural_kb_path=_CULTURAL_KB, | |
| max_steps=1, | |
| difficulty="easy", | |
| use_escalation=False, | |
| ) | |
| def test_env_step_has_process_reward_key(env_mock_llm): | |
| """env.step() must include process_reward key in reward_components.""" | |
| env = _make_env() | |
| env.reset() | |
| _, _, _, _, info = env.step(_ACTION) | |
| rc = info["reward_components"] | |
| assert "process_reward" in rc | |
| def test_env_step_process_reward_graceful_zero(env_mock_llm): | |
| """process_reward is None when no raw_output is supplied (graceful zero).""" | |
| env = _make_env() | |
| env.reset() | |
| _, _, _, _, info = env.step(_ACTION) # no raw_output | |
| rc = info["reward_components"] | |
| assert rc.get("process_reward") is None | |
| assert info.get("process_reward_result") is None | |