""" Document Analyzer — Sends OCR-extracted JSON to Groq LLM for intelligent analysis. Produces: summary, risk_level, severity_score, key_findings, recommendation. Uses the existing GROQ_API_KEY — zero additional cost. """ import json import logging from app.core.config import settings logger = logging.getLogger(__name__) # ─── Arabic Prompt Templates per document type ──────────────────────────────── _BASE_PROMPT = """\ أنت محلل وثائق متخصص في منصة عون للمساعدة الاجتماعية. تم استخراج البيانات التالية من وثيقة من نوع "{doc_type}" باستخدام OCR: {fields_json} قم بتحليل هذه البيانات وأعد ردًا بتنسيق JSON فقط — بدون أي شرح أو نص خارج JSON. الحقول المطلوبة: {{ "summary": "ملخص موجز للوثيقة بالعربية (جملة أو جملتان)", "risk_level": "high | medium | low", "severity_score": <رقم من 0 إلى 100>, "key_findings": ["اكتشاف 1", "اكتشاف 2", ...], "recommendation": "توصية واحدة واضحة للموظف المسؤول", "confidence": <رقم من 0.0 إلى 1.0 يعبر عن مدى اكتمال البيانات>, "tampering_detected": true | false }} قواعد التقييم: - risk_level=high → مرض مزمن / دخل أقل من 1500 جنيه / ديون تتجاوز ضعف الدخل - risk_level=medium → وضع متوسط يحتاج متابعة - risk_level=low → وضع مستقر لا يستدعي تدخلاً عاجلاً - لو في حقول ناقصة (null) → اذكرها في key_findings وخفض confidence - للكشف عن التزوير (tampering_detected=true): هل هناك تناقض صريح في التواريخ؟ هل الأرقام أو النصوص تبدو غير منطقية أو متقطعة بشكل يدل على تلاعب؟ - أجب بـ JSON فقط بدون markdown """ # ─── Analyzer ───────────────────────────────────────────────────────────────── class DocumentAnalyzer: """LLM-powered document intelligence layer, built on top of OCR output.""" def __init__(self): if not settings.GROQ_API_KEY: logger.warning("GROQ_API_KEY missing — Document Analyzer unavailable.") self.client = None else: from groq import AsyncGroq self.client = AsyncGroq(api_key=settings.GROQ_API_KEY, timeout=30.0) logger.info("Document Analyzer initialized (Groq).") def is_available(self) -> bool: return self.client is not None async def analyze(self, ocr_fields: dict, document_type: str) -> dict: """ Analyze OCR-extracted fields using LLM. Args: ocr_fields: The dict returned by OCR (without _provider key). document_type: e.g. "medical_report", "income_proof". Returns: dict matching DocumentAnalysis schema, or a graceful fallback. """ if not self.client: return self._unavailable_fallback(document_type) # Remove internal keys before sending to LLM clean_fields = {k: v for k, v in ocr_fields.items() if not k.startswith("_")} # Count null fields for a quick completeness hint null_count = sum(1 for v in clean_fields.values() if v is None) total_fields = len(clean_fields) or 1 prompt = _BASE_PROMPT.format( doc_type=document_type, fields_json=json.dumps(clean_fields, ensure_ascii=False, indent=2), ) try: response = await self.client.chat.completions.create( model="llama-3.3-70b-versatile", messages=[{"role": "user", "content": prompt}], temperature=0.1, # Low temp → deterministic structured output max_tokens=512, ) raw = response.choices[0].message.content.strip() # Strip markdown fences if present if "```json" in raw: raw = raw.split("```json")[1].split("```")[0].strip() elif "```" in raw: raw = raw.split("```")[1].split("```")[0].strip() result = json.loads(raw) # Validate required keys — fill missing with defaults result.setdefault("summary", "تعذّر إنشاء الملخص.") result.setdefault("risk_level", "medium") result.setdefault("severity_score", 50) result.setdefault("key_findings", []) result.setdefault("recommendation", "يُرجى مراجعة الوثيقة يدوياً.") result.setdefault("confidence", round(1 - (null_count / total_fields), 2)) result.setdefault("tampering_detected", False) # If tampering is detected, override recommendation if result.get("tampering_detected"): result["recommendation"] = "🚨 تحذير: اشتباه في تلاعب بالوثيقة. " + result["recommendation"] # Clamp severity_score result["severity_score"] = max(0, min(100, int(result["severity_score"]))) result["risk_level"] = result["risk_level"].lower() if result["risk_level"] not in ("high", "medium", "low"): result["risk_level"] = "medium" return result except json.JSONDecodeError as e: logger.error("LLM returned non-JSON response: %s | raw=%s", e, raw[:200]) return self._parse_error_fallback(document_type) except Exception as e: logger.error("Document analysis failed: %s", e, exc_info=True) return self._error_fallback(document_type) # ─── Fallbacks ──────────────────────────────────────────────────────────── @staticmethod def _unavailable_fallback(doc_type: str) -> dict: return { "summary": "خدمة التحليل غير متاحة حالياً.", "risk_level": "medium", "severity_score": 50, "key_findings": ["لم يتم إجراء التحليل — GROQ_API_KEY غير مضبوط."], "recommendation": "يُرجى ضبط GROQ_API_KEY وإعادة المحاولة.", "confidence": 0.0, "tampering_detected": False, } @staticmethod def _parse_error_fallback(doc_type: str) -> dict: return { "summary": "تعذّر تحليل الوثيقة — الرد غير صالح.", "risk_level": "medium", "severity_score": 50, "key_findings": ["فشل تحليل رد النموذج — يُرجى المراجعة اليدوية."], "recommendation": "راجع البيانات المستخرجة يدوياً.", "confidence": 0.0, "tampering_detected": False, } @staticmethod def _error_fallback(doc_type: str) -> dict: return { "summary": "حدث خطأ أثناء التحليل.", "risk_level": "medium", "severity_score": 50, "key_findings": ["تعذّر التحليل بسبب خطأ تقني."], "recommendation": "يُرجى المحاولة مجدداً أو المراجعة اليدوية.", "confidence": 0.0, "tampering_detected": False, } # Singleton document_analyzer = DocumentAnalyzer()