vajeeda commited on
Commit
639b641
Β·
1 Parent(s): 5a386b9

refactor(phase0): replace Anthropic SDK with LLMBackend abstraction (default Qwen)

Browse files
viral_script_engine/agents/critic.py CHANGED
@@ -1,12 +1,9 @@
1
  import json
2
- import os
3
  from typing import List
4
 
5
- import anthropic
6
- from dotenv import load_dotenv
7
  from pydantic import BaseModel
8
 
9
- load_dotenv()
10
 
11
  SYSTEM_PROMPT = """You are an expert social media content critic specialising in short-form video scripts for Reels and YouTube Shorts. Your job is to find specific, real problems in creator scripts β€” not vague feedback.
12
 
@@ -70,27 +67,17 @@ class CritiqueOutput(BaseModel):
70
 
71
 
72
  class CriticAgent:
73
- def __init__(self, model_name: str = "claude-sonnet-4-20250514"):
74
- self.model_name = model_name
75
- self.client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
76
-
77
- def _call_api(self, user_content: str) -> str:
78
- message = self.client.messages.create(
79
- model=self.model_name,
80
- max_tokens=2048,
81
- system=SYSTEM_PROMPT,
82
- messages=[{"role": "user", "content": user_content}],
83
- )
84
- return message.content[0].text
85
 
86
- def _parse_response(self, raw: str, user_content: str) -> CritiqueOutput:
87
  try:
88
  data = json.loads(raw)
89
  data["raw_response"] = raw
90
  return CritiqueOutput(**data)
91
  except Exception:
92
- strict_content = user_content + STRICT_RETRY_SUFFIX
93
- raw2 = self._call_api(strict_content)
94
  try:
95
  data = json.loads(raw2)
96
  data["raw_response"] = raw2
@@ -99,8 +86,8 @@ class CriticAgent:
99
  raise CriticParseError(f"Failed to parse critique after 2 attempts: {e}")
100
 
101
  def critique(self, script: str, region: str, platform: str, niche: str) -> CritiqueOutput:
102
- user_content = USER_PROMPT_TEMPLATE.format(
103
  script=script, region=region, platform=platform, niche=niche
104
  )
105
- raw = self._call_api(user_content)
106
- return self._parse_response(raw, user_content)
 
1
  import json
 
2
  from typing import List
3
 
 
 
4
  from pydantic import BaseModel
5
 
6
+ from viral_script_engine.agents.llm_backend import LLMBackend
7
 
8
  SYSTEM_PROMPT = """You are an expert social media content critic specialising in short-form video scripts for Reels and YouTube Shorts. Your job is to find specific, real problems in creator scripts β€” not vague feedback.
9
 
 
67
 
68
 
69
  class CriticAgent:
70
+ def __init__(self, backend: str = "qwen", model_name: str = "Qwen/Qwen2.5-7B-Instruct"):
71
+ self.llm = LLMBackend(backend=backend, model_name=model_name)
 
 
 
 
 
 
 
 
 
 
72
 
73
+ def _parse_response(self, raw: str, user_prompt: str) -> CritiqueOutput:
74
  try:
75
  data = json.loads(raw)
76
  data["raw_response"] = raw
77
  return CritiqueOutput(**data)
78
  except Exception:
79
+ strict_prompt = user_prompt + STRICT_RETRY_SUFFIX
80
+ raw2 = self.llm.generate(SYSTEM_PROMPT, strict_prompt, max_tokens=2048)
81
  try:
82
  data = json.loads(raw2)
83
  data["raw_response"] = raw2
 
86
  raise CriticParseError(f"Failed to parse critique after 2 attempts: {e}")
87
 
88
  def critique(self, script: str, region: str, platform: str, niche: str) -> CritiqueOutput:
89
+ user_prompt = USER_PROMPT_TEMPLATE.format(
90
  script=script, region=region, platform=platform, niche=niche
91
  )
