| 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() |
|
|