ArabicNewsAnalyzer commited on
Commit
51d2734
·
verified ·
1 Parent(s): ab937a7

Upload 17 files

Browse files

cleaned and removed wikidata

agents/claim_extractor.py CHANGED
@@ -1,28 +1,3 @@
1
- """
2
- Agent 1 — Claim Extractor (LLM Version)
3
- Arabic News Analyzer · Graduation Project
4
-
5
- Extracts atomic, verifiable factual claims from Arabic news text.
6
- Uses Groq (llama-3.3-70b-versatile) — same stack as Agents 3 & 4.
7
-
8
- Why LLM over NER/POS:
9
- - Arabic morphology defeats rule-based POS pipelines on news text
10
- - LLMs understand discourse boundaries → true atomic claim splitting
11
- - Numerical/relational claims ("300 injuries", "first flight") are
12
- invisible to NER but trivial for an LLM
13
- - Produces fully self-contained claims — no [سياق:] workarounds needed
14
- - No external Gradio Space dependency
15
-
16
- Output per claim:
17
- A complete, standalone Arabic sentence with the subject always explicit.
18
- e.g. "أعلنت إدارة نادي ريال مدريد أن التقديم الرسمي لمبابي سيكون في برنابيو"
19
- NOT: "وأضافت أن التقديم الرسمي سيكون في برنابيو"
20
-
21
- Required setup:
22
- pip install groq python-dotenv
23
- Add to .env: GROQ_API_KEY=gsk_...
24
- """
25
-
26
  import asyncio
27
  import json
28
  import logging
@@ -34,21 +9,17 @@ from dotenv import load_dotenv
34
 
35
  load_dotenv()
36
 
37
- # ── Logging ───────────────────────────────────────────────────────────────────
38
  logging.basicConfig(
39
  level=logging.INFO,
40
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
41
  )
42
  logger = logging.getLogger(__name__)
43
 
44
- # ── Config ────────────────────────────────────────────────────────────────────
45
  GROQ_MODEL = "llama-3.3-70b-versatile"
46
  MAX_TOKENS = 1024
47
- # Zero temperature = fully deterministic extraction, no creativity wanted here
48
  TEMPERATURE = 0.0
49
  MAX_RETRIES = 2
50
 
51
- # ── System prompt ─────────────────────────────────────────────────────────────
52
  SYSTEM_PROMPT = """أنت نظام متخصص في استخراج الادعاءات الإخبارية القابلة للتحقق من النصوص العربية.
53
 
54
  ━━━ تعريف الادعاء القابل للتحقق ━━━
@@ -94,24 +65,6 @@ SYSTEM_PROMPT = """أنت نظام متخصص في استخراج الادعاء
94
 
95
 
96
  class ClaimExtractor:
97
- """
98
- Agent 1 — Extracts atomic verifiable claims from Arabic news text.
99
-
100
- Every claim is a fully self-contained Arabic sentence with an explicit
101
- subject — no pronouns, no implicit references. Safe to pass directly
102
- to Agent 2 as a search query without any preprocessing.
103
-
104
- Usage (async):
105
- extractor = ClaimExtractor()
106
- claims = await extractor.run(text)
107
-
108
- Usage (sync, for standalone testing):
109
- claims = asyncio.run(extractor.run(text))
110
-
111
- Output:
112
- List of self-contained claim strings, each independently verifiable.
113
- """
114
-
115
  def __init__(self):
116
  api_key = os.environ.get("GROQ_API_KEY2")
117
  if not api_key:
@@ -119,20 +72,10 @@ class ClaimExtractor:
119
  "GROQ_API_KEY2 not set — ClaimExtractor will fail at runtime.")
120
  self.client = AsyncGroq(api_key=api_key)
121
 
122
- # ── Public entry point ────────────────────────────────────────────────────
123
-
124
  async def run(self, text: str) -> list[str]:
125
- """
126
- Main entry point. Takes raw Arabic article text,
127
- returns a list of atomic claim strings.
128
-
129
- Retries once on JSON parse failure (same pattern as Verifier).
130
- Returns empty list on complete failure — never raises.
131
- """
132
  if not text or not text.strip():
133
  return []
134
 
135
- # First attempt
136
  result = await self._call_groq(text)
137
  if result is not None:
138
  return result
@@ -148,13 +91,7 @@ class ClaimExtractor:
148
  "[ClaimExtractor] Both attempts failed — returning empty list.")
149
  return []
150
 
151
- # ── Groq API call ─────────────────────────────────────────────────────────
152
-
153
  async def _call_groq(self, text: str) -> list[str] | None:
154
- """
155
- Single Groq API call.
156
- Returns parsed claims list on success, None on any failure.
157
- """
158
  try:
