File size: 15,393 Bytes
0e4f105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
"""
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
# ---------------------------------------------------------------------------

@pytest.fixture
def parser():
    return ReasoningParser()


@pytest.fixture
def verifier():
    return ProcessVerifier()


@pytest.fixture
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


@pytest.fixture
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