Verifier-Service / agents /explainer.py
ArabicNewsAnalyzer's picture
Update agents/explainer.py
152629f verified
Raw
History Blame Contribute Delete
12.1 kB
import asyncio
import json
import logging
import os
import re
from groq import AsyncGroq
from dotenv import load_dotenv
from pydantic import BaseModel, field_validator
load_dotenv()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
GROQ_MODEL = "llama-3.3-70b-versatile"
MAX_TOKENS = 1024
TEMPERATURE = 0.1
RETRY_DELAY = 1.0
MAX_EVIDENCE = 6
MAX_SNIPPET_LEN = 200
VALID_VERDICTS = {"SUPPORTED", "REFUTED", "PARTIALLY_TRUE", "UNVERIFIABLE"}
VERDICT_LABELS = {
"SUPPORTED": "✓ مدعوم بالأدلة",
"REFUTED": "✗ مدحوض",
"PARTIALLY_TRUE": "~ صحيح جزئياً",
"UNVERIFIABLE": "? لا يمكن التحقق",
}
class Citation(BaseModel):
title: str = ""
url: str = ""
@field_validator("url")
@classmethod
def url_must_be_http(cls, v: str) -> str:
if v and not v.startswith("http"):
return ""
return v
class ExplainerOutput(BaseModel):
arabic_explanation: str
verdict: str
confidence: float = 0.0
citations: list[Citation] = []
@field_validator("verdict")
@classmethod
def verdict_must_be_valid(cls, v: str) -> str:
v = v.upper().strip()
return v if v in VALID_VERDICTS else "UNVERIFIABLE"
@field_validator("arabic_explanation")
@classmethod
def explanation_not_empty(cls, v: str) -> str:
v = v.strip()
return v if v else "لم يتمكن النظام من توليد تفسير."
class VerdictInput(BaseModel):
verdict: str = "UNVERIFIABLE"
confidence: float = 0.0
reasoning: str = ""
@field_validator("verdict")
@classmethod
def verdict_must_be_valid(cls, v: str) -> str:
v = v.upper().strip()
return v if v in VALID_VERDICTS else "UNVERIFIABLE"
def _default_explanation(
claim: str, verdict: str, confidence: float
) -> ExplainerOutput:
label = VERDICT_LABELS.get(verdict, verdict)
return ExplainerOutput(
arabic_explanation=(
f'الادعاء: "{claim}"\n'
f"الحكم: {label}\n"
"لم يتمكن النظام من توليد تفسير مفصل في الوقت الحالي."
),
verdict=verdict,
confidence=confidence,
citations=[],
)
SYSTEM_PROMPT = """أنت محرر صحفي متخصص في تدقيق الأخبار العربية.
مهمتك: كتابة تقرير تحقق موجز وواضح يفهمه القارئ العادي، يشرح نتيجة التحقق من الادعاء وأسبابها.
━━━ أسلوب الكتابة ━━━
- فقرة عربية واحدة متماسكة، من 3 إلى 5 جمل، بأسلوب صحفي محايد.
- ابدأ دائماً بجملة تُعلن نتيجة التحقق مباشرةً (مثال: "تبيّن أن هذا الادعاء صحيح..." أو "ثبت أن هذا الادعاء مضلل...").
- اربط النتيجة بالأدلة بشكل طبيعي (مثال: "إذ أكد موقع فرانس 24 أن..." أو "وقد كشف تقرير منظمة Misbar أن...").
- لا تذكر أسماء تقنية مثل "Agent 3" أو "النظام" أو "الخوارزمية".
- لا تكرر الادعاء حرفياً في بداية التفسير — لخّصه أو أشر إليه بصيغة مختلفة.
━━━ تعليمات حسب نوع الحكم ━━━
- SUPPORTED: وضّح ما الذي أثبتته الأدلة وكيف تدعم الادعاء.
- REFUTED: وضّح التناقض بين الادعاء والحقيقة بأرقام أو وقائع محددة من الأدلة.
- PARTIALLY_TRUE: اذكر الجانب الصحيح أولاً ثم الجانب المضلل أو المبالغ فيه.
- UNVERIFIABLE: اشرح لماذا تعذّر التحقق (غياب مصادر، تعارض الأدلة، ادعاء مستقبلي، إلخ).
━━━ قواعد الاستشهاد ━━━
- اذكر اسم المصدر في المتن بشكل طبيعي عند الاستناد إليه.
- أدرج في citations فقط المصادر التي ذكرتها فعلاً في التفسير.
- لا تُدرج URLs وهمية أو مصادر غير واردة في الأدلة.
━━━ قواعد الإخراج ━━━
1. أجب فقط بـ JSON صالح — لا نص قبله أو بعده، لا علامات markdown.
2. يجب أن يطابق verdict القيمة المعطاة — لا تغيّرها.
3. اكتب النص بالعربية الفصحى فقط. ممنوع منعاً تاماً استخدام أي حرف أو كلمة من لغة أخرى
(إنجليزية، لاتينية، كيريلية، أو أي أبجدية غير عربية) داخل النص العربي، حتى ولو جزئياً
داخل كلمة واحدة. إذا احتجت لذكر اسم مصدر أو كيان أجنبي، اكتبه بحروف عربية فقط.
━━━ بنية الفقرة ━━━
اكتب النص كفقرة واحدة متماسكة من 3 إلى 5 جمل موزعة على ثلاث طبقات:
1. جملة الحكم: ابدأ بنتيجة التحقق مباشرة (جملة واحدة).
2. جملة الدليل: اذكر المصدر والواقعة (جملة أو جملتان).
3. جملة الخلاصة: اربط الدليل بالنتيجة (جملة ختامية).
استخدم فاصل "،" بين الجمل المتصلة، والنقطة "." عند نهاية كل فكرة.
لا تكرر المعلومة بصيغة مختلفة. لا تضع مسافات زائدة.
- تأكد ألا توجد جملتان تحملان نفس المعنى بصيغ مختلفة.
- كل جملة يجب أن تضيف معلومة جديدة لم تُذكر في الجمل السابقة.
شكل الإجابة (JSON فقط):
{
"arabic_explanation": "فقرة عربية واحدة متماسكة...",
"verdict": "SUPPORTED | REFUTED | PARTIALLY_TRUE | UNVERIFIABLE",
"citations": [
{"title": "اسم المصدر أو عنوان المقال", "url": "https://..."}
]
}"""
class Explainer:
def __init__(self):
self.client = AsyncGroq(api_key=os.environ.get("GROQ_API_KEY5"))
async def run(
self,
claim: str,
evidence: list[dict],
verdict_result: dict,
) -> ExplainerOutput:
claim = self._strip_context_tag(claim)
try:
v_input = VerdictInput(**verdict_result)
except Exception as e:
logger.warning(
f"[Explainer] Invalid verdict_result input: {e} — using defaults"
)
v_input = VerdictInput()
if not evidence:
return ExplainerOutput(
arabic_explanation=(
f'تعذّر التحقق من الادعاء التالي: "{claim}"\n'
"لم يتم العثور على أي أدلة أو مصادر تتعلق بهذا الادعاء."
),
verdict="UNVERIFIABLE",
confidence=v_input.confidence,
citations=[],
)
user_prompt = self._build_prompt(
claim, evidence, v_input.verdict, v_input.reasoning, v_input.confidence
)
result = await self._call_groq(user_prompt, v_input.confidence)
if result is not None:
return result
logger.warning(
f"[Explainer] JSON parse failed — retrying after {RETRY_DELAY}s for: {claim[:60]}"
)
await asyncio.sleep(RETRY_DELAY)
result = await self._call_groq(user_prompt, v_input.confidence)
if result is not None:
return result
logger.error(
f"[Explainer] Both attempts failed — using default explanation for: {claim[:60]}"
)
return _default_explanation(claim, v_input.verdict, v_input.confidence)
def _build_prompt(
self,
claim: str,
evidence: list[dict],
verdict: str,
reasoning: str,
confidence: float,
) -> str:
verdict_label = VERDICT_LABELS.get(verdict, verdict)
confidence_pct = f"{confidence:.0%}"
capped = evidence[:MAX_EVIDENCE]
source_lines = []
for i, e in enumerate(capped, 1):
source = e.get("source", "unknown")
snippet = e.get("snippet", "").strip()[:MAX_SNIPPET_LEN]
url = e.get("source_url", "")
rating = e.get("rating")
source_label = {
"google_factcheck": "تحقق من الحقائق",
"tavily": "بحث ويب",
}.get(source, source)
line = f"[{i}] {source_label}"
if url and url.startswith("http"):
line += f" — {url}"
if rating:
line += f"\n التقييم الرسمي: {rating}"
line += f"\n {snippet}"
source_lines.append(line)
sources_block = "\n\n".join(
source_lines) if source_lines else "لا توجد أدلة."
return f"""الادعاء:
«{claim}»
نتيجة التحقق: {verdict_label} (مستوى الثقة: {confidence_pct})
ملخص التحليل: {reasoning}
الأدلة المتاحة ({len(capped)} مصدر):
{sources_block}
اكتب تقريراً تحققياً موجزاً للقارئ العادي بصيغة JSON."""
async def _call_groq(
self, user_prompt: str, confidence: float
) -> ExplainerOutput | None:
try:
response = await self.client.chat.completions.create(
model=GROQ_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
max_tokens=MAX_TOKENS,
temperature=TEMPERATURE,
)
except Exception as e:
logger.error(
f"[Explainer] Groq API error: {type(e).__name__}: {e!r}")
return None
raw = response.choices[0].message.content or ""
return self._parse_response(raw, confidence)
def _parse_response(self, raw: str, confidence: float) -> ExplainerOutput | None:
clean = re.sub(r"```json\s*", "", raw)
clean = re.sub(r"```\s*", "", clean)
clean = clean.strip()
try:
parsed = json.loads(clean)
return self._validate_response(parsed, confidence)
except json.JSONDecodeError:
pass
match = re.search(r"\{.*\}", clean, re.DOTALL)
if match:
try:
parsed = json.loads(match.group())
return self._validate_response(parsed, confidence)
except json.JSONDecodeError:
pass
logger.warning(
f"[Explainer] Could not parse JSON from response: {raw[:200]}")
return None
def _validate_response(self, parsed: dict, confidence: float) -> ExplainerOutput:
raw_citations = parsed.get("citations", [])
clean_citations = []
if isinstance(raw_citations, list):
for c in raw_citations:
if isinstance(c, dict):
clean_citations.append(
{
"title": str(c.get("title", "")).strip(),
"url": str(c.get("url", "")).strip(),
}
)
return ExplainerOutput(
arabic_explanation=str(parsed.get(
"arabic_explanation", "")).strip(),
verdict=str(parsed.get("verdict", "UNVERIFIABLE")),
confidence=confidence,
citations=clean_citations,
)
def _strip_context_tag(self, claim: str) -> str:
if claim.startswith("[سياق:"):
match = re.match(r"\[سياق:\s*.+?\]\s*(.+)", claim, re.DOTALL)
if match:
return match.group(1).strip()
return claim.strip()