159
  response = await self.client.chat.completions.create(
160
  model=GROQ_MODEL,
@@ -172,32 +109,17 @@ class ClaimExtractor:
172
  raw = response.choices[0].message.content or ""
173
  return self._parse_response(raw)
174
 
175
- # ── Response parsing ─────────────────���────────────────────────────────────
176
-
177
  def _parse_response(self, raw: str) -> list[str] | None:
178
- """
179
- Parse Groq's JSON response into a list of claim strings.
180
-
181
- Handles:
182
- - Clean JSON
183
- - JSON wrapped in ```json ... ``` markdown fences
184
- - JSON embedded in prose
185
-
186
- Returns None if parsing fails completely (triggers retry).
187
- """
188
- # Strip markdown fences
189
  clean = re.sub(r"```json\s*", "", raw)
190
  clean = re.sub(r"```\s*", "", clean)
191
  clean = clean.strip()
192
 
193
- # Direct parse
194
  try:
195
  parsed = json.loads(clean)
196
  return self._validate_claims(parsed)
197
  except json.JSONDecodeError:
198
  pass
199
 
200
- # Extract JSON object from prose
201
  match = re.search(r"\{.*\}", clean, re.DOTALL)
202
  if match:
203
  try:
@@ -210,10 +132,6 @@ class ClaimExtractor:
210
  return None
211
 
212
  def _validate_claims(self, parsed: dict) -> list[str]:
213
- """
214
- Validate the parsed dict and return a clean claims list.
215
- Filters out empty strings or non-string entries.
216
- """
217
  claims = parsed.get("claims", [])
218
  if not isinstance(claims, list):
219
  return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import asyncio
2
  import json
3
  import logging
 
9
 
10
  load_dotenv()
11
 
 
12
  logging.basicConfig(
13
  level=logging.INFO,
14
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
15
  )
16
  logger = logging.getLogger(__name__)
17
 
 
18
  GROQ_MODEL = "llama-3.3-70b-versatile"
19
  MAX_TOKENS = 1024
 
20
  TEMPERATURE = 0.0
21
  MAX_RETRIES = 2
22
 
 
23
  SYSTEM_PROMPT = """أنت نظام متخصص في استخراج الادعاءات الإخبارية القابلة للتحقق من النصوص العربية.
24
 
25
  ━━━ تعريف الادعاء القابل للتحقق ━━━
 
65
 
66
 
67
  class ClaimExtractor:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  def __init__(self):
69
  api_key = os.environ.get("GROQ_API_KEY2")
70
  if not api_key:
 
72
  "GROQ_API_KEY2 not set — ClaimExtractor will fail at runtime.")
73
  self.client = AsyncGroq(api_key=api_key)
74
 
 
 
75
  async def run(self, text: str) -> list[str]:
 
 
 
 
 
 
 
76
  if not text or not text.strip():
77
  return []
78
 
 
79
  result = await self._call_groq(text)
80
  if result is not None:
81
  return result
 
91
  "[ClaimExtractor] Both attempts failed — returning empty list.")
92
  return []
93
 
 
 
94
  async def _call_groq(self, text: str) -> list[str] | None:
 
 
 
 
95
  try:
96
  response = await self.client.chat.completions.create(
97
  model=GROQ_MODEL,
 
109
  raw = response.choices[0].message.content or ""
110
  return self._parse_response(raw)
111
 
 
 
112
  def _parse_response(self, raw: str) -> list[str] | None:
 
 
 
 
 
 
 
 
 
 
 
113
  clean = re.sub(r"```json\s*", "", raw)
114
  clean = re.sub(r"```\s*", "", clean)
115
  clean = clean.strip()
116
 
 
117
  try:
118
  parsed = json.loads(clean)
119
  return self._validate_claims(parsed)
120
  except json.JSONDecodeError:
121
  pass
122
 
 
123
  match = re.search(r"\{.*\}", clean, re.DOTALL)
124
  if match:
125
  try:
 
132
  return None
133
 
134
  def _validate_claims(self, parsed: dict) -> list[str]:
 
 
 
 
135
  claims = parsed.get("claims", [])
136
  if not isinstance(claims, list):
137
  return []
agents/evidence_retriever.py CHANGED
@@ -1,33 +1,3 @@
1
- """
2
- Agent 2 - Evidence Retriever
3
- Arabic News Analyzer - Graduation Project
4
-
5
- Retrieves evidence for each claim from 3 parallel sources:
6
- 1. Wikidata - structured entity facts (free, no key)
7
- 2. Google FC - already fact-checked claims (free, needs API key)
8
- 3. Tavily - real-time web evidence (free tier, needs API key)
9
-
10
- All three run in parallel via asyncio.gather.
11
- SPARQLWrapper is synchronous - wrapped in run_in_executor to avoid
12
- blocking the event loop.
13
-
14
- Google FC and Tavily can optionally be restricted to a whitelist
15
- of trusted domains (see config/trusted_sources.py). Wikidata is not
16
- domain-restricted since it's structured entity data, not open web search.
17
-
18
- Input: self-contained claim string from Agent 1
19
- (fully explicit subject — no pronouns, no [سياق:] tags)
20
- Output: list of evidence dicts, each with {snippet, source_url, source, rating}
21
-
22
- Required setup:
23
- pip install SPARQLWrapper tavily-python httpx python-dotenv
24
- Create .env file in project root with:
25
- GOOGLE_FACTCHECK_API_KEY=AIza...
26
- TAVILY_API_KEY=tvly-...
27
- Get free Google FC key at: https://developers.google.com/fact-check/tools/api
28
- Get free Tavily key at: https://tavily.com
29
- """
30
-
31
  import asyncio
32
  import sys
33
  from pathlib import Path
@@ -36,30 +6,11 @@ if __package__ in {None, ""}:
36
  sys.path.append(str(Path(__file__).resolve().parent.parent))
37
 
38
  from config.trusted_sources import TRUSTED_DOMAINS
39
- from tools.tavily_search import search_tavily
40
  from tools.google_factcheck import search_google_factcheck
41
- from tools.wikidata import search_wikidata
42
 
43
 
44
  class EvidenceRetriever:
45
- """
46
- Agent 2 - retrieves evidence for a single claim from 3 sources in parallel.
47
-
48
- Each source returns a list of evidence dicts:
49
- {
50
- "snippet": str - the evidence text
51
- "source_url": str - where it came from
52
- "source": str - "wikidata" | "google_factcheck" | "tavily"
53
- "rating": str | None - verdict rating (google_factcheck only)
54
- }
55
-
56
- Usage:
57
- retriever = EvidenceRetriever() # uses default trusted whitelist
58
- retriever = EvidenceRetriever(domains=["bbc.com"]) # custom whitelist
59
- retriever = EvidenceRetriever(domains=None) # no restriction, open web
60
- evidence = await retriever.run(claim)
61
- """
62
-
63
  def __init__(self, domains: list[str] | None = TRUSTED_DOMAINS):
64
  self.domains = domains
65
 
@@ -71,59 +22,31 @@ class EvidenceRetriever:
71
  print(f"[DEBUG] Domain whitelist: {self.domains}")
72
 
73
  results = await asyncio.gather(
74
- self._search_wikidata(claim),
75
  self._search_google_factcheck(search_query),
76
  self._search_tavily(search_query),
77
  return_exceptions=True,
78
  )
79
 
80
  evidence = []
81
- source_names = ["wikidata", "google_factcheck", "tavily"]
82
  for i, result_set in enumerate(results):
83
  if isinstance(result_set, Exception):
84
  print(
85
- f"[EvidenceRetriever] {source_names[i]} error: {type(result_set).__name__}: {result_set!r}")
 
86
  continue
87
- print(
88
- f"[DEBUG] {source_names[i]} returned {len(result_set)} results:")
89
  for r in result_set:
90
- print(
91
- f" - {r.get('source_url', '')} | {r.get('snippet', '')[:120]}")
92
  evidence.extend(result_set)
93
 
94
  return evidence
95
 
96
  def _build_query(self, claim: str) -> str:
97
- """
98
- Build search query for Tavily and Google FC.
99
- Arabic verification prefix biases results toward fact-check sources.
100
- """
101
  return f"تحقق {claim}"
102
 
103
- async def _search_wikidata(self, claim: str) -> list[dict]:
104
- """
105
- Searches Wikidata for entities mentioned in the claim.
106
- SPARQLWrapper is synchronous -> run in executor.
107
- Tries Arabic label first, falls back to English.
108
- """
109
- return await search_wikidata(claim)
110
-
111
  async def _search_google_factcheck(self, query: str) -> list[dict]:
112
- """
113
- Searches Google Fact Check Tools API for matching fact-checked claims.
114
- Returns publisher verdict + review URL as evidence.
115
- Requires GOOGLE_FACTCHECK_API_KEY env var.
116
- """
117
  return await search_google_factcheck(query)
118
 
119
  async def _search_tavily(self, query: str) -> list[dict]:
120
- """
121
- Searches Tavily for real-time Arabic web evidence.
122
- Runs two queries in parallel:
123
- 1. fact-check angle (تحقق + claim + context)
124
- 2. news angle (claim + مصدر رسمي)
125
- Merges and deduplicates by URL.
126
- If self.domains is set, results are restricted to the whitelist
127
- (both via Tavily's include_domains and a local post-filter).
128
- """
129
  return await search_tavily(query, domains=self.domains)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import asyncio
2
  import sys
3
  from pathlib import Path
 
6
  sys.path.append(str(Path(__file__).resolve().parent.parent))
7
 
8
  from config.trusted_sources import TRUSTED_DOMAINS
 
9
  from tools.google_factcheck import search_google_factcheck
10
+ from tools.tavily_search import search_tavily
11
 
12
 
13
  class EvidenceRetriever:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  def __init__(self, domains: list[str] | None = TRUSTED_DOMAINS):
15
  self.domains = domains
16
 
 
22
  print(f"[DEBUG] Domain whitelist: {self.domains}")
23
 
24
  results = await asyncio.gather(
 
25
  self._search_google_factcheck(search_query),
26
  self._search_tavily(search_query),
27
  return_exceptions=True,
28
  )
29
 
30
  evidence = []
31
+ source_names = ["google_factcheck", "tavily"]
32
  for i, result_set in enumerate(results):
33
  if isinstance(result_set, Exception):
34
  print(
35
+ f"[EvidenceRetriever] {source_names[i]} error: {type(result_set).__name__}: {result_set!r}"
36
+ )
37
  continue
38
+ print(f"[DEBUG] {source_names[i]} returned {len(result_set)} results:")
 
39
  for r in result_set:
40
+ print(f" - {r.get('source_url', '')} | {r.get('snippet', '')[:120]}")
 
41
  evidence.extend(result_set)
42
 
43
  return evidence
44
 
45
  def _build_query(self, claim: str) -> str:
 
 
 
 
46
  return f"تحقق {claim}"
47
 
 
 
 
 
 
 
 
 
48
  async def _search_google_factcheck(self, query: str) -> list[dict]:
 
 
 
 
 
49
  return await search_google_factcheck(query)
50
 
51
  async def _search_tavily(self, query: str) -> list[dict]:
 
 
 
 
 
 
 
 
 
52
  return await search_tavily(query, domains=self.domains)
agents/explainer.py CHANGED
@@ -1,34 +1,3 @@
1
- """
2
- Agent 4 — Explainer
3
- Arabic News Analyzer · Graduation Project
4
-
5
- Receives the claim + evidence + verdict from Agent 3.
6
- Calls Groq API to produce a human-readable Arabic explanation
7
- with cited sources.
8
-
9
- Output schema:
10
- {
11
- "arabic_explanation": "...", # full Arabic paragraph for the user
12
- "verdict": "...", # echoed from Agent 3
13
- "confidence": 0.0, # echoed from Agent 3
14
- "citations": [
15
- {"title": "...", "url": "..."},
16
- ...
17
- ]
18
- }
19
-
20
- Rules:
21
- - One Groq call per claim
22
- - Retry once (with 1s delay) on JSON parse failure
23
- - Default to a minimal explanation on any error
24
- - Uses AsyncGroq — non-blocking, compatible with FastAPI + LangGraph async
25
- - Pydantic models for validated I/O schema
26
-
27
- Required setup:
28
- pip install groq python-dotenv pydantic
29
- Add to .env: GROQ_API_KEY5=gsk_...
30
- """
31
-
32
  import asyncio
33
  import json
34
  import logging
@@ -41,25 +10,22 @@ from pydantic import BaseModel, field_validator
41
 
42
  load_dotenv()
43
 
44
- # ── Logging ───────────────────────────────────────────────────────────────────
45
  logging.basicConfig(
46
  level=logging.INFO,
47
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
48
  )
49
  logger = logging.getLogger(__name__)
50
 
51
- # ── Config ────────────────────────────────────────────────────────────────────
52
  GROQ_MODEL = "llama-3.3-70b-versatile"
53
  MAX_TOKENS = 1024
54
- TEMPERATURE = 0.1 # slightly higher than Agent 3 — explanation needs fluency
55
- RETRY_DELAY = 1.0 # seconds to wait before retry (handles rate-limit spikes)
56
 
57
- MAX_EVIDENCE = 6 # cap sources passed to the prompt
58
- MAX_SNIPPET_LEN = 200 # chars per snippet in prompt
59
 
60
  VALID_VERDICTS = {"SUPPORTED", "REFUTED", "PARTIALLY_TRUE", "UNVERIFIABLE"}
61
 
62
- # ── Verdict Arabic labels ─────────────────────────────────────────────────────
63
  VERDICT_LABELS = {
64
  "SUPPORTED": "✓ مدعوم بالأدلة",
65
  "REFUTED": "✗ مدحوض",
@@ -67,8 +33,6 @@ VERDICT_LABELS = {
67
  "UNVERIFIABLE": "? لا يمكن التحقق",
68
  }
69
 
70
- # ── Pydantic I/O models ───────────────────────────────────────────────────────
71
-
72
 
73
  class Citation(BaseModel):
74
  title: str = ""
@@ -77,7 +41,6 @@ class Citation(BaseModel):
77
  @field_validator("url")
78
  @classmethod
79
  def url_must_be_http(cls, v: str) -> str:
80
- """Reject anything that does not look like a real URL."""
81
  if v and not v.startswith("http"):
82
  return ""
83
  return v
@@ -103,8 +66,6 @@ class ExplainerOutput(BaseModel):
103
 
104
 
105
  class VerdictInput(BaseModel):
106
- """Expected shape of Agent 3's output."""
107
-
108
  verdict: str = "UNVERIFIABLE"
109
  confidence: float = 0.0
110
  reasoning: str = ""
@@ -116,7 +77,6 @@ class VerdictInput(BaseModel):
116
  return v if v in VALID_VERDICTS else "UNVERIFIABLE"
117
 
118
 
119
- # ── Default output when everything fails ──────────────────────────────────────
120
  def _default_explanation(
121
  claim: str, verdict: str, confidence: float
122
  ) -> ExplainerOutput:
@@ -133,7 +93,6 @@ def _default_explanation(
133
  )
134
 
135
 
136
- # ── System prompt ─────────────────────────────────────────────────────────────
137
  SYSTEM_PROMPT = """أنت محرر صحفي متخصص في تدقيق الأخبار العربية.
138
 
139
  مهمتك: كتابة تقرير تحقق موجز وواضح يفهمه القارئ العادي، يشرح نتيجة التحقق من الادعاء وأسبابها.
@@ -174,48 +133,17 @@ SYSTEM_PROMPT = """أنت محرر صحفي متخصص في تدقيق الأخ
174
 
175
 
176
  class Explainer:
177
- """
178
- Agent 4 — generates a human-readable Arabic explanation of the verdict.
179
-
180
- Usage:
181
- explainer = Explainer()
182
- result = await explainer.run(claim, evidence, verdict_result)
183
-
184
- Input:
185
- claim: clean claim string (no [سياق:] tag)
186
- evidence: list of evidence dicts from Agent 2
187
- verdict_result: dict from Agent 3 {verdict, confidence, reasoning}
188
-
189
- Output:
190
- ExplainerOutput Pydantic model (arabic_explanation, verdict, confidence, citations)
191
- """
192
-
193
  def __init__(self):
194
  self.client = AsyncGroq(api_key=os.environ.get("GROQ_API_KEY5"))
195
 
196
- # ── Public entry point ──��────────────────────────────────────────────────
197
-
198
  async def run(
199
  self,
200
  claim: str,
201
  evidence: list[dict],
202
  verdict_result: dict,
203
  ) -> ExplainerOutput:
204
- """
205
- Main entry point.
206
-
207
- Steps:
208
- 1. Strip [سياق:] tag if present
209
- 2. Validate Agent 3 input with Pydantic
210
- 3. Build prompt from claim + evidence + verdict
211
- 4. Call Groq API
212
- 5. Parse + validate JSON response
213
- 6. Retry once (after 1s delay) on failure
214
- 7. Return default explanation on second failure
215
- """
216
  claim = self._strip_context_tag(claim)
217
 
218
- # Validate Agent 3's input
219
  try:
220
  v_input = VerdictInput(**verdict_result)
221
  except Exception as e:
@@ -239,12 +167,10 @@ class Explainer:
239
  claim, evidence, v_input.verdict, v_input.reasoning, v_input.confidence
240
  )
241
 
242
- # First attempt
243
  result = await self._call_groq(user_prompt, v_input.confidence)
244
  if result is not None:
245
  return result
246
 
247
- # Wait then retry (handles transient rate-limit spikes)
248
  logger.warning(
249
  f"[Explainer] JSON parse failed — retrying after {RETRY_DELAY}s for: {claim[:60]}"
250
  )
@@ -259,7 +185,6 @@ class Explainer:
259
  )
260
  return _default_explanation(claim, v_input.verdict, v_input.confidence)
261
 