92
+ raw = self.llm.generate(SYSTEM_PROMPT, user_prompt, max_tokens=2048)
93
+ return self._parse_response(raw, user_prompt)
viral_script_engine/agents/llm_backend.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class LLMBackend:
2
+ def __init__(self, backend: str = "qwen", model_name: str = "Qwen/Qwen2.5-7B-Instruct"):
3
+ """
4
+ backend: "qwen" | "anthropic" | "openai"
5
+ Default is local Qwen β€” no API key needed.
6
+ Pipeline is lazy-loaded on first generate() call.
7
+ """
8
+ self.backend = backend
9
+ self.model_name = model_name
10
+ self._pipe = None
11
+ self._client = None
12
+
13
+ if backend not in ("qwen", "anthropic", "openai"):
14
+ raise ValueError(f"Unknown backend: {backend!r}. Choose qwen | anthropic | openai")
15
+
16
+ def _get_pipe(self):
17
+ if self._pipe is None:
18
+ from transformers import pipeline
19
+ self._pipe = pipeline("text-generation", model=self.model_name, device_map="auto")
20
+ return self._pipe
21
+
22
+ def _get_client(self):
23
+ if self._client is None:
24
+ if self.backend == "anthropic":
25
+ import anthropic
26
+ self._client = anthropic.Anthropic()
27
+ elif self.backend == "openai":
28
+ from openai import OpenAI
29
+ self._client = OpenAI()
30
+ return self._client
31
+
32
+ def generate(self, system_prompt: str, user_prompt: str, max_tokens: int = 512) -> str:
33
+ if self.backend == "qwen":
34
+ messages = [
35
+ {"role": "system", "content": system_prompt},
36
+ {"role": "user", "content": user_prompt},
37
+ ]
38
+ out = self._get_pipe()(messages, max_new_tokens=max_tokens, return_full_text=False)
39
+ return out[0]["generated_text"]
40
+
41
+ elif self.backend == "anthropic":
42
+ msg = self._get_client().messages.create(
43
+ model=self.model_name,
44
+ max_tokens=max_tokens,
45
+ system=system_prompt,
46
+ messages=[{"role": "user", "content": user_prompt}],
47
+ )
48
+ return msg.content[0].text
49
+
50
+ elif self.backend == "openai":
51
+ resp = self._get_client().chat.completions.create(
52
+ model=self.model_name,
53
+ max_tokens=max_tokens,
54
+ messages=[
55
+ {"role": "system", "content": system_prompt},
56
+ {"role": "user", "content": user_prompt},
57
+ ],
58
+ )
59
+ return resp.choices[0].message.content
viral_script_engine/requirements.txt CHANGED
@@ -1,7 +1,20 @@
1
- anthropic>=0.40.0
 
 
 
 
 
2
  sentence-transformers>=2.7.0
3
- numpy>=1.26.0
4
  pydantic>=2.0.0
 
5
  python-dotenv>=1.0.0
6
  rich>=13.0.0
 
 
7
  pytest>=8.0.0
 
 
 
 
 
 
 
1
+ # Core β€” required
2
+ transformers>=4.40.0
3
+ torch>=2.2.0
4
+ accelerate>=0.28.0
5
+ unsloth
6
+ trl>=0.12.0
7
  sentence-transformers>=2.7.0
 
8
  pydantic>=2.0.0
9
+ numpy>=1.26.0
10
  python-dotenv>=1.0.0
11
  rich>=13.0.0
12
+ fastapi>=0.110.0
13
+ uvicorn>=0.29.0
14
  pytest>=8.0.0
15
+ matplotlib>=3.8.0
16
+ openenv
17
+
18
+ # Optional β€” only needed if using non-Qwen backends
19
+ anthropic>=0.40.0 # only if backend="anthropic"
20
+ openai>=1.0.0 # only if backend="openai"
viral_script_engine/scripts/run_critic_gate.py CHANGED
@@ -30,8 +30,8 @@ def load_scripts(dry_run: bool) -> list:
30
  return scripts
