Spaces:
Sleeping
Sleeping
| 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 = "" | |
| 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] = [] | |
| def verdict_must_be_valid(cls, v: str) -> str: | |
| v = v.upper().strip() | |
| return v if v in VALID_VERDICTS else "UNVERIFIABLE" | |
| 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 = "" | |
| 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() | |