262
- # ── Prompt construction ──────────────────────────────────────────────────
263
  def _build_prompt(
264
  self,
265
  claim: str,
@@ -279,11 +204,10 @@ class Explainer:
279
  url = e.get("source_url", "")
280
  rating = e.get("rating")
281
 
282
- source_label = {
283
- "google_factcheck": "تحقق من الحقائق",
284
- "wikidata": "ويكيبيانات",
285
- "tavily": "بحث ويب",
286
- }.get(source, source)
287
 
288
  line = f"[{i}] {source_label}"
289
  if url and url.startswith("http"):
@@ -307,15 +231,9 @@ class Explainer:
307
 
308
  اكتب تقريراً تحققياً موجزاً للقارئ العادي بصيغة JSON."""
309
 
310
- # ── Groq API call ────────────────────────────────────────────────────────
311
-
312
  async def _call_groq(
313
  self, user_prompt: str, confidence: float
314
  ) -> ExplainerOutput | None:
315
- """
316
- Single Groq API call.
317
- Returns ExplainerOutput on success, None on any failure.
318
- """
319
  try:
320
  response = await self.client.chat.completions.create(
321
  model=GROQ_MODEL,
@@ -334,27 +252,17 @@ class Explainer:
334
  raw = response.choices[0].message.content or ""
335
  return self._parse_response(raw, confidence)
336
 
337
- # ── Response parsing ─────────────────────────────────────────────────────
338
-
339
  def _parse_response(self, raw: str, confidence: float) -> ExplainerOutput | None:
340
- """
341
- Parse Groq response into validated ExplainerOutput.
342
- Handles clean JSON, markdown-fenced JSON, JSON in prose.
343
- Returns None if all parsing attempts fail.
344
- """
345
- # Strip markdown fences
346
  clean = re.sub(r"```json\s*", "", raw)
347
  clean = re.sub(r"```\s*", "", clean)
348
  clean = clean.strip()
349
 
350
- # Direct parse
351
  try:
352
  parsed = json.loads(clean)
353
  return self._validate_response(parsed, confidence)
354
  except json.JSONDecodeError:
355
  pass
356
 
357
- # Extract JSON object from prose
358
  match = re.search(r"\{.*\}", clean, re.DOTALL)
359
  if match:
360
  try:
@@ -368,11 +276,6 @@ class Explainer:
368
  return None
369
 
370
  def _validate_response(self, parsed: dict, confidence: float) -> ExplainerOutput:
371
- """
372
- Validate and normalize parsed dict into ExplainerOutput via Pydantic.
373
- Injects confidence from Agent 3 (the LLM doesn't return it).
374
- """
375
- # Normalize citations before passing to Pydantic
376
  raw_citations = parsed.get("citations", [])
377
  clean_citations = []
378
  if isinstance(raw_citations, list):
@@ -393,10 +296,7 @@ class Explainer:
393
  citations=clean_citations,
394
  )
395
 
396
- # ── Helpers ─────────────────────────────────────────────────────────────��
397
-
398
  def _strip_context_tag(self, claim: str) -> str:
399
- """Remove [سياق: ...] prefix if present."""
400
  if claim.startswith("[سياق:"):
401
  match = re.match(r"\[سياق:\s*.+?\]\s*(.+)", claim, re.DOTALL)
402
  if match:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import asyncio
2
  import json
3
  import logging
 
10
 
11
  load_dotenv()
12
 
 
13
  logging.basicConfig(
14
  level=logging.INFO,
15
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
16
  )
17
  logger = logging.getLogger(__name__)
18
 
 
19
  GROQ_MODEL = "llama-3.3-70b-versatile"
20
  MAX_TOKENS = 1024
21
+ TEMPERATURE = 0.1
22
+ RETRY_DELAY = 1.0
23
 
24
+ MAX_EVIDENCE = 6
25
+ MAX_SNIPPET_LEN = 200
26
 
27
  VALID_VERDICTS = {"SUPPORTED", "REFUTED", "PARTIALLY_TRUE", "UNVERIFIABLE"}
28
 
 
29
  VERDICT_LABELS = {
30
  "SUPPORTED": "✓ مدعوم بالأدلة",
31
  "REFUTED": "✗ مدحوض",
 
33
  "UNVERIFIABLE": "? لا يمكن التحقق",
34
  }
35
 
 
 
36
 
37
  class Citation(BaseModel):
38
  title: str = ""
 
41
  @field_validator("url")
42
  @classmethod
43
  def url_must_be_http(cls, v: str) -> str:
 
44
  if v and not v.startswith("http"):
45
  return ""
46
  return v
 
66
 
67
 
68
  class VerdictInput(BaseModel):
 
 
69
  verdict: str = "UNVERIFIABLE"
70
  confidence: float = 0.0
71
  reasoning: str = ""
 
77
  return v if v in VALID_VERDICTS else "UNVERIFIABLE"
78
 
79
 
 
80
  def _default_explanation(
81
  claim: str, verdict: str, confidence: float
82
  ) -> ExplainerOutput:
 
93
  )
94
 
95
 
 
96
  SYSTEM_PROMPT = """أنت محرر صحفي متخصص في تدقيق الأخبار العربية.
97
 
98
  مهمتك: كتابة تقرير تحقق موجز وواضح يفهمه القارئ العادي، يشرح نتيجة التحقق من الادعاء وأسبابها.
 
133
 
134
 
135
  class Explainer:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  def __init__(self):
137
  self.client = AsyncGroq(api_key=os.environ.get("GROQ_API_KEY5"))
138
 
 
 
139
  async def run(
140
  self,
141
  claim: str,
142
  evidence: list[dict],
143
  verdict_result: dict,
144
  ) -> ExplainerOutput:
 
 
 
 
 
 
 
 
 
 
 
 
145
  claim = self._strip_context_tag(claim)
146
 
 
147
  try:
148
  v_input = VerdictInput(**verdict_result)
149
  except Exception as e:
 
167
  claim, evidence, v_input.verdict, v_input.reasoning, v_input.confidence
168
  )
169
 
 
170
  result = await self._call_groq(user_prompt, v_input.confidence)
171
  if result is not None:
172
  return result
173
 
 
174
  logger.warning(
175
  f"[Explainer] JSON parse failed — retrying after {RETRY_DELAY}s for: {claim[:60]}"
176
  )
 
185
  )
186
  return _default_explanation(claim, v_input.verdict, v_input.confidence)
