Upload 17 files
Browse filescleaned and removed wikidata
- agents/claim_extractor.py +0 -82
- agents/evidence_retriever.py +6 -83
- agents/explainer.py +8 -108
- agents/jury/__init__.py +1 -10
- agents/jury/_base_juror.py +2 -48
- agents/jury/aggregator.py +11 -71
- agents/jury/contextualist.py +0 -28
- agents/jury/debate.py +9 -67
- agents/jury/empiricist.py +0 -28
- agents/jury/sceptic.py +0 -29
- config/trusted_sources.py +20 -30
- graph/jury_subgraph.py +3 -58
- graph/langgraph_pipeline.py +0 -23
- main.py +0 -2
- schemas.py +0 -11
- tools/google_factcheck.py +4 -3
- tools/tavily_search.py +3 -16
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.
|
| 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 = ["
|
| 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
|
| 55 |
-
RETRY_DELAY = 1.0
|
| 56 |
|
| 57 |
-
MAX_EVIDENCE = 6
|
| 58 |
-
MAX_SNIPPET_LEN = 200
|
| 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 |
-
"
|
| 285 |
-
|
| 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
|
| 37 |
MAX_EVIDENCE_SNIPPETS = 8
|
| 38 |
-
MAX_SNIPPET_LENGTH = 300
|
| 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",
|
|
|
|
| 33 |
|
| 34 |
VALID_VERDICTS: frozenset[str] = frozenset(
|
| 35 |
{"SUPPORTED", "REFUTED", "PARTIALLY_TRUE", "UNVERIFIABLE"}
|
| 36 |
)
|
| 37 |
|
| 38 |
-
CONFIDENCE_SPREAD_THRESHOLD = 0.25
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
# ── Data classes ─────────────────────────────────────────────────────────────
|
| 42 |
|
| 43 |
|
| 44 |
@dataclass
|
| 45 |
class JurorOutput:
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 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]
|
| 68 |
-
debate_log: list[dict]
|
| 69 |
needs_human_review: bool
|
| 70 |
-
reasoning: str
|
| 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:
|
| 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
|
| 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(
|
|
|
|
| 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)
|
|
|
|
| 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(
|
|
|
|
| 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 = [
|
|
|
|
| 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",
|
| 19 |
-
"dw.com",
|
| 20 |
-
"france24.com",
|
| 21 |
|
| 22 |
-
|
| 23 |
-
"
|
| 24 |
-
"
|
| 25 |
-
"
|
| 26 |
-
"
|
| 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",
|
| 34 |
-
"naharnet.com",
|
| 35 |
-
"jordantimes.com",
|
| 36 |
-
"ahram.org.eg",
|
| 37 |
-
"madaamasr.com",
|
| 38 |
|
| 39 |
-
# --- Middle East: Gulf Focus ---
|
| 40 |
"aljazeera.net",
|
| 41 |
-
"arabnews.com",
|
| 42 |
-
"gulfnews.com",
|
| 43 |
-
"alrai.com",
|
| 44 |
|
| 45 |
-
|
| 46 |
-
"
|
| 47 |
-
"
|
| 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",
|
| 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 |
-
|
| 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,
|
| 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 |
-
|
| 20 |
-
|
| 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(
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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 |
|