File size: 13,466 Bytes
dfa9070
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Phase 8 tests β€” Creator Persona Modelling."""
import json
import sys
from pathlib import Path
from unittest.mock import patch

import pytest

sys.path.insert(0, str(Path(__file__).parent.parent.parent))

from viral_script_engine.personas.creator_profile import CreatorProfile, CreatorTier, PostingFrequency
from viral_script_engine.personas.profile_generator import ProfileGenerator
from viral_script_engine.rewards.r8_persona_fit import PersonaFitReward
from viral_script_engine.environment.actions import ArbitratorAction, ActionType

KB_PATH = str(Path(__file__).parent.parent / "data" / "persona_advice_kb.json")


# ── ProfileGenerator ──────────────────────────────────────────────────────────

class TestProfileGenerator:
    def setup_method(self):
        self.gen = ProfileGenerator()

    def test_generate_beginner_within_range(self):
        p = self.gen.generate(CreatorTier.BEGINNER, "cooking", seed=1)
        assert 50 <= p.follower_count <= 999
        assert 0.08 <= p.avg_engagement_rate <= 0.15
        assert p.tier == CreatorTier.BEGINNER
        assert p.niche == "cooking"

    def test_generate_growing_within_range(self):
        p = self.gen.generate(CreatorTier.GROWING, "fitness", seed=2)
        assert 1000 <= p.follower_count <= 9999
        assert 0.04 <= p.avg_engagement_rate <= 0.08
        assert p.tier == CreatorTier.GROWING

    def test_generate_established_within_range(self):
        p = self.gen.generate(CreatorTier.ESTABLISHED, "tech reviews", seed=3)
        assert 10000 <= p.follower_count <= 99999
        assert 0.02 <= p.avg_engagement_rate <= 0.04
        assert p.tier == CreatorTier.ESTABLISHED

    def test_generate_verified_within_range(self):
        p = self.gen.generate(CreatorTier.VERIFIED, "comedy", seed=4)
        assert 100000 <= p.follower_count <= 2000000
        assert 0.01 <= p.avg_engagement_rate <= 0.02
        assert p.tier == CreatorTier.VERIFIED

    def test_generate_is_deterministic(self):
        p1 = self.gen.generate(CreatorTier.GROWING, "cooking", seed=42)
        p2 = self.gen.generate(CreatorTier.GROWING, "cooking", seed=42)
        assert p1.follower_count == p2.follower_count
        assert p1.avg_engagement_rate == p2.avg_engagement_rate
        assert p1.past_weak_points == p2.past_weak_points

    def test_generate_profile_has_weak_and_strong_points(self):
        p = self.gen.generate(CreatorTier.BEGINNER, "education", seed=7)
        assert 1 <= len(p.past_weak_points) <= 3
        assert 1 <= len(p.past_strong_points) <= 2
        overlap = set(p.past_weak_points) & set(p.past_strong_points)
        assert len(overlap) == 0, "Weak and strong points must not overlap"

    def test_generate_valid_pydantic_model(self):
        p = self.gen.generate(CreatorTier.ESTABLISHED, "personal finance", seed=10)
        assert isinstance(p, CreatorProfile)
        assert isinstance(p.posting_frequency, PostingFrequency)
        assert 0.0 <= p.avg_retention_rate <= 1.0

    def test_generate_batch_size(self):
        profiles = self.gen.generate_batch(20)
        assert len(profiles) == 20

    def test_generate_batch_tier_distribution(self):
        profiles = self.gen.generate_batch(200)
        tiers = [p.tier for p in profiles]
        beginner_ratio = tiers.count(CreatorTier.BEGINNER) / len(tiers)
        verified_ratio = tiers.count(CreatorTier.VERIFIED) / len(tiers)
        # beginner should be highest, verified should be lowest
        assert beginner_ratio > verified_ratio
        # beginner should be roughly 40% Β± 15%
        assert 0.25 <= beginner_ratio <= 0.55

    def test_needs_fundamentals_property(self):
        beginner = self.gen.generate(CreatorTier.BEGINNER, "cooking", seed=1)
        verified = self.gen.generate(CreatorTier.VERIFIED, "cooking", seed=1)
        assert beginner.needs_fundamentals is True
        assert verified.needs_fundamentals is False

    def test_needs_refinement_property(self):
        established = self.gen.generate(CreatorTier.ESTABLISHED, "cooking", seed=1)
        beginner = self.gen.generate(CreatorTier.BEGINNER, "cooking", seed=1)
        assert established.needs_refinement is True
        assert beginner.needs_refinement is False


# ── PersonaFitReward ───────────────────────────────────────────────────────────

def _make_action(action_type: ActionType) -> ArbitratorAction:
    return ArbitratorAction(
        action_type=action_type,
        target_section="hook",
        instruction="Test instruction",
        critique_claim_id="C1",
        reasoning="Test reasoning",
    )


def _make_profile(tier: CreatorTier, weak_points=None) -> CreatorProfile:
    gen = ProfileGenerator()
    p = gen.generate(tier=tier, niche="fitness", seed=99)
    if weak_points is not None:
        p = p.model_copy(update={"past_weak_points": weak_points})
    return p


class TestPersonaFitReward:
    def setup_method(self):
        self.r8 = PersonaFitReward(kb_path=KB_PATH)

    def test_priority_action_scores_1(self):
        # hook_rewrite is priority for beginner
        action = _make_action(ActionType.HOOK_REWRITE)
        profile = _make_profile(CreatorTier.BEGINNER)
        result = self.r8.score(action, profile, addressed_critique_class="irrelevant")
        assert result.score == 1.0
        assert result.tier_match == "priority"
        assert result.is_forbidden is False

    def test_forbidden_action_scores_0(self):
        # hook_rewrite is forbidden for verified
        action = _make_action(ActionType.HOOK_REWRITE)
        profile = _make_profile(CreatorTier.VERIFIED)
        result = self.r8.score(action, profile, addressed_critique_class="hook_weakness")
        assert result.score == 0.0
        assert result.is_forbidden is True

    def test_deprioritised_action_scores_low(self):
        # cultural_ref_sub is deprioritised for beginner
        # pass explicit weak_points that exclude cultural_mismatch to avoid the +0.1 bonus
        action = _make_action(ActionType.CULTURAL_REF_SUB)
        profile = _make_profile(CreatorTier.BEGINNER, weak_points=["hook_weakness"])
        result = self.r8.score(action, profile, addressed_critique_class="cultural_mismatch")
        assert result.score == pytest.approx(0.2, abs=0.01)
        assert result.tier_match == "deprioritised"

    def test_neutral_action_scores_mid(self):
        # cta_placement is neutral for growing tier:
        #   priority=[hook_rewrite, section_reorder], deprioritised=[cultural_ref_sub], forbidden=[]
        # pass weak_points that exclude cta_buried to avoid the +0.1 bonus
        action = _make_action(ActionType.CTA_PLACEMENT)
        profile = _make_profile(CreatorTier.GROWING, weak_points=["hook_weakness"])
        result = self.r8.score(action, profile, addressed_critique_class="cta_buried")
        assert result.score == pytest.approx(0.5, abs=0.01)
        assert result.tier_match == "neutral"

    def test_recurring_weakness_bonus_applied(self):
        # beginner, hook_rewrite (priority=1.0) + hook_weakness in weak points
        action = _make_action(ActionType.HOOK_REWRITE)
        profile = _make_profile(CreatorTier.BEGINNER, weak_points=["hook_weakness", "cta_buried"])
        result = self.r8.score(action, profile, addressed_critique_class="hook_weakness")
        assert result.recurring_weakness_bonus == pytest.approx(0.1)
        assert result.score == pytest.approx(1.0)  # capped at 1.0

    def test_recurring_weakness_bonus_not_applied_when_not_matching(self):
        action = _make_action(ActionType.HOOK_REWRITE)
        profile = _make_profile(CreatorTier.BEGINNER, weak_points=["pacing_issue"])
        result = self.r8.score(action, profile, addressed_critique_class="hook_weakness")
        assert result.recurring_weakness_bonus == 0.0
        assert result.score == pytest.approx(1.0)

    def test_score_capped_at_1(self):
        # priority (1.0) + bonus (0.1) should be capped at 1.0
        action = _make_action(ActionType.HOOK_REWRITE)
        profile = _make_profile(CreatorTier.BEGINNER, weak_points=["hook_weakness"])
        result = self.r8.score(action, profile, addressed_critique_class="hook_weakness")
        assert result.score <= 1.0

    def test_result_has_explanation(self):
        action = _make_action(ActionType.SECTION_REORDER)
        profile = _make_profile(CreatorTier.GROWING)
        result = self.r8.score(action, profile, addressed_critique_class="pacing_issue")
        assert isinstance(result.explanation, str)
        assert len(result.explanation) > 0


import json as _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):
    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


# ── Environment integration ────────────────────────────────────────────────────

class TestEnvironmentIntegration:
    """Tests that env.reset() and step() produce correct profile and R8."""

    def _make_env(self, difficulty="medium"):
        from viral_script_engine.environment.env import ViralScriptEnv
        base = Path(__file__).parent.parent
        return ViralScriptEnv(
            scripts_path=str(base / "data" / "test_scripts" / "scripts.json"),
            cultural_kb_path=str(base / "data" / "cultural_kb.json"),
            max_steps=2,
            difficulty=difficulty,
            use_anti_gaming=False,
            use_escalation=False,
        )

    def test_reset_returns_creator_profile(self):
        env = self._make_env()
        obs, _ = env.reset(seed=1)
        assert "creator_profile" in obs
        assert obs["creator_profile"] is not None
        assert "tier" in obs["creator_profile"]

    def test_profile_tier_matches_difficulty_easy(self):
        env = self._make_env(difficulty="easy")
        obs, _ = env.reset(seed=1)
        tier = obs["creator_profile"]["tier"]
        assert tier in ["beginner", "growing"]

    def test_profile_tier_matches_difficulty_hard(self):
        env = self._make_env(difficulty="hard")
        obs, _ = env.reset(seed=1)
        tier = obs["creator_profile"]["tier"]
        assert tier in ["established", "verified"]

    def test_step_returns_r8_in_reward_components(self, 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),
        )
        env = self._make_env()
        env.reset(seed=5)
        action = {
            "action_type": "hook_rewrite",
            "target_section": "hook",
            "instruction": "Rewrite the hook.",
            "critique_claim_id": "C1",
            "reasoning": "Testing R8",
        }
        obs, reward, done, trunc, info = env.step(action)
        rc = info["reward_components"]
        assert "r8_persona_fit" in rc

    def test_observation_includes_profile_dict(self):
        env = self._make_env()
        obs, _ = env.reset(seed=3)
        profile = obs["creator_profile"]
        assert isinstance(profile["follower_count"], int)
        assert isinstance(profile["avg_engagement_rate"], float)
        assert isinstance(profile["past_weak_points"], list)

    def test_prompt_template_includes_profile_fields(self):
        from viral_script_engine.training.rollout_function import _format_observation_prompt
        obs = {
            "current_script": "Test script",
            "region": "Mumbai",
            "platform": "Reels",
            "niche": "fitness",
            "reward_components": {"r1_hook_strength": 0.5, "r2_coherence": 0.6},
            "debate_history": [],
            "creator_profile": {
                "tier": "growing",
                "follower_count": 4200,
                "posting_frequency": "regular",
                "past_weak_points": ["hook_weakness", "cta_buried"],
                "voice_descriptors": ["direct", "Hinglish"],
                "niche_maturity": "established_in_niche",
            },
        }
        prompt = _format_observation_prompt(obs, step_num=1, max_steps=3)
        assert "CREATOR PROFILE" in prompt
        assert "growing" in prompt
        assert "4200" in prompt
        assert "hook_weakness" in prompt