187
 
 
188
  def _build_prompt(
189
  self,
190
  claim: str,
 
204
  url = e.get("source_url", "")
205
  rating = e.get("rating")
206
 
207
+ source_label = {
208
+ "google_factcheck": "تحقق من الحقائق",
209
+ "tavily": "بحث ويب",
210
+ }.get(source, source)
 
211
 
212
  line = f"[{i}] {source_label}"
213
  if url and url.startswith("http"):
 
231
 
232
  اكتب تقريراً تحققياً موجزاً للقارئ العادي بصيغة JSON."""
233
 
 
 
234
  async def _call_groq(
235
  self, user_prompt: str, confidence: float
236
  ) -> ExplainerOutput | None:
 
 
 
 
237
  try:
238
  response = await self.client.chat.completions.create(
239
  model=GROQ_MODEL,
 
252
  raw = response.choices[0].message.content or ""
253
  return self._parse_response(raw, confidence)
254
 
 
 
255
  def _parse_response(self, raw: str, confidence: float) -> ExplainerOutput | None:
 
 
 
 
 
 
256
  clean = re.sub(r"```json\s*", "", raw)
257
  clean = re.sub(r"```\s*", "", clean)
258
  clean = clean.strip()
259
 
 
260
  try:
261
  parsed = json.loads(clean)
262
  return self._validate_response(parsed, confidence)
263
  except json.JSONDecodeError:
264
  pass
265
 
 
266
  match = re.search(r"\{.*\}", clean, re.DOTALL)
267
  if match:
268
  try:
 
276
  return None
277
 
278
  def _validate_response(self, parsed: dict, confidence: float) -> ExplainerOutput:
 
 
 
 
 
279
  raw_citations = parsed.get("citations", [])
280
  clean_citations = []
281
  if isinstance(raw_citations, list):
 
296
  citations=clean_citations,
297
  )
298
 
 
 
299
  def _strip_context_tag(self, claim: str) -> str:
 
300
  if claim.startswith("[سياق:"):
301
  match = re.match(r"\[سياق:\s*.+?\]\s*(.+)", claim, re.DOTALL)
302
  if match:
agents/jury/__init__.py CHANGED
@@ -1,12 +1,3 @@
1
- """
2
- Delphi Jury System — agents/jury/__init__.py
3
- Arabic News Analyzer · Graduation Project
4
-
5
- Exposes shared jury types.
6
- The orchestration entry point lives in graph/jury_subgraph.py
7
- (kept in the graph layer to avoid circular imports).
8
- """
9
-
10
- from agents.jury.aggregator import JurorOutput, JuryResult # noqa: F401
11
 
12
  __all__ = ["JurorOutput", "JuryResult"]
 
1
+ from agents.jury.aggregator import JurorOutput, JuryResult
 
 
 
 
 
 
 
 
 
2
 
3
  __all__ = ["JurorOutput", "JuryResult"]
agents/jury/_base_juror.py CHANGED
@@ -1,19 +1,3 @@
1
- """
2
- Delphi Jury — Base Juror (agents/jury/_base_juror.py)
3
- Arabic News Analyzer · Graduation Project
4
-
5
- Shared async Groq wrapper used by all three jurors.
6
- Each juror subclass only needs to define:
7
- • JUROR_ID – identifier string
8
- • SYSTEM_PROMPT – specialist persona & rules
9
-
10
- The base class handles:
11
- • Groq API call (AsyncGroq, llama-3.3-70b-versatile)
12
- • JSON parsing with markdown-fence stripping
13
- • Retry once on parse failure
14
- • Fallback JurorOutput on total failure
15
- """
16
-
17
  from __future__ import annotations
18
 
19
  import json
@@ -30,22 +14,14 @@ load_dotenv()
30
 
31
  logger = logging.getLogger(__name__)
32
 
33
- # ── Shared config ─────────────────────────────────────────────────────────────
34
  GROQ_MODEL = "llama-3.3-70b-versatile"
35
  MAX_TOKENS = 900
36
- TEMPERATURE = 0.15 # low = more deterministic analysis
37
  MAX_EVIDENCE_SNIPPETS = 8
38
- MAX_SNIPPET_LENGTH = 300 # chars
39
-
40
 
41
- # ── Base class ────────────────────────────────────────────────────────────────
42
 
43
  class BaseJuror:
44
- """
45
- Abstract base for all Delphi jurors.
46
- Subclasses MUST set JUROR_ID and SYSTEM_PROMPT as class attributes.
47
- """
48
-
49
  JUROR_ID: str = "base_juror"
50
  SYSTEM_PROMPT: str = ""
51
 
@@ -58,19 +34,7 @@ class BaseJuror:
58
  env_var = key_map.get(self.JUROR_ID, "GROQ_API_KEY1")
59
  self.client = AsyncGroq(api_key=os.environ.get(env_var))
60
 
61
- # ── Public entry point ───────────────────────────────────────────────────
62
-
63
  async def analyse(self, claim: str, evidence: list[dict]) -> JurorOutput:
64
- """
65
- Analyse the claim against evidence and return a JurorOutput.
66
-
67
- Steps:
68
- 1. Build prompt from claim + evidence snippets
69
- 2. Call Groq
70
- 3. Parse JSON response
71
- 4. Retry once on parse failure
72
- 5. Return UNVERIFIABLE default on total failure
73
- """
74
  user_prompt = self._build_prompt(claim, evidence)
75
 
76
  result = await self._call_groq(user_prompt)
@@ -95,8 +59,6 @@ class BaseJuror:
95
  reasoning="فشل الوكيل في تحليل الادعاء.",
96
  )
97
 
98
- # ── Prompt construction ──────────────────────────────────────────────────
99
-
100
  def _build_prompt(self, claim: str, evidence: list[dict]) -> str:
101
  capped = evidence[:MAX_EVIDENCE_SNIPPETS]
102
  evidence_lines: list[str] = []
@@ -128,8 +90,6 @@ class BaseJuror:
128
  f"بناءً على الأدلة أعلاه فقط، أصدر تحليلك بصيغة JSON."
129
  )
130
 
131
- # ── Groq API call ────────────────────────────────────────────────────────
132
-
133
  async def _call_groq(self, user_prompt: str) -> JurorOutput | None:
134
  try:
135
  response = await self.client.chat.completions.create(
@@ -148,20 +108,16 @@ class BaseJuror:
148
  raw = response.choices[0].message.content or ""
149
  return self._parse_response(raw)
150
 
151
- # ── Response parsing ─────────────────────────────────────────────────────
152
-
153
  def _parse_response(self, raw: str) -> JurorOutput | None:
154
  clean = re.sub(r"```json\s*", "", raw)
155
  clean = re.sub(r"```\s*", "", clean).strip()
156
 
157
- # Direct parse
158
  try:
159
  parsed = json.loads(clean)
160
  return self._validate(parsed)
161
  except json.JSONDecodeError:
162
  pass
163
 
164
- # Extract JSON object from prose
165
  match = re.search(r"\{.*?\}", clean, re.DOTALL)
166
  if match:
167
  try:
@@ -176,13 +132,11 @@ class BaseJuror:
176
  return None
177
 
178
  def _validate(self, parsed: dict) -> JurorOutput:
179
- """Normalise parsed dict into a JurorOutput."""
180
  try:
181
  confidence = float(parsed.get("confidence", 0.0))
182
  except (TypeError, ValueError):
183
  confidence = 0.0
184
 
185
- # Extract citations list if present
186
  raw_citations = parsed.get("citations", [])
187
  citations: list[dict] = []
188
  if isinstance(raw_citations, list):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import json
 
14
 
15
  logger = logging.getLogger(__name__)
16
 
 
17
  GROQ_MODEL = "llama-3.3-70b-versatile"
18
  MAX_TOKENS = 900
19
+ TEMPERATURE = 0.15
20
  MAX_EVIDENCE_SNIPPETS = 8
21
+ MAX_SNIPPET_LENGTH = 300
 
22
 
 
23
 
24
  class BaseJuror:
 
 
 
 
 
25
  JUROR_ID: str = "base_juror"
26
  SYSTEM_PROMPT: str = ""
27
 
 
34
  env_var = key_map.get(self.JUROR_ID, "GROQ_API_KEY1")
35
  self.client = AsyncGroq(api_key=os.environ.get(env_var))
36
 
 
 
37
  async def analyse(self, claim: str, evidence: list[dict]) -> JurorOutput:
 
 
 
 
 
 
 
 
 
 
38
  user_prompt = self._build_prompt(claim, evidence)
39
 
40
  result = await self._call_groq(user_prompt)
 
59
  reasoning="فشل الوكيل في تحليل الادعاء.",
60
  )
61
 
 
 
62
  def _build_prompt(self, claim: str, evidence: list[dict]) -> str:
63
  capped = evidence[:MAX_EVIDENCE_SNIPPETS]
64
  evidence_lines: list[str] = []
 
90
  f"بناءً على الأدلة أعلاه فقط، أصدر تحليلك بصيغة JSON."
91
  )
92
 
 
 
93
  async def _call_groq(self, user_prompt: str) -> JurorOutput | None:
94
  try:
95
  response = await self.client.chat.completions.create(
 
108
  raw = response.choices[0].message.content or ""
109
  return self._parse_response(raw)
110
 
 
 
111
  def _parse_response(self, raw: str) -> JurorOutput | None:
112
  clean = re.sub(r"```json\s*", "", raw)
113
  clean = re.sub(r"```\s*", "", clean).strip()
114
 
 
115
  try:
116
  parsed = json.loads(clean)
117
  return self._validate(parsed)
118
  except json.JSONDecodeError:
119
  pass
120
 
 
121
  match = re.search(r"\{.*?\}", clean, re.DOTALL)
122
  if match:
123
  try:
 
132
  return None
133
 
134
  def _validate(self, parsed: dict) -> JurorOutput:
 
135
  try:
136
  confidence = float(parsed.get("confidence", 0.0))
137
  except (TypeError, ValueError):
138
  confidence = 0.0
139
 
 
140
  raw_citations = parsed.get("citations", [])
141
  citations: list[dict] = []
142
  if isinstance(raw_citations, list):
agents/jury/aggregator.py CHANGED
@@ -1,25 +1,3 @@
1
- """
2
- Delphi Jury — Aggregator (agents/jury/aggregator.py)
3
- Arabic News Analyzer · Graduation Project
4
-
5
- Pure-Python consensus engine. NO LLM calls here.
6
-
7
- Aggregation rules
8
- ─────────────────
9
- • Majority vote (≥ 2/3) determines final verdict.
10
- • If no majority → verdict = UNVERIFIABLE.
11
-
12
- Confidence multiplier
13
- ─────────────────────
14
- • Unanimous (3/3) → ×1.0
15
- • Majority (2/3) → ×0.85
16
- • No consensus → ×0.0 (UNVERIFIABLE path)
17
-
18
- Final confidence = mean(post_debate_confidences) * multiplier
19
-
20
- needs_human_review is set to True when final verdict is UNVERIFIABLE.
21
- """
22
-
23
  from __future__ import annotations
24
 
25
  from collections import Counter
@@ -27,28 +5,23 @@ from dataclasses import dataclass, field
27
  from statistics import mean
28
  from typing import Literal
29
 
30
- # ── Valid verdict labels ──────────────────────────────────────────────────────
31
 
32
- VerdictLabel = Literal["SUPPORTED", "REFUTED", "PARTIALLY_TRUE", "UNVERIFIABLE"]
 
33
 
34
  VALID_VERDICTS: frozenset[str] = frozenset(
35
  {"SUPPORTED", "REFUTED", "PARTIALLY_TRUE", "UNVERIFIABLE"}
36
  )
37
 
38
- CONFIDENCE_SPREAD_THRESHOLD = 0.25 # triggers debate when exceeded
39
-
40
-
41
- # ── Data classes ─────────────────────────────────────────────────────────────
42
 
43
 
44
  @dataclass
45
  class JurorOutput:
46
- """Single juror verdict — same shape for both pre- and post-debate."""
47
-
48
- juror_id: str # "J1_empiricist" | "J2_contextualist" | "J3_sceptic"
49
- verdict: str # one of VALID_VERDICTS
50
- confidence: float # 0.0 – 1.0
51
- reasoning: str # Arabic or English reasoning paragraph
52
  citations: list[dict] = field(default_factory=list)
53
 
54
  def __post_init__(self) -> None:
@@ -60,17 +33,12 @@ class JurorOutput:
60
 
61
  @dataclass
62
  class JuryResult:
63
- """Final output of the Delphi jury for a single claim."""
64
-
65
  verdict: str
66
  confidence: float
67
- jury_outputs: list[JurorOutput] # post-debate (or initial if no debate)
68
- debate_log: list[dict] # empty list when debate was skipped
69
  needs_human_review: bool
70
- reasoning: str # combined reasoning for Explainer
71
-
72
-
73
- # ── Helpers ───────────────────────────────────────────────────────────────────
74
 
75
 
76
  def _normalise_verdict(v: str) -> str:
@@ -85,17 +53,7 @@ def _confidence_spread(outputs: list[JurorOutput]) -> float:
85
  return max(confidences) - min(confidences)
86
 
87
 
88
- # ── Public API ────────────────────────────────────────────────────────────────
89
-
90
-
91
  def detect_disagreement(outputs: list[JurorOutput]) -> bool:
92
- """
93
- Return True when a debate round is required.
94
-
95
- Disagreement exists when:
96
- • jurors disagree on verdict label, OR
97
- • confidence spread > CONFIDENCE_SPREAD_THRESHOLD (0.25)
98
- """
99
  unique_verdicts = {o.verdict for o in outputs}
100
  if len(unique_verdicts) > 1:
101
  return True
@@ -105,19 +63,6 @@ def detect_disagreement(outputs: list[JurorOutput]) -> bool:
105
 
106
 
107
  def aggregate(outputs: list[JurorOutput], debate_log: list[dict]) -> JuryResult:
108
- """
109
- Aggregate post-debate juror outputs into a single JuryResult.
110
-
111
- Parameters
112
- ----------
113
- outputs : list of JurorOutput (post-debate or initial if no debate)
114
- debate_log: list of debate-round dicts (empty when debate was skipped)
115
-
116
- Returns
117
- -------
118
- JuryResult with final verdict, confidence, needs_human_review flag,
119
- and combined reasoning.
120
- """
121
  if not outputs:
122
  return JuryResult(
123
  verdict="UNVERIFIABLE",
@@ -128,27 +73,22 @@ def aggregate(outputs: list[JurorOutput], debate_log: list[dict]) -> JuryResult:
128
  reasoning="لم يتم الحصول على أي مخرجات من هيئة المحلفين.",
129
  )
130
 
131
- # ── Majority vote ────────────────────────────────────────────────────────
132
  verdict_counts: Counter[str] = Counter(o.verdict for o in outputs)
133
  top_verdict, top_count = verdict_counts.most_common(1)[0]
134
  total = len(outputs)
135
 
136
- if top_count >= 2: # majority (2/3 or 3/3)
137
  final_verdict = top_verdict
138
  multiplier = 1.0 if top_count == total else 0.85
139
  else:
140
- # Each juror voted differently — no consensus
141
  final_verdict = "UNVERIFIABLE"
142
  multiplier = 0.0
143
 
144
- # ── Confidence ───────────────────────────────────────────────────────────
145
  avg_confidence = mean(o.confidence for o in outputs)
146
  final_confidence = round(avg_confidence * multiplier, 3)
147
 
148
- # ── needs_human_review ───────────────────────────────────────────────────
149
  needs_human_review = final_verdict == "UNVERIFIABLE"
150
 
151
- # ── Combined reasoning ───────────────────────────────────────────────────
152
  reasoning_parts = [
153
  f"[{o.juror_id}] الحكم: {o.verdict} (ثقة: {o.confidence:.0%})\n{o.reasoning}"
154
  for o in outputs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  from collections import Counter
 
5
  from statistics import mean
6
  from typing import Literal
7
 
 
8
 
9
+ VerdictLabel = Literal["SUPPORTED", "REFUTED",
10
+ "PARTIALLY_TRUE", "UNVERIFIABLE"]
11
 
12
  VALID_VERDICTS: frozenset[str] = frozenset(
13
  {"SUPPORTED", "REFUTED", "PARTIALLY_TRUE", "UNVERIFIABLE"}
14
  )
15
 
16
+ CONFIDENCE_SPREAD_THRESHOLD = 0.25
 
 
 
17
 
18
 
19
  @dataclass
20
  class JurorOutput:
21
+ juror_id: str
22
+ verdict: str
23
+ confidence: float
24
+ reasoning: str
 
 
25
  citations: list[dict] = field(default_factory=list)
26
 
27
  def __post_init__(self) -> None:
 
33
 
34
  @dataclass
35
  class JuryResult:
 
 
36
  verdict: str
37
  confidence: float
38
+ jury_outputs: list[JurorOutput]
39
+ debate_log: list[dict]
40
  needs_human_review: bool
41
+ reasoning: str
 
 
 
42
 
43
 
44
  def _normalise_verdict(v: str) -> str:
 
53
  return max(confidences) - min(confidences)
54
 
55
 
 
 
 
56
  def detect_disagreement(outputs: list[JurorOutput]) -> bool:
 
 
 
 
 
 
 
57
  unique_verdicts = {o.verdict for o in outputs}
58
  if len(unique_verdicts) > 1:
59
  return True
 
63
 
64
 
65
  def aggregate(outputs: list[JurorOutput], debate_log: list[dict]) -> JuryResult:
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  if not outputs:
67
  return JuryResult(
68
  verdict="UNVERIFIABLE",
 
73
  reasoning="لم يتم الحصول على أي مخرجات من هيئة المحلفين.",
74
  )
75
 
 
76
  verdict_counts: Counter[str] = Counter(o.verdict for o in outputs)
77
  top_verdict, top_count = verdict_counts.most_common(1)[0]
78
  total = len(outputs)
79
 
80
+ if top_count >= 2:
81
  final_verdict = top_verdict
82
  multiplier = 1.0 if top_count == total else 0.85
83
  else:
 
84
  final_verdict = "UNVERIFIABLE"
85
  multiplier = 0.0
86
 
 
87
  avg_confidence = mean(o.confidence for o in outputs)
88
  final_confidence = round(avg_confidence * multiplier, 3)
89
 
 
90
  needs_human_review = final_verdict == "UNVERIFIABLE"
91
 
 
92
  reasoning_parts = [
93
  f"[{o.juror_id}] الحكم: {o.verdict} (ثقة: {o.confidence:.0%})\n{o.reasoning}"
94
  for o in outputs
agents/jury/contextualist.py CHANGED
@@ -1,29 +1,8 @@
1
- """
2
- Delphi Jury — J2 Contextualist (agents/jury/contextualist.py)
3
- Arabic News Analyzer · Graduation Project
4
-
5
- Persona:
6
- Uses historical and geopolitical context.
7
- Uses named entities and KG relationships.
8
- Detects misleading framing even when individual facts are technically true.
9
-
10
- Targets:
11
- • missing context (claims stripped of necessary background)
12
- • narrative manipulation
13
- • context-stripped claims that mislead through selective truth
14
-
15
- Reasoning style:
16
- Frame-aware. Asks not just "is this fact correct?" but
17
- "does the framing create a false impression?"
18
- """
19
-
20
  from agents.jury._base_juror import BaseJuror
21
 
22
- # ── Juror identity ────────────────────────────────────────────────────────────
23
 
24
  _JUROR_ID = "J2_contextualist"
25
 
26
- # ── System prompt ─────────────────────────────────────────────────────────────
27
 
28
  _SYSTEM_PROMPT = """أنت المحقق السياقي (J2) في هيئة تحقق متخصصة في الأخبار العربية.
29
 
@@ -53,13 +32,6 @@ _SYSTEM_PROMPT = """أنت المحقق السياقي (J2) في هيئة تحق
53
  }"""
54
 
55
 
56
- # ── Juror class ───────────────────────────────────────────────────────────────
57
-
58
  class ContextualistJuror(BaseJuror):
59
- """
60
- J2 — Contextualist juror.
61
- Frame-aware analysis; catches narrative manipulation and context stripping.
62
- """
63
-
64
  JUROR_ID = _JUROR_ID
65
  SYSTEM_PROMPT = _SYSTEM_PROMPT
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from agents.jury._base_juror import BaseJuror
2
 
 
3
 
4
  _JUROR_ID = "J2_contextualist"
5
 
 
6
 
7
  _SYSTEM_PROMPT = """أنت المحقق السياقي (J2) في هيئة تحقق متخصصة في الأخبار العربية.
8
 
 
32
  }"""
33
 
34
 
 
 
35
  class ContextualistJuror(BaseJuror):
 
 
 
 
 
36
  JUROR_ID = _JUROR_ID
37
  SYSTEM_PROMPT = _SYSTEM_PROMPT
agents/jury/debate.py CHANGED
@@ -1,27 +1,3 @@
1
- """
2
- Delphi Jury — Debate Module (agents/jury/debate.py)
3
- Arabic News Analyzer · Graduation Project
4
-
5
- Orchestrates a single debate revision round between dissenting jurors.
6
-
7
- How it works
8
- ────────────
9
- 1. Identify dissenters — jurors whose verdict differs from the majority
10
- OR whose confidence deviates significantly from the mean.
11
- 2. Build an anonymised summary of other jurors' reasoning (no juror IDs).
12
- 3. Each dissenting juror calls the LLM with the anonymised context and
13
- is asked to either revise or defend its position.
14
- 4. Non-dissenters keep their original output unchanged.
15
- 5. Return updated outputs + a structured debate log.
16
-
17
- Design constraints
18
- ──────────────────
19
- • Only ONE debate round (as specified).
20
- • Non-dissenters are never called again (saves Groq tokens).
21
- • Anonymisation prevents anchoring bias between jurors.
22
- • asyncio.gather() used for parallel dissenter revision calls.
23
- """
24
-
25
  from __future__ import annotations
26
 
27
  import asyncio
@@ -40,14 +16,11 @@ load_dotenv()
40
 
41
  logger = logging.getLogger(__name__)
42
 
43
- # ── Config ────────────────────────────────────────────────────────────────────
44
  GROQ_MODEL = "llama-3.3-70b-versatile"
45
  DEBATE_MAX_TOKENS = 700
46
  DEBATE_TEMPERATURE = 0.2
47
 
48
 
49
- # ── Debate system prompt ──────────────────────────────────────────────────────
50
-
51
  _DEBATE_SYSTEM_PROMPT = """أنت محقق في هيئة تحقق عربية متخصصة.
52
  لقد أصدرت حكماً أولياً على ادعاء، لكن زملاءك (مجهولو الهوية) توصلوا إلى آراء مختلفة.
53
 
@@ -70,29 +43,21 @@ _DEBATE_SYSTEM_PROMPT = """أنت محقق في هيئة تحقق عربية م
70
  }"""
71
 
72
 
73
- # ── Internal helpers ──────────────────────────────────────────────────────────
74
-
75
  def _majority_verdict(outputs: list[JurorOutput]) -> str | None:
76
- """Return the majority verdict or None if no majority."""
77
  counts: Counter[str] = Counter(o.verdict for o in outputs)
78
  top_v, top_c = counts.most_common(1)[0]
79
  return top_v if top_c >= 2 else None
80
 
81
 
82
  def _is_dissenter(juror: JurorOutput, majority_verdict: str | None) -> bool:
83
- """A juror is a dissenter if their verdict differs from the majority verdict."""
84
  if majority_verdict is None:
85
- return True # no majority → all dissent
86
  return juror.verdict != majority_verdict
87
 
88
 
89
  def _anonymise_others(
90
  all_outputs: list[JurorOutput], exclude_id: str
91
  ) -> str:
92
- """
93
- Build an anonymised reasoning summary for the debate prompt,
94
- excluding the dissenting juror's own output.
95
- """
96
  parts: list[str] = []
97
  anon_label = ["المحقق أ", "المحقق ب", "المحقق ج"]
98
  idx = 0
@@ -141,10 +106,6 @@ async def _revise_juror(
141
  all_initial: list[JurorOutput],
142
  client: AsyncGroq,
143
  ) -> tuple[JurorOutput, dict]:
144
- """
145
- Ask one dissenting juror to revise its verdict in light of peer reasoning.
146
- Returns (updated_output, debate_log_entry).
147
- """
148
  evidence_summary = _build_evidence_summary(evidence)
149
  peer_summary = _anonymise_others(all_initial, exclude_id=juror.juror_id)
150
  prompt = _build_debate_prompt(juror, claim, evidence_summary, peer_summary)
@@ -163,7 +124,6 @@ async def _revise_juror(
163
  raw = response.choices[0].message.content or ""
164
  except Exception as exc:
165
  logger.error("[Debate] Groq error for %s: %s", juror.juror_id, exc)
166
- # Keep original verdict on API error
167
  return juror, {
168
  "juror_id": juror.juror_id,
169
  "original_verdict": juror.verdict,
@@ -173,7 +133,8 @@ async def _revise_juror(
173
  }
174
 
175
  revised = _parse_revision(raw, juror)
176
- changed = revised.verdict != juror.verdict or abs(revised.confidence - juror.confidence) > 0.05
 
177
 
178
  log_entry = {
179
  "juror_id": juror.juror_id,
@@ -188,7 +149,6 @@ async def _revise_juror(
188
 
189
 
190
  def _parse_revision(raw: str, original: JurorOutput) -> JurorOutput:
191
- """Parse the debate revision response; fall back to original on failure."""
192
  clean = re.sub(r"```json\s*", "", raw)
193
  clean = re.sub(r"```\s*", "", clean).strip()
194
 
@@ -204,7 +164,8 @@ def _parse_revision(raw: str, original: JurorOutput) -> JurorOutput:
204
  juror_id=original.juror_id,
205
  verdict=verdict,
206
  confidence=confidence,
207
- reasoning=str(d.get("reasoning", original.reasoning)).strip() or original.reasoning,
 
208
  citations=original.citations,
209
  )
210
 
@@ -220,36 +181,20 @@ def _parse_revision(raw: str, original: JurorOutput) -> JurorOutput:
220
  except json.JSONDecodeError:
221
  pass
222
 
223
- logger.warning("[Debate] Could not parse revision for %s — keeping original", original.juror_id)
 
224
  return original
225
 
226
 
227
- # ── Public entry point ────────────────────────────────────────────────────────
228
-
229
  async def run_debate(
230
  initial_outputs: list[JurorOutput],
231
  claim: str,
232
  evidence: list[dict],
233
  ) -> tuple[list[JurorOutput], list[dict]]:
234
- """
235
- Run the debate round.
236
-
237
- Parameters
238
- ----------
239
- initial_outputs : list of JurorOutput from Stage 1 (independent analysis)
240
- claim : the claim string (no [سياق:] tag)
241
- evidence : evidence list from Agent 2
242
-
243
- Returns
244
- -------
245
- (updated_outputs, debate_log)
246
- updated_outputs : list[JurorOutput] — post-debate verdicts
247
- (dissenters may have revised; non-dissenters unchanged)
248
- debate_log : list[dict] — one entry per dissenting juror
249
- """
250
  majority = _majority_verdict(initial_outputs)
251
  dissenters = [o for o in initial_outputs if _is_dissenter(o, majority)]
252
- non_dissenters = [o for o in initial_outputs if not _is_dissenter(o, majority)]
 
253
 
254
  if not dissenters:
255
  logger.info("[Debate] No dissenters found — debate skipped.")
@@ -262,7 +207,6 @@ async def run_debate(
262
 
263
  client = AsyncGroq(api_key=os.environ.get("GROQ_API_KEY5"))
264
 
265
- # Revise all dissenters in parallel
266
  revision_tasks = [
267
  _revise_juror(d, claim, evidence, initial_outputs, client)
268
  for d in dissenters
@@ -280,10 +224,8 @@ async def run_debate(
280
  updated_dissenters.append(revised_output)
281
  debate_log.append(log_entry)
282
 
283
- # Combine: non-dissenters keep originals; dissenters use revised
284
  final_outputs = non_dissenters + updated_dissenters
285
 
286
- # Preserve original order by juror_id
287
  order = {o.juror_id: i for i, o in enumerate(initial_outputs)}
288
  final_outputs.sort(key=lambda o: order.get(o.juror_id, 999))
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import asyncio
 
16
 
17
  logger = logging.getLogger(__name__)
18
 
 
19
  GROQ_MODEL = "llama-3.3-70b-versatile"
20
  DEBATE_MAX_TOKENS = 700
21
  DEBATE_TEMPERATURE = 0.2
22
 
23
 
 
 
24
  _DEBATE_SYSTEM_PROMPT = """أنت محقق في هيئة تحقق عربية متخصصة.