31
 
32
 
33
- def run_gate(max_retries: int = 3, dry_run: bool = False) -> bool:
34
- agent = CriticAgent()
35
  evaluator = CriticEvaluator()
36
  scripts = load_scripts(dry_run)
37
 
@@ -126,9 +126,11 @@ def main():
126
  parser = argparse.ArgumentParser(description="Run Critic quality gate")
127
  parser.add_argument("--max-retries", type=int, default=3)
128
  parser.add_argument("--dry-run", action="store_true")
 
 
129
  args = parser.parse_args()
130
 
131
- passed = run_gate(max_retries=args.max_retries, dry_run=args.dry_run)
132
  sys.exit(0 if passed else 1)
133
 
134
 
 
30
  return scripts
31
 
32
 
33
+ def run_gate(max_retries: int = 3, dry_run: bool = False, backend: str = "qwen", model_name: str = "Qwen/Qwen2.5-7B-Instruct") -> bool:
34
+ agent = CriticAgent(backend=backend, model_name=model_name)
35
  evaluator = CriticEvaluator()
36
  scripts = load_scripts(dry_run)
37
 
 
126
  parser = argparse.ArgumentParser(description="Run Critic quality gate")
127
  parser.add_argument("--max-retries", type=int, default=3)
128
  parser.add_argument("--dry-run", action="store_true")
129
+ parser.add_argument("--backend", default="qwen", choices=["qwen", "anthropic", "openai"])
130
+ parser.add_argument("--model-name", default="Qwen/Qwen2.5-7B-Instruct")
131
  args = parser.parse_args()
132
 
133
+ passed = run_gate(max_retries=args.max_retries, dry_run=args.dry_run, backend=args.backend, model_name=args.model_name)
134
  sys.exit(0 if passed else 1)
135
 
136
 
viral_script_engine/tests/conftest.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import pytest
3
+
4
+ MOCK_VALID_RESPONSE = json.dumps({
5
+ "claims": [
6
+ {
7
+ "claim_id": "C1",
8
+ "critique_class": "hook_weakness",
9
+ "claim_text": "Weak hook.",
10
+ "timestamp_range": "0:00-0:03",
11
+ "evidence": "Let me tell you a secret",
12
+ "is_falsifiable": True,
13
+ "severity": "high",
14
+ },
15
+ {
16
+ "claim_id": "C2",
17
+ "critique_class": "cta_buried",
18
+ "claim_text": "CTA at end.",
19
+ "timestamp_range": "0:45-0:50",
20
+ "evidence": "Like and save this video",
21
+ "is_falsifiable": True,
22
+ "severity": "medium",
23
+ },
24
+ {
25
+ "claim_id": "C3",
26
+ "critique_class": "pacing_issue",
27
+ "claim_text": "Pacing issue.",
28
+ "timestamp_range": "N/A",
29
+ "evidence": "save twenty percent",
30
+ "is_falsifiable": True,
31
+ "severity": "low",
32
+ },
33
+ ],
34
+ "overall_severity": "high",
35
+ })
36
+
37
+
38
+ @pytest.fixture
39
+ def mock_llm(monkeypatch):
40
+ monkeypatch.setattr(
41
+ "viral_script_engine.agents.llm_backend.LLMBackend.generate",
42
+ lambda self, sys_prompt, usr_prompt, **kw: MOCK_VALID_RESPONSE,
43
+ )
44
+ return MOCK_VALID_RESPONSE
viral_script_engine/tests/test_critic.py CHANGED
@@ -1,9 +1,16 @@
1
  import json
 
 
 
 
2
  import pytest
3
- from unittest.mock import MagicMock, patch
4
 
5
- from viral_script_engine.agents.critic import CritiqueClaim, CritiqueOutput
6
  from viral_script_engine.evaluation.critic_evaluator import CriticEvaluator, EvaluationResult
 
 
 
7
 
8
 
9
  # ── Task 2: Model parsing tests ───────────────────────────────────────────────
@@ -91,11 +98,6 @@ def test_evaluator_fails_low_specificity():
91
 
92
  # ── Task 5: CLI exit-code test ────────────────────────────────────────────────
93
 
94
- import os
95
- import subprocess
96
- import sys
97
-
98
-
99
  def test_cli_dry_run_exits_zero_or_one():
100
  """CLI must exit 0 (pass) or 1 (fail) β€” never crash with unhandled exception."""
101
  result = subprocess.run(
@@ -103,7 +105,7 @@ def test_cli_dry_run_exits_zero_or_one():
103
  capture_output=True,
104
  text=True,
105
  cwd=str(__import__("pathlib").Path(__file__).parent.parent),
106
- env={**os.environ, "ANTHROPIC_API_KEY": "sk-fake-key-for-test"},
107
  )
108
  assert result.returncode in (0, 1), (
109
  f"Unexpected exit code: {result.returncode}\nSTDERR: {result.stderr}"
@@ -112,65 +114,37 @@ def test_cli_dry_run_exits_zero_or_one():
112
 
113
  # ── Task 6: Mocked CriticAgent tests ─────────────────────────────────────────
114
 
115
- from viral_script_engine.agents.critic import CriticAgent, CriticParseError
116
-
117
- MOCK_VALID_JSON = json.dumps({
118
- "claims": [
119
- {"claim_id": "C1", "critique_class": "hook_weakness", "claim_text": "Weak hook.", "timestamp_range": "0:00-0:03", "evidence": "Let me tell you a secret", "is_falsifiable": True, "severity": "high"},
120
- {"claim_id": "C2", "critique_class": "cta_buried", "claim_text": "CTA at end.", "timestamp_range": "0:45-0:50", "evidence": "Like and save this video", "is_falsifiable": True, "severity": "medium"},
121
- {"claim_id": "C3", "critique_class": "pacing_issue", "claim_text": "Pacing issue.", "timestamp_range": "N/A", "evidence": "save twenty percent", "is_falsifiable": True, "severity": "low"},
122
- ],
123
- "overall_severity": "high",
124
- })
125
-
126
- MOCK_INVALID_JSON = "Here is my feedback: The hook is weak and the CTA is missing."
127
-
128
-
129
- @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"})
130
- @patch("viral_script_engine.agents.critic.anthropic.Anthropic")
131
- def test_critic_agent_returns_critique_output(mock_anthropic_cls):
132
- mock_client = MagicMock()
133
- mock_anthropic_cls.return_value = mock_client
134
- mock_msg = MagicMock()
135
- mock_msg.content = [MagicMock(text=MOCK_VALID_JSON)]
136
- mock_client.messages.create.return_value = mock_msg
137
-
138
- agent = CriticAgent()
139
  result = agent.critique("Some script text here", "Mumbai", "Reels", "finance")
140
-
141
  assert len(result.claims) == 3
142
  assert result.claims[0].claim_id == "C1"
143
  assert result.overall_severity == "high"
144
- assert mock_client.messages.create.call_count == 1
145
 
146
 
147
- @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"})
148
- @patch("viral_script_engine.agents.critic.anthropic.Anthropic")
149
- def test_critic_agent_retries_on_bad_json(mock_anthropic_cls):
150
- mock_client = MagicMock()
151
- mock_anthropic_cls.return_value = mock_client
152
- bad_msg = MagicMock()
153
- bad_msg.content = [MagicMock(text=MOCK_INVALID_JSON)]
154
- good_msg = MagicMock()
155
- good_msg.content = [MagicMock(text=MOCK_VALID_JSON)]
156
- mock_client.messages.create.side_effect = [bad_msg, good_msg]
157
 
158
- agent = CriticAgent()
 
 
 
159
  result = agent.critique("Some script text here", "Mumbai", "Reels", "finance")
160
  assert len(result.claims) == 3
161
- assert mock_client.messages.create.call_count == 2
162
 
163
 
164
- @patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"})
165
- @patch("viral_script_engine.agents.critic.anthropic.Anthropic")
166
- def test_critic_agent_raises_parse_error_after_two_failures(mock_anthropic_cls):
167
- mock_client = MagicMock()
168
- mock_anthropic_cls.return_value = mock_client
169
- bad_msg = MagicMock()
170
- bad_msg.content = [MagicMock(text=MOCK_INVALID_JSON)]
171
- mock_client.messages.create.return_value = bad_msg
172
-
173
- agent = CriticAgent()
174
  with pytest.raises(CriticParseError):
175
  agent.critique("Some script text here", "Mumbai", "Reels", "finance")
176
- assert mock_client.messages.create.call_count == 2
 
1
  import json
2
+ import os
3
+ import subprocess
4
+ import sys
5
+
6
  import pytest
7
+ from unittest.mock import patch
8
 
9
+ from viral_script_engine.agents.critic import CritiqueClaim, CritiqueOutput, CriticAgent, CriticParseError
10
  from viral_script_engine.evaluation.critic_evaluator import CriticEvaluator, EvaluationResult
11
+ from tests.conftest import MOCK_VALID_RESPONSE
12
+
13
+ MOCK_INVALID_RESPONSE = "Here is my feedback: The hook is weak and the CTA is missing."
14
 
15
 
16
  # ── Task 2: Model parsing tests ───────────────────────────────────────────────
 
98
 
99
  # ── Task 5: CLI exit-code test ────────────────────────────────────────────────
100
 
 
 
 
 
 
101
  def test_cli_dry_run_exits_zero_or_one():
102
  """CLI must exit 0 (pass) or 1 (fail) β€” never crash with unhandled exception."""
103
  result = subprocess.run(
 
105
  capture_output=True,
106
  text=True,
107
  cwd=str(__import__("pathlib").Path(__file__).parent.parent),
108
+ env={**os.environ},
109
  )
110
  assert result.returncode in (0, 1), (
111
  f"Unexpected exit code: {result.returncode}\nSTDERR: {result.stderr}"
 
114
 
115
  # ── Task 6: Mocked CriticAgent tests ─────────────────────────────────────────
116
 
117
+ def test_critic_agent_returns_critique_output(mock_llm):
118
+ agent = CriticAgent(backend="qwen")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  result = agent.critique("Some script text here", "Mumbai", "Reels", "finance")
 
120
  assert len(result.claims) == 3
121
  assert result.claims[0].claim_id == "C1"
122
  assert result.overall_severity == "high"
 
123
 
124
 
125
+ def test_critic_agent_retries_on_bad_json(monkeypatch):
126
+ calls = []
127
+
128
+ def fake_generate(self, sys_prompt, usr_prompt, **kw):
129
+ calls.append(usr_prompt)
130
+ if len(calls) == 1:
131
+ return MOCK_INVALID_RESPONSE
132
+ return MOCK_VALID_RESPONSE
 
 
133
 
134
+ monkeypatch.setattr(
135
+ "viral_script_engine.agents.llm_backend.LLMBackend.generate", fake_generate
136
+ )
137
+ agent = CriticAgent(backend="qwen")
138
  result = agent.critique("Some script text here", "Mumbai", "Reels", "finance")
139
  assert len(result.claims) == 3
140
+ assert len(calls) == 2
141
 
142
 
143
+ def test_critic_agent_raises_parse_error_after_two_failures(monkeypatch):
144
+ monkeypatch.setattr(
145
+ "viral_script_engine.agents.llm_backend.LLMBackend.generate",
146
+ lambda self, sys_prompt, usr_prompt, **kw: MOCK_INVALID_RESPONSE,
147
+ )
148
+ agent = CriticAgent(backend="qwen")
 
 
 
 
149
  with pytest.raises(CriticParseError):
150
  agent.critique("Some script text here", "Mumbai", "Reels", "finance")