25
  لقد أصدرت حكماً أولياً على ادعاء، لكن زملاءك (مجهولو الهوية) توصلوا إلى آراء مختلفة.
26
 
 
43
  }"""
44
 
45
 
 
 
46
  def _majority_verdict(outputs: list[JurorOutput]) -> str | None:
 
47
  counts: Counter[str] = Counter(o.verdict for o in outputs)
48
  top_v, top_c = counts.most_common(1)[0]
49
  return top_v if top_c >= 2 else None
50
 
51
 
52
  def _is_dissenter(juror: JurorOutput, majority_verdict: str | None) -> bool:
 
53
  if majority_verdict is None:
54
+ return True
55
  return juror.verdict != majority_verdict
56
 
57
 
58
  def _anonymise_others(
59
  all_outputs: list[JurorOutput], exclude_id: str
60
  ) -> str:
 
 
 
 
61
  parts: list[str] = []
62
  anon_label = ["المحقق أ", "المحقق ب", "المحقق ج"]
63
  idx = 0
 
106
  all_initial: list[JurorOutput],
107
  client: AsyncGroq,
108
  ) -> tuple[JurorOutput, dict]:
 
 
 
 
109
  evidence_summary = _build_evidence_summary(evidence)
110
  peer_summary = _anonymise_others(all_initial, exclude_id=juror.juror_id)
111
  prompt = _build_debate_prompt(juror, claim, evidence_summary, peer_summary)
 
124
  raw = response.choices[0].message.content or ""
125
  except Exception as exc:
126
  logger.error("[Debate] Groq error for %s: %s", juror.juror_id, exc)
 
127
  return juror, {
128
  "juror_id": juror.juror_id,
129
  "original_verdict": juror.verdict,
 
133
  }
134
 
135
  revised = _parse_revision(raw, juror)
136
+ changed = revised.verdict != juror.verdict or abs(
137
+ revised.confidence - juror.confidence) > 0.05
138
 
139
  log_entry = {
140
  "juror_id": juror.juror_id,
 
149
 
150
 
151
  def _parse_revision(raw: str, original: JurorOutput) -> JurorOutput:
 
152
  clean = re.sub(r"```json\s*", "", raw)
153
  clean = re.sub(r"```\s*", "", clean).strip()
154
 
 
164
  juror_id=original.juror_id,
165
  verdict=verdict,
166
  confidence=confidence,
167
+ reasoning=str(d.get("reasoning", original.reasoning)
168
+ ).strip() or original.reasoning,
169
  citations=original.citations,
170
  )
171
 
 
181
  except json.JSONDecodeError:
182
  pass
183
 
184
+ logger.warning(
185
+ "[Debate] Could not parse revision for %s — keeping original", original.juror_id)
186
  return original
187
 
188
 
 
 
189
  async def run_debate(
190
  initial_outputs: list[JurorOutput],
191
  claim: str,
192
  evidence: list[dict],
193
  ) -> tuple[list[JurorOutput], list[dict]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  majority = _majority_verdict(initial_outputs)
195
  dissenters = [o for o in initial_outputs if _is_dissenter(o, majority)]
196
+ non_dissenters = [
197
+ o for o in initial_outputs if not _is_dissenter(o, majority)]
198
 
199
  if not dissenters:
200
  logger.info("[Debate] No dissenters found — debate skipped.")
 
207
 
208
  client = AsyncGroq(api_key=os.environ.get("GROQ_API_KEY5"))
209
 
 
210
  revision_tasks = [
211
  _revise_juror(d, claim, evidence, initial_outputs, client)
212
  for d in dissenters
 
224
  updated_dissenters.append(revised_output)
225
  debate_log.append(log_entry)
226
 
 
227
  final_outputs = non_dissenters + updated_dissenters
228
 
 
229
  order = {o.juror_id: i for i, o in enumerate(initial_outputs)}
230
  final_outputs.sort(key=lambda o: order.get(o.juror_id, 999))
231
 
agents/jury/empiricist.py CHANGED
@@ -1,30 +1,8 @@
1
- """
2
- Delphi Jury — J1 Empiricist (agents/jury/empiricist.py)
3
- Arabic News Analyzer · Graduation Project
4
-
5
- Persona:
6
- Only trusts explicit retrieved evidence.
7
- Avoids inference beyond source text.
8
- Focuses on factual grounding.
9
-
10
- Targets:
11
- • fabricated statistics
12
- • unsupported claims
13
- • hallucinated facts
14
-
15
- Reasoning style:
16
- Evidence-first. Each statement in the reasoning must map back to a
17
- numbered evidence item. If a fact is not in the evidence, it is
18
- treated as unverifiable.
19
- """
20
-
21
  from agents.jury._base_juror import BaseJuror
22
 
23
- # ── Juror identity ────────────────────────────────────────────────────────────
24
 
25
  _JUROR_ID = "J1_empiricist"
26
 
27
- # ── System prompt ─────────────────────────────────────────────────────────────
28
 
29
  _SYSTEM_PROMPT = """أنت المحقق التجريبي (J1) في هيئة تحقق متخصصة في الأخبار العربية.
30
 
@@ -54,13 +32,7 @@ _SYSTEM_PROMPT = """أنت المحقق التجريبي (J1) في هيئة تح
54
  }"""
55
 
56
 
57
- # ── Juror class ───────────────────────────────────────────────────────────────
58
-
59
  class EmpricistJuror(BaseJuror):
60
- """
61
- J1 — Empiricist juror.
62
- Verdict driven entirely by explicit textual evidence.
63
- """
64
 
65
  JUROR_ID = _JUROR_ID
66
  SYSTEM_PROMPT = _SYSTEM_PROMPT
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from agents.jury._base_juror import BaseJuror
2
 
 
3
 
4
  _JUROR_ID = "J1_empiricist"
5
 
 
6
 
7
  _SYSTEM_PROMPT = """أنت المحقق التجريبي (J1) في هيئة تحقق متخصصة في الأخبار العربية.
8
 
 
32
  }"""
33
 
34
 
 
 
35
  class EmpricistJuror(BaseJuror):
 
 
 
 
36
 
37
  JUROR_ID = _JUROR_ID
38
  SYSTEM_PROMPT = _SYSTEM_PROMPT
agents/jury/sceptic.py CHANGED
@@ -1,30 +1,8 @@
1
- """
2
- Delphi Jury — J3 Sceptic (agents/jury/sceptic.py)
3
- Arabic News Analyzer · Graduation Project
4
-
5
- Persona:
6
- Assumes the claim is FALSE until proven otherwise with strong evidence.
7
- Requires a high evidentiary bar before issuing SUPPORTED.
8
- Serves as the jury's defence against over-confident false positives.
9
-
10
- Targets:
11
- • overconfident SUPPORTED verdicts based on weak evidence
12
- • propaganda-style claims designed to sound authoritative
13
- • claims supported only by a single low-credibility source
14
-
15
- Reasoning style:
16
- Devil's advocate. Starts from doubt and demands that evidence
17
- overcome the presumption of falsity. Penalises missing sources,
18
- single-source confirmations, and vague language.
19
- """
20
-
21
  from agents.jury._base_juror import BaseJuror
22
 
23
- # ── Juror identity ────────────────────────────────────────────────────────────
24
 
25
  _JUROR_ID = "J3_sceptic"
26
 
27
- # ── System prompt ─────────────────────────────────────────────────────────────
28
 
29
  _SYSTEM_PROMPT = """أنت المحقق المشكِّك (J3) في هيئة تحقق متخصصة في الأخبار العربية.
30
 
@@ -54,13 +32,6 @@ _SYSTEM_PROMPT = """أنت المحقق المشكِّك (J3) في هيئة تح
54
  }"""
55
 
56
 
57
- # ── Juror class ───────────────────────────────────────────────────────────────
58
-
59
  class ScepticJuror(BaseJuror):
60
- """
61
- J3 — Sceptic juror.
62
- Guilty-until-proven-innocent stance; guards against weak SUPPORTED verdicts.
63
- """
64
-
65
  JUROR_ID = _JUROR_ID
66
  SYSTEM_PROMPT = _SYSTEM_PROMPT
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from agents.jury._base_juror import BaseJuror
2
 
 
3
 
4
  _JUROR_ID = "J3_sceptic"
5
 
 
6
 
7
  _SYSTEM_PROMPT = """أنت المحقق المشكِّك (J3) في هيئة تحقق متخصصة في الأخبار العربية.
8
 
 
32
  }"""
33
 
34
 
 
 
35
  class ScepticJuror(BaseJuror):
 
 
 
 
 
36
  JUROR_ID = _JUROR_ID
37
  SYSTEM_PROMPT = _SYSTEM_PROMPT
config/trusted_sources.py CHANGED
@@ -1,5 +1,4 @@
1
  TRUSTED_DOMAINS: list[str] = [
2
- # --- Existing List ---
3
  "bbc.com",
4
  "reuters.com",
5
  "apnews.com",
@@ -9,50 +8,41 @@ TRUSTED_DOMAINS: list[str] = [
9
  "un.org",
10
  "who.int",
11
 
12
- # --- Major International News ---
13
  "cnn.com",
14
  "nytimes.com",
15
  "washingtonpost.com",
16
  "theguardian.com",
17
  "bloomberg.com",
18
- "afp.com", # Agence France-Presse (Global wire service)
19
- "dw.com", # Deutsche Welle (Germany)
20
- "france24.com", # France 24
21
 
22
- # --- Middle East: Pan-Arab & Regional Heavyweights ---
23
- "asharq.com", # Asharq Al-Awsat / Asharq News
24
- "skynewsarabia.com", # Sky News Arabia
25
- "almayadeen.net", # Al Mayadeen (Lebanon-based regional network)
26
- "thenationalnews.com", # The National (Major UAE-based English daily)
27
- "al-monitor.com", # Al-Monitor (Deep regional analysis)
28
 
29
- # --- Middle East: North Africa & Levant Focus ---
30
  "sana.sy",
31
  "syria.tv",
32
  "zamanalwsl.net",
33
- "lorientlejour.com", # L'Orient-Le Jour (Major Lebanese daily)
34
- "naharnet.com", # Naharnet (Lebanon)
35
- "jordantimes.com", # Jordan Times
36
- "ahram.org.eg", # Al-Ahram (Egypt's state/flagship paper)
37
- "madaamasr.com", # Mada Masr (Prominent Egyptian independent media)
38
 
39
- # --- Middle East: Gulf Focus ---
40
  "aljazeera.net",
41
- "arabnews.com", # Arab News (Saudi Arabia / English daily)
42
- "gulfnews.com", # Gulf News (UAE)
43
- "alrai.com", # Al-Rai (Kuwait)
44
 
45
- # --- Middle East: Additional Syrian/Levant Media ---
46
- "enabbaladi.net", # Enab Baladi (Respected Syrian independent media)
47
- "rozana.fm", # Radio Rozana (Syrian independent media)
48
- "sn4hr.org", # Syrian Network for Human Rights (Documentation)
49
 
50
- # --- Arabic Fact-Checking Organizations ---
51
- # تحقق — Palestinian Observatory for Fact-Checking (AFCN member)
52
  "tahaqaq.ps",
53
- # فتبينوا — MENA-wide fact-checking platform (confirmed via live test)
54
  "fatabyyano.net",
55
- # تأكد — Syria-focused fact-checking (confirmed via live test)
56
  "verify-sy.com",
57
- "misbar.com", # Misbar — Jordan-based Arabic fact-checking platform
58
  ]
 
1
  TRUSTED_DOMAINS: list[str] = [
 
2
  "bbc.com",
3
  "reuters.com",
4
  "apnews.com",
 
8
  "un.org",
9
  "who.int",
10
 
 
11
  "cnn.com",
12
  "nytimes.com",
13
  "washingtonpost.com",
14
  "theguardian.com",
15
  "bloomberg.com",
16
+ "afp.com",
17
+ "dw.com",
18
+ "france24.com",
19
 
20
+ "asharq.com",
21
+ "skynewsarabia.com",
22
+ "almayadeen.net",
23
+ "thenationalnews.com",
24
+ "al-monitor.com",
 
25
 
 
26
  "sana.sy",
27
  "syria.tv",
28
  "zamanalwsl.net",
29
+ "lorientlejour.com",
30
+ "naharnet.com",
31
+ "jordantimes.com",
32
+ "ahram.org.eg",
33
+ "madaamasr.com",
34
 
 
35
  "aljazeera.net",
36
+ "arabnews.com",
37
+ "gulfnews.com",
38
+ "alrai.com",
39
 
40
+ "enabbaladi.net",
41
+ "rozana.fm",
42
+ "sn4hr.org",
 
43
 
 
 
44
  "tahaqaq.ps",
 
45
  "fatabyyano.net",
 
46
  "verify-sy.com",
47
+ "misbar.com",
48
  ]
graph/jury_subgraph.py CHANGED
@@ -1,25 +1,3 @@
1
- """
2
- Delphi Jury — Jury Subgraph (graph/jury_subgraph.py)
3
- Arabic News Analyzer · Graduation Project
4
-
5
- Orchestrates the full Delphi pipeline for a SINGLE claim:
6
-
7
- Stage 1 — Independent parallel analysis (J1, J2, J3 via asyncio.gather)
8
- Stage 2 — Disagreement detection (pure Python, no LLM)
9
- Stage 3 — Debate round (conditional; only when needed)
10
- Stage 4 — Aggregation (pure Python, no LLM)
11
-
12
- Returns a JuryResult that the LangGraph pipeline stores in state as
13
- the claim's "verdict" field (plus extended jury metadata).
14
-
15
- The function signature is:
16
- run_jury_subgraph(claim: str, evidence: list[dict]) -> dict
17
-
18
- The returned dict is JSON-serialisable and matches the expected
19
- claim_result["verdict"] shape consumed by run_explainer and
20
- _serialize_claim_results in langgraph_pipeline.py.
21
- """
22
-
23
  from __future__ import annotations
24
 
25
  import asyncio
@@ -39,53 +17,28 @@ from agents.jury.aggregator import (
39
 
40
  logger = logging.getLogger(__name__)
41
 
42
- # ── Singleton juror instances ─────────────────────────────────────────────────
43
- # Instantiated once at module load time; each holds one AsyncGroq client.
44
-
45
  _j1 = EmpricistJuror()
46
  _j2 = ContextualistJuror()
47
  _j3 = ScepticJuror()
48
 
49
 
50
- # ── Public entry point ────────────────────────────────────────────────────────
51
-
52
  async def run_jury_subgraph(claim: str, evidence: list[dict]) -> dict:
53
- """
54
- Run the full Delphi jury pipeline for a single claim.
55
-
56
- Parameters
57
- ----------
58
- claim : clean claim string (no [سياق:] tag)
59
- evidence : list of evidence dicts from Agent 2
60
-
61
- Returns
62
- -------
63
- dict with keys:
64
- verdict str — final consensus verdict
65
- confidence float — weighted confidence
66
- reasoning str — combined post-debate reasoning
67
- jury_outputs list — per-juror results (post-debate)
68
- debate_log list — debate round entries (empty if skipped)
69
- needs_human_review bool — True when verdict == UNVERIFIABLE
70
- """
71
- # ── Stage 1: Independent parallel analysis ────────────────────────────────
72
- logger.info("[Jury] Stage 1 — launching 3 jurors in parallel for: %.60s", claim)
73
 
74
  j1_out, j2_out, j3_out = await asyncio.gather(
75
  _j1.analyse(claim, evidence),
76
  _j2.analyse(claim, evidence),
77
  _j3.analyse(claim, evidence),
78
- return_exceptions=False, # let individual failures surface as JurorOutput fallbacks
79
  )
80
 
81
  initial_outputs: list[JurorOutput] = [j1_out, j2_out, j3_out]
82
 
83
  _log_jury_stage(initial_outputs, stage="initial")
84
 
85
- # ── Stage 2: Disagreement detection ──────────────────────────────────────
86
  disagreement = detect_disagreement(initial_outputs)
87
 
88
- # ── Stage 3: Debate (only when needed) ───────────────────────────────────
89
  if disagreement:
90
  logger.info("[Jury] Stage 3 — disagreement detected, starting debate.")
91
  post_debate_outputs, debate_log = await run_debate(
@@ -97,7 +50,6 @@ async def run_jury_subgraph(claim: str, evidence: list[dict]) -> dict:
97
  post_debate_outputs = initial_outputs
98
  debate_log = []
99
 
100
- # ── Stage 4: Aggregation ──────────────────────────────────────────────────
101
  result: JuryResult = aggregate(post_debate_outputs, debate_log)
102
 
103
  logger.info(
@@ -108,8 +60,6 @@ async def run_jury_subgraph(claim: str, evidence: list[dict]) -> dict:
108
  return _serialise_result(result)
109
 
110
 
111
- # ── Serialisation ─────────────────────────────────────────────────────────────
112
-
113
  def _serialise_juror_output(o: JurorOutput) -> dict:
114
  return {
115
  "juror_id": o.juror_id,
@@ -121,21 +71,16 @@ def _serialise_juror_output(o: JurorOutput) -> dict:
121
 
122
 
123
  def _serialise_result(result: JuryResult) -> dict:
124
- """Convert JuryResult to a plain JSON-serialisable dict."""
125
  return {
126
- # ── Fields that replace the old single-verifier verdict ───────────────
127
  "verdict": result.verdict,
128
  "confidence": result.confidence,
129
  "reasoning": result.reasoning,
130
- # ── Jury-specific metadata ────────────────────────────────────────────
131
  "jury_outputs": [_serialise_juror_output(o) for o in result.jury_outputs],
132
  "debate_log": result.debate_log,
133
  "needs_human_review": result.needs_human_review,
134
  }
135
 
136
 
137
- # ── Debug helper ──────────────────────────────────────────────────────────────
138
-
139
  def _log_jury_stage(outputs: list[JurorOutput], stage: str) -> None:
140
  for o in outputs:
141
  logger.debug(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import asyncio
 
17
 
18
  logger = logging.getLogger(__name__)
19
 
 
 
 
20
  _j1 = EmpricistJuror()
21
  _j2 = ContextualistJuror()
22
  _j3 = ScepticJuror()
23
 
24
 
 
 
25
  async def run_jury_subgraph(claim: str, evidence: list[dict]) -> dict:
26
+ logger.info(
27
+ "[Jury] Stage 1 launching 3 jurors in parallel for: %.60s", claim)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  j1_out, j2_out, j3_out = await asyncio.gather(
30
  _j1.analyse(claim, evidence),
31
  _j2.analyse(claim, evidence),
32
  _j3.analyse(claim, evidence),
33
+ return_exceptions=False,
34
  )
35
 
36
  initial_outputs: list[JurorOutput] = [j1_out, j2_out, j3_out]
37
 
38
  _log_jury_stage(initial_outputs, stage="initial")
39
 
 
40
  disagreement = detect_disagreement(initial_outputs)
41
 
 
42
  if disagreement:
43
  logger.info("[Jury] Stage 3 — disagreement detected, starting debate.")
44
  post_debate_outputs, debate_log = await run_debate(
 
50
  post_debate_outputs = initial_outputs
51
  debate_log = []
52
 
 
53
  result: JuryResult = aggregate(post_debate_outputs, debate_log)
54
 
55
  logger.info(
 
60
  return _serialise_result(result)
61
 
62
 
 
 
63
  def _serialise_juror_output(o: JurorOutput) -> dict:
64
  return {
65
  "juror_id": o.juror_id,
 
71
 
72
 
73
  def _serialise_result(result: JuryResult) -> dict:
 
74
  return {
 
75
  "verdict": result.verdict,
76
  "confidence": result.confidence,
77
  "reasoning": result.reasoning,
 
78
  "jury_outputs": [_serialise_juror_output(o) for o in result.jury_outputs],
79
  "debate_log": result.debate_log,
80
  "needs_human_review": result.needs_human_review,
81
  }
82
 
83
 
 
 
84
  def _log_jury_stage(outputs: list[JurorOutput], stage: str) -> None:
85
  for o in outputs:
86
  logger.debug(
graph/langgraph_pipeline.py CHANGED
@@ -11,7 +11,6 @@ from graph.jury_subgraph import run_jury_subgraph
11
  class ClaimResult(TypedDict):
12
  claim: str
13
  evidence: list[dict[str, Any]]
14
- # includes jury_outputs, debate_log, needs_human_review
15
  verdict: dict[str, Any]
16
  explanation: dict[str, Any]
17
 
@@ -160,12 +159,6 @@ async def run_evidence_retriever(state: PipelineState) -> PipelineState:
160
 
161
 
162
  async def run_jury(state: PipelineState) -> PipelineState:
163
- """
164
- Stage 3 — Delphi Jury.
165
-
166
- Processes each claim independently through the jury subgraph
167
- (J1 Empiricist, J2 Contextualist, J3 Sceptic → Debate → Aggregator).
168
- """
169
  updated_results: list[ClaimResult] = []
170
 
171
  for result in state["claim_results"]:
@@ -176,12 +169,6 @@ async def run_jury(state: PipelineState) -> PipelineState:
176
 
177
 
178
  async def run_explainer(state: PipelineState) -> PipelineState:
179
- """
180
- Stage 4 — Explainer.
181
-
182
- Passes jury reasoning + debate log to the explainer so it can produce
183
- a richer human-readable explanation that reflects the jury's deliberation.
184
- """
185
  updated_results: list[ClaimResult] = []
186
 
187
  for result in state["claim_results"]:
@@ -237,18 +224,11 @@ pipeline = build_pipeline()
237
 
238
 
239
  def _serialize_claim_results(claim_results: list[ClaimResult]) -> list[dict[str, Any]]:
240
- """
241
- Convert internal ClaimResult list into clean per-claim dicts for the API.
242
- Claims from Agent 1 are already self-contained — no stripping needed.
243
- Includes jury metadata (jury_outputs, debate_log, needs_human_review)
244
- for frontend rendering and audit trails.
245
- """
246
  out = []
247
  for result in claim_results:
248
  exp = result.get("explanation", {})
249
  verdict_block = result.get("verdict", {})
250
  out.append({
251
- # ── Standard fields (VerifyResponse compatible) ────────────────────
252
  "claim": result["claim"],
253
  "verdict": verdict_block.get("verdict", "UNVERIFIABLE"),
254
  "confidence": round(float(verdict_block.get("confidence", 0.0)), 3),
@@ -258,7 +238,6 @@ def _serialize_claim_results(claim_results: list[ClaimResult]) -> list[dict[str,
258
  for c in exp.get("citations", [])
259
  if isinstance(c, dict)
260
  ],
261
- # ── Jury-specific metadata ─────────────────────────────────────────
262
  "needs_human_review": verdict_block.get("needs_human_review", False),
263
  "jury_outputs": verdict_block.get("jury_outputs", []),
264
  "debate_log": verdict_block.get("debate_log", []),
@@ -289,12 +268,10 @@ async def run_pipeline(text: str) -> dict[str, Any]:
289
  )
290
 
291
  return {
292
- # ── Article-level summary (VerifyResponse compatible) ─────────────────
293
  "verdict": article.get("verdict", "UNVERIFIABLE"),
294
  "confidence": round(float(article.get("confidence", 0.0)), 3),
295
  "arabic_explanation": article.get("arabic_explanation", ""),
296
  "citations": article.get("citations", []),
297
  "needs_human_review": needs_human_review,
298
- # ── Per-claim breakdown ───────────────────────────────────────────────
299
  "claims": _serialize_claim_results(claim_results),
300
  }
 
11
  class ClaimResult(TypedDict):
12
  claim: str
13
  evidence: list[dict[str, Any]]
 
14
  verdict: dict[str, Any]
15
  explanation: dict[str, Any]
16
 
 
159
 
160
 
161
  async def run_jury(state: PipelineState) -> PipelineState:
 
 
 
 
 
 
162
  updated_results: list[ClaimResult] = []
163
 
164
  for result in state["claim_results"]:
 
169
 
170
 
171
  async def run_explainer(state: PipelineState) -> PipelineState:
 
 
 
 
 
 
172
  updated_results: list[ClaimResult] = []
173
 
174
  for result in state["claim_results"]:
 
224
 
225
 
226
  def _serialize_claim_results(claim_results: list[ClaimResult]) -> list[dict[str, Any]]:
 
 
 
 
 
 
227
  out = []
228
  for result in claim_results:
229
  exp = result.get("explanation", {})
230
  verdict_block = result.get("verdict", {})
231
  out.append({
 
232
  "claim": result["claim"],
233
  "verdict": verdict_block.get("verdict", "UNVERIFIABLE"),
234
  "confidence": round(float(verdict_block.get("confidence", 0.0)), 3),
 
238
  for c in exp.get("citations", [])
239
  if isinstance(c, dict)
240
  ],
 
241
  "needs_human_review": verdict_block.get("needs_human_review", False),
242
  "jury_outputs": verdict_block.get("jury_outputs", []),
243
  "debate_log": verdict_block.get("debate_log", []),
 
268
  )
269
 
270
  return {
 
271
  "verdict": article.get("verdict", "UNVERIFIABLE"),
272
  "confidence": round(float(article.get("confidence", 0.0)), 3),
273
  "arabic_explanation": article.get("arabic_explanation", ""),
274
  "citations": article.get("citations", []),
275
  "needs_human_review": needs_human_review,
 
276
  "claims": _serialize_claim_results(claim_results),
277
  }
main.py CHANGED
@@ -6,8 +6,6 @@ from schemas import VerifyRequest, VerifyResponse
6
  app = FastAPI(title="Arabic Verifier Service")
7
 
8
 
9
- # ── Endpoints ─────────────────────────────────────────────────────────────────
10
-
11
  @app.post("/verify", response_model=VerifyResponse)
12
  async def verify(req: VerifyRequest):
13
  try:
 
6
  app = FastAPI(title="Arabic Verifier Service")
7
 
8
 
 
 
9
  @app.post("/verify", response_model=VerifyResponse)
10
  async def verify(req: VerifyRequest):
11
  try:
schemas.py CHANGED
@@ -1,26 +1,16 @@
1
- """
2
- schemas.py
3
- Pydantic request/response models for the Arabic Verifier Service.
4
- """
5
-
6
  from pydantic import BaseModel
7
 
8
 
9
- # ── Request ───────────────────────────────────────────────────────────────────
10
-
11
  class VerifyRequest(BaseModel):
12
  text: str
13
 
14
 
15
- # ── Response models ───────────────────────────────────────────────────────────
16
-
17
  class Citation(BaseModel):
18
  title: str = ""
19
  url: str = ""
20
 
21
 
22
  class JurorVerdictDetail(BaseModel):
23
- """Per-juror result from the Delphi jury."""
24
  juror_id: str
25
  verdict: str
26
  confidence: float = 0.0
@@ -29,7 +19,6 @@ class JurorVerdictDetail(BaseModel):
29
 
30
 
31
  class DebateLogEntry(BaseModel):
32
- """Single entry from the debate round log."""
33
  juror_id: str
34
  original_verdict: str
35
  original_confidence: float = 0.0
 
 
 
 
 
 
1
  from pydantic import BaseModel
2
 
3
 
 
 
4
  class VerifyRequest(BaseModel):
5
  text: str
6
 
7
 
 
 
8
  class Citation(BaseModel):
9
  title: str = ""
10
  url: str = ""
11
 
12
 
13
  class JurorVerdictDetail(BaseModel):
 
14
  juror_id: str
15
  verdict: str
16
  confidence: float = 0.0
 
19
 
20
 
21
  class DebateLogEntry(BaseModel):
 
22
  juror_id: str
23
  original_verdict: str
24
  original_confidence: float = 0.0
tools/google_factcheck.py CHANGED
@@ -16,8 +16,8 @@ async def search_google_factcheck(
16
  page_size: int = 5,
17
  api_key: str | None = None,
18
  ) -> list[dict[str, Any]]:
19
- """Search the Google Fact Check Tools API for reviewed claims."""
20
- resolved_api_key = api_key or os.getenv("GOOGLE_FACTCHECK_API_KEY", "").strip()
21
  if not resolved_api_key:
22
  print("[GoogleFC] No API key found in GOOGLE_FACTCHECK_API_KEY.")
23
  return []
@@ -45,7 +45,8 @@ async def search_google_factcheck(
45
  for claim_item in data.get("claims", []):
46
  claim_text = str(claim_item.get("text", "")).strip()
47
  for review in claim_item.get("claimReview", []):
48
- publisher = str(review.get("publisher", {}).get("name", "")).strip()
 
49
  rating = str(review.get("textualRating", "")).strip()
50
  url = str(review.get("url", "")).strip()
51
  title = str(review.get("title", "")).strip() or claim_text
 
16
  page_size: int = 5,
17
  api_key: str | None = None,
18
  ) -> list[dict[str, Any]]:
19
+ resolved_api_key = api_key or os.getenv(
20
+ "GOOGLE_FACTCHECK_API_KEY", "").strip()
21
  if not resolved_api_key:
22
  print("[GoogleFC] No API key found in GOOGLE_FACTCHECK_API_KEY.")
23
  return []
 
45
  for claim_item in data.get("claims", []):
46
  claim_text = str(claim_item.get("text", "")).strip()
47
  for review in claim_item.get("claimReview", []):
48
+ publisher = str(review.get(
49
+ "publisher", {}).get("name", "")).strip()
50
  rating = str(review.get("textualRating", "")).strip()
51
  url = str(review.get("url", "")).strip()
52
  title = str(review.get("title", "")).strip() or claim_text
tools/tavily_search.py CHANGED
@@ -10,14 +10,8 @@ from tavily import TavilyClient
10
 
11
  load_dotenv()
12
 
13
- # ── Key rotation ──────────────────────────────────────────────────────────────
14
-
15
 
16
  def _load_keys() -> list[str]:
17
- """
18
- Collects all TAVILY_API_KEY_N env vars in order (1, 2, 3, ...).
19
- Falls back to plain TAVILY_API_KEY if no numbered keys exist.
20
- """
21
  keys = []
22
  for i in count(1):
23
  key = os.getenv(f"TAVILY_API_KEY_{i}")
@@ -32,7 +26,7 @@ def _load_keys() -> list[str]:
32
 
33
 
34
  _KEYS = _load_keys()
35
- _key_index = 0 # points at the currently active key
36
 
37
 
38
  def _current_client() -> TavilyClient | None:
@@ -42,7 +36,6 @@ def _current_client() -> TavilyClient | None:
42
 
43
 
44
  def _rotate_key() -> bool:
45
- """Advance to the next key. Returns False if no more keys are left."""
46
  global _key_index
47
  if _key_index + 1 < len(_KEYS):
48
  _key_index += 1
@@ -56,13 +49,11 @@ def _is_rate_limit_error(exc: Exception) -> bool:
56
  return "429" in msg or "rate limit" in msg or "quota" in msg
57
 
58
 
59
- # ── Domain filtering ─────────────────────────────────────────────────────────
60
  def _domain_matches(url: str, domains: list[str]) -> bool:
61
  host = urlparse(url).netloc.lower().removeprefix("www.")
62
  return any(host == d or host.endswith("." + d) for d in domains)
63
 
64
 
65
- # ── Core search call (sync, retried across keys) ─────────────────────────────
66
  def _tavily_search_sync(
67
  query: str,
68
  *,
@@ -73,7 +64,7 @@ def _tavily_search_sync(
73
  print("[Tavily] No TAVILY_API_KEY_N or TAVILY_API_KEY set in .env")
74
  return []
75
 
76
- attempts = len(_KEYS) - _key_index # remaining keys from current position
77
  for _ in range(attempts):
78
  client = _current_client()
79
  try:
@@ -90,7 +81,7 @@ def _tavily_search_sync(
90
  print(
91
  f"[Tavily] {label} rate-limited: {type(exc).__name__}: {exc!r}")
92
  if _rotate_key():
93
- continue # retry immediately with the next key
94
  print("[Tavily] All keys exhausted — giving up for this call")
95
  return []
96
  else:
@@ -119,10 +110,6 @@ async def search_tavily(
119
  *,
120
  domains: list[str] | None = None,
121
  ) -> list[dict[str, Any]]:
122
- """
123
- Retrieve Arabic web evidence via Tavily using two complementary angles:
124
- a fact-check phrasing and a broader official-source news phrasing.
125
- """
126
  raw_query = re.sub(r"^تحقق\s*", "", query).strip()
127
  news_query = f"{raw_query} مصدر رسمي"
128
 
 
10
 
11
  load_dotenv()
12
 
 
 
13
 
14
  def _load_keys() -> list[str]:
 
 
 
 
15
  keys = []
16
  for i in count(1):
17
  key = os.getenv(f"TAVILY_API_KEY_{i}")
 
26
 
27
 
28
  _KEYS = _load_keys()
29
+ _key_index = 0
30
 
31
 
32
  def _current_client() -> TavilyClient | None:
 
36
 
37
 
38
  def _rotate_key() -> bool:
 
39
  global _key_index
40
  if _key_index + 1 < len(_KEYS):
41
  _key_index += 1
 
49
  return "429" in msg or "rate limit" in msg or "quota" in msg
50
 
51
 
 
52
  def _domain_matches(url: str, domains: list[str]) -> bool:
53
  host = urlparse(url).netloc.lower().removeprefix("www.")
54
  return any(host == d or host.endswith("." + d) for d in domains)
55
 
56
 
 
57
  def _tavily_search_sync(
58
  query: str,
59
  *,
 
64
  print("[Tavily] No TAVILY_API_KEY_N or TAVILY_API_KEY set in .env")
65
  return []
66
 
67
+ attempts = len(_KEYS) - _key_index
68
  for _ in range(attempts):
69
  client = _current_client()
70
  try:
 
81
  print(
82
  f"[Tavily] {label} rate-limited: {type(exc).__name__}: {exc!r}")
83
  if _rotate_key():
84
+ continue
85
  print("[Tavily] All keys exhausted — giving up for this call")
86
  return []
87
  else:
 
110
  *,
111
  domains: list[str] | None = None,
112
  ) -> list[dict[str, Any]]:
 
 
 
 
113
  raw_query = re.sub(r"^تحقق\s*", "", query).strip()
114
  news_query = f"{raw_query} مصدر رسمي"
115