""" ENA Chatbot — Agentic RAG v1.0 Agent يقرر تلقائياً شنو الأداة المناسبة لكل سؤال """ import json import re import requests from bs4 import BeautifulSoup from utils import normalize_arabic, detect_lang from config import PAGE_URLS # ══════════════════════════════════════════════════════════ # 🛠️ TOOLS DEFINITION # ══════════════════════════════════════════════════════════ TOOLS = [ { "type": "function", "function": { "name": "search_db", "description": """ابحث في قاعدة بيانات ENA عن معلومات عامة. استخدم هذه الأداة لـ: - أسئلة عامة عن ENA (تاريخ، مهام، شراكات) - التكوين المستمر والـ FAD - القيادة الإدارية والحوكمة - أي سؤال لا يتعلق بشروط مناظرة محددة""", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "السؤال للبحث عنه في قاعدة البيانات" } }, "required": ["query"] } } }, { "type": "function", "function": { "name": "get_concours_page", "description": """جيب صفحة المناظرة كاملة مباشرة من الموقع الرسمي. استخدم هذه الأداة لـ: - شروط الترشح لأي مناظرة - وثائق ملف الترشح - الاختبارات والبرامج - مواعيد وإجراءات التسجيل - الفرق بين المناظرة الخارجية والداخلية""", "parameters": { "type": "object", "properties": { "concours_type": { "type": "string", "enum": ["superieur_ar", "superieur_fr", "a2_ar", "a2_fr", "a3_ar", "a3_fr", "general_ar", "general_fr"], "description": "نوع المناظرة واللغة" } }, "required": ["concours_type"] } } }, { "type": "function", "function": { "name": "ask_user_profile", "description": """اسأل المستخدم عن وضعه الشخصي لتقييم تأهله. استخدم هذه الأداة فقط عندما يسأل المستخدم: - "هل أنا مؤهل؟" - "هل يمكنني الترشح؟" - "هل سني مناسب؟" - "هل شهادتي تنفع؟" لا تستخدمها للأسئلة العامة.""", "parameters": { "type": "object", "properties": { "concours_type": { "type": "string", "description": "نوع المناظرة المقصودة إن وُجدت" } }, "required": [] } } }, { "type": "function", "function": { "name": "summarize_url", "description": """لخّص محتوى رابط من موقع ENA. استخدم هذه الأداة فقط عندما يعطي المستخدم رابط ويطلب تلخيصه.""", "parameters": { "type": "object", "properties": { "url": { "type": "string", "description": "الرابط المطلوب تلخيصه" } }, "required": ["url"] } } }, { "type": "function", "function": { "name": "check_latest_news", "description": """تحقق من آخر الأخبار والبلاغات في موقع ENA مباشرة. استخدم هذه الأداة عندما يسأل المستخدم عن: - هل في جديد؟ - هل نزل بلاغ جديد؟ - هل فُتحت مناظرة جديدة؟ - آخر الأخبار والإعلانات - أي سؤال عن التحديثات الأخيرة""", "parameters": { "type": "object", "properties": { "lang": { "type": "string", "enum": ["ar", "fr"], "description": "لغة الصفحة المطلوبة" } }, "required": ["lang"] } } } ] # ══════════════════════════════════════════════════════════ # 🔧 TOOL IMPLEMENTATIONS # ══════════════════════════════════════════════════════════ CONCOURS_URLS = { "superieur_ar": PAGE_URLS["ar_concours_superieur"], "superieur_fr": PAGE_URLS["concours_superieur"], "a2_ar": PAGE_URLS["ar_concours_a2"], "a2_fr": PAGE_URLS["concours_a2"], "a3_ar": PAGE_URLS["ar_concours_a3"], "a3_fr": PAGE_URLS["concours_a3"], "general_ar": PAGE_URLS["ar_concours_general"], "general_fr": PAGE_URLS["concours_general"], } def tool_search_db(engine, query: str): """يبحث في الـ DB ويرجع النتائج""" try: results = engine.hybrid_search(normalize_arabic(query)) top = engine.rerank(normalize_arabic(query), results) if not top: return "لم أجد معلومات كافية في قاعدة البيانات.", [] context = "\n\n".join([f"[{i+1}] {c['content']}" for i, c in enumerate(top)]) sources = [{"url": c["meta"].get("url", ""), "page_name": c["meta"].get("page_name", ""), "content": c.get("content", "")} for c in top] return context, sources except Exception as e: return f"خطأ في البحث: {e}", [] def tool_get_concours_page(concours_type: str): """يجيب صفحة المناظرة كاملة""" url = CONCOURS_URLS.get(concours_type) if not url: return "نوع المناظرة غير معروف.", [] try: r = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15) soup = BeautifulSoup(r.text, "html.parser") for tag in soup(["script", "style", "nav", "footer", "header"]): tag.decompose() text = soup.get_text(" ", strip=True) # نجيب أهم جزء — أول 4000 حرف return f"[من {url}]\n\n{text[:4000]}", [{"url": url, "page_name": concours_type, "content": text[:800] + "..."}] except Exception as e: return f"خطأ في جلب الصفحة: {e}", [] def tool_ask_user_profile(lang: str, concours_type: str = "") -> str: """يولّد سؤال لجمع معلومات المستخدم""" concours = concours_type or ("المناظرة" if lang == "ar" else "le concours") if lang == "ar": return f"""باش نحكم على تأهّلك لـ {concours}، أجبني على 3 أسئلة: 1️⃣ **عمرك؟** (مثال: 28 سنة) 2️⃣ **شهادتك؟** (مثال: ماجستير قانون / مهندس / إجازة) 3️⃣ **وضعك الحالي؟** - 🎓 طالب أو بدون عمل - 👔 موظف في الإدارة العمومية - 📋 مسجل بمكتب التشغيل""" else: return f"""Pour évaluer votre éligibilité au {concours}, répondez à 3 questions : 1️⃣ **Votre âge ?** 2️⃣ **Votre diplôme ?** 3️⃣ **Votre situation actuelle ?** - 🎓 Étudiant ou sans emploi - 👔 Fonctionnaire - 📋 Inscrit au bureau d'emploi""" def tool_summarize_url(url: str): """يجيب محتوى رابط للتلخيص""" try: r = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15) soup = BeautifulSoup(r.text, "html.parser") for tag in soup(["script", "style", "nav", "footer", "header"]): tag.decompose() text = soup.get_text(" ", strip=True) return text[:3000], [{"url": url, "page_name": "رابط مقال", "content": text[:800] + "..."}] except Exception as e: return f"خطأ في جلب الرابط: {e}", [] def tool_check_latest_news(lang: str = "ar"): """يجيب آخر الأخبار من موقع ENA مباشرة""" from config import PAGE_URLS url = PAGE_URLS.get(f"actualites_{lang}", PAGE_URLS["actualites_ar"]) try: r = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15) soup = BeautifulSoup(r.text, "html.parser") # جيب كل المقالات مع تواريخها articles = [] for item in soup.find_all(["article", "li", "div"], class_=lambda x: x and any( k in str(x).lower() for k in ["post", "article", "news", "actualite", "item"] ))[:10]: title = item.find(["h1","h2","h3","h4","a"]) date = item.find(["time", "span"], class_=lambda x: x and "date" in str(x).lower()) link = item.find("a", href=True) if title and title.get_text(strip=True): articles.append({ "title": title.get_text(strip=True)[:100], "date": date.get_text(strip=True) if date else "", "url": link["href"] if link else "" }) if not articles: # fallback: نجيب النص الخام for tag in soup(["script","style","nav","footer","header"]): tag.decompose() text = soup.get_text(" ", strip=True)[:3000] return "[\u0645\u0646 " + url + "]\n\n" + text, [{"url": url, "page_name": "\u0627\u0644\u0623\u062e\u0628\u0627\u0631", "content": text[:500]}] # نرجع النتائج كنص منظم result = "[\u0622\u062e\u0631 \u0627\u0644\u0623\u062e\u0628\u0627\u0631 \u0645\u0646 " + url + "]\n\n" for i, a in enumerate(articles[:5]): result += f"{i+1}. {a['title']}" if a['date']: result += f" — {a['date']}" if a['url']: result += "\n 🔗 " + a['url'] result += "\n\n" sources = [{"url": a["url"] or url, "page_name": a["title"], "content": a["title"]} for a in articles[:3]] return result, sources except Exception as e: return f"خطأ في جلب الأخبار: {e}", [] # ══════════════════════════════════════════════════════════ # 🤖 AGENTIC RAG ENGINE # ══════════════════════════════════════════════════════════ class AgenticRAG: def __init__(self, engine): self.engine = engine self.llm = engine.llm def run(self, question: str, history: list = [], uploaded_pdf_text: str = "") -> dict: """ يشغّل الـ Agent ويرجع: - answer: الإجابة النهائية - tool_used: الأداة اللي استخدمها - context: السياق اللي استخدمه - needs_user_input: True إذا يحتاج معلومات من المستخدم """ if not self.llm: return {"answer": "❌ GROQ_TOKEN Missing", "tool_used": None} lang = detect_lang(question) # ── Step 1: Agent يقرر شنو الأداة ── system = """أنت Agent ذكي لـ ENA تونس. مهمتك اختيار الأداة المناسبة للإجابة. قواعد اختيار الأداة: - شروط/وثائق/اختبارات مناظرة → get_concours_page - هل أنا مؤهل/نجم نترشح → ask_user_profile - رابط للتلخيص → summarize_url - هل في جديد؟/آخر الأخبار؟/هل نزل بلاغ؟ → check_latest_news - أي سؤال آخر → search_db اختر أداة واحدة فقط.""" messages = [{"role": "system", "content": system}] # أضف تاريخ المحادثة for msg in history[-4:]: messages.append({ "role": msg["role"], "content": re.sub(r"<[^>]+>", "", msg["content"])[:300] }) messages.append({"role": "user", "content": question}) try: # Agent يختار الأداة response = self.llm.chat.completions.create( model="llama-3.3-70b-versatile", messages=messages, tools=TOOLS, tool_choice="auto", temperature=0, max_tokens=200 ) msg = response.choices[0].message tool_calls = getattr(msg, "tool_calls", None) # ── Step 2: تنفيذ الأداة ── if tool_calls: tool_call = tool_calls[0] tool_name = tool_call.function.name tool_args = json.loads(tool_call.function.arguments) # إذا يحتاج معلومات المستخدم if tool_name == "ask_user_profile": profile_q = tool_ask_user_profile( lang, tool_args.get("concours_type", "") ) return { "answer": profile_q, "tool_used": "ask_user_profile", "needs_user_input": True, "context": "" } # جيب السياق من الأداة المناسبة sources = [] if tool_name == "search_db": context, sources = tool_search_db(self.engine, tool_args["query"]) elif tool_name == "get_concours_page": context, sources = tool_get_concours_page(tool_args["concours_type"]) elif tool_name == "summarize_url": context, sources = tool_summarize_url(tool_args["url"]) elif tool_name == "check_latest_news": context, sources = tool_check_latest_news(tool_args.get("lang", lang)) else: context, sources = tool_search_db(self.engine, question) else: # Agent decided to just talk (e.g. small talk like "Hello") return { "answer": msg.content or "بكل سرور! تفضل اسأل أي شيء حول المدرسة الوطنية للإدارة.", "tool_used": "chat", "context": "محادثة عامة", "sources": [], "needs_user_input": False } if uploaded_pdf_text: context = f"[ملف PDF رسمي مرفوع من المستخدم للإجابة منه]\n{uploaded_pdf_text[:8000]}\n\n---\n" + context # ── Step 3: LLM يولّد الإجابة ── final_answer = self._generate_answer( question, context, lang, history, tool_name ) return { "answer": final_answer, "tool_used": tool_name, "context": context, "sources": sources, "needs_user_input": False } except Exception as e: # Fallback للـ RAG العادي context, sources = tool_search_db(self.engine, question) if uploaded_pdf_text: context = f"[ملف PDF رسمي مرفوع من المستخدم للإجابة منه]\n{uploaded_pdf_text[:8000]}\n\n---\n" + context answer = self._generate_answer(question, context, lang, history, "search_db") return { "answer": answer, "tool_used": "search_db (fallback)", "context": context, "sources": sources, "needs_user_input": False } def _generate_answer(self, question: str, context: str, lang: str, history: list, tool_used: str) -> str: """يولّد الإجابة النهائية""" system_prompt = self.engine.get_system_prompt(lang) # تاريخ المحادثة history_text = "" if history: label = "تاريخ المحادثة:" if lang == "ar" else "Historique:" history_text = f"\n\n{label}\n" for msg in history[-6:]: role = "المستخدم" if msg["role"] == "user" else "المساعد" content = re.sub(r"<[^>]+>", "", msg["content"])[:400] history_text += f"{role}: {content}\n" if lang == "ar": user_msg = f"الوثائق المرجعية:\n{context}{history_text}\n\nالسؤال: {question}" else: user_msg = f"Documents:\n{context}{history_text}\n\nQuestion: {question}" try: resp = self.engine.llm.chat.completions.create( model="llama-3.3-70b-versatile", messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_msg} ], temperature=0.1, max_tokens=1000 ) return resp.choices[0].message.content except Exception as e: return f"❌ خطأ: {e}" def evaluate_user_profile(self, user_info: str, lang: str) -> str: """يقيّم تأهل المستخدم بعد ما يعطي معلوماته""" # جيب شروط المناظرات context_ar, _ = tool_get_concours_page("superieur_ar") context_fr, _ = tool_get_concours_page("general_ar") context = f"{context_ar}\n\n{context_fr}"[:5000] if lang == "ar": prompt = f"""بناءً على الوثائق الرسمية لـ ENA، قيّم تأهل هذا الشخص: معلومات المترشح: {user_info} الوثائق: {context} أعطني: ✅ مؤهل / ❌ غير مؤهل / ⚠️ مؤهل مع شرط السبب بالتفصيل مع النص القانوني إذا غير مؤهل للمرحلة العليا، هل مؤهل لمناظرة أخرى؟""" else: prompt = f"""Basé sur les documents officiels ENA, évaluez l'éligibilité: Profil: {user_info} Documents: {context} Donnez: ✅ Éligible / ❌ Non éligible / ⚠️ Sous condition Raison détaillée avec référence légale.""" try: resp = self.engine.llm.chat.completions.create( model="llama-3.3-70b-versatile", messages=[{"role": "user", "content": prompt}], temperature=0.1, max_tokens=600 ) return resp.choices[0].message.content except Exception as e: return f"❌ خطأ: {e}" def generate_suggestions(self, question: str, answer: str, lang: str) -> list[dict]: """يولّد 3 اقتراحات ذات صلة بعد الإجابة — مع أيقونة + عنوان + وصف""" if not self.llm: return [] try: if lang == "ar": prompt = f"""أنت مساعد ENA تونس. بناءً على هذا السؤال والإجابة، اقترح 3 أسئلة متابعة مفيدة ومتنوعة. السؤال: {question} الإجابة: {answer[:300]} القواعد الصارمة: - السؤال 1: خطوة عملية تالية (وثائق، تسجيل، مواعيد) - السؤال 2: مناظرة أو مرحلة مختلفة (أ2، أ3، داخلية) - السؤال 3: موضوع آخر كلياً (تكوين، شراكات، حياة الطالب) - لا تكرر كلمات من السؤال الأصلي أجب بـ JSON فقط بهذا الشكل بالضبط: [ {{"icon": "📄", "title": "عنوان قصير", "desc": "سأعطيك...", "question": "السؤال الكامل"}}, {{"icon": "📅", "title": "عنوان قصير", "desc": "سأخبرك...", "question": "السؤال الكامل"}}, {{"icon": "✅", "title": "عنوان قصير", "desc": "سأقيّم...", "question": "السؤال الكامل"}} ]""" else: prompt = f"""Tu es l'assistant ENA Tunisie. Propose 3 questions de suivi variées. Question: {question} Réponse: {answer[:300]} Règles strictes: - Question 1: étape pratique (documents, inscription, dates) - Question 2: autre concours ou cycle (A2, A3, interne) - Question 3: autre sujet (formation, partenariats, vie étudiante) Réponds en JSON uniquement: [ {{"icon": "📄", "title": "Titre court", "desc": "Je vais vous...", "question": "Question complète"}}, {{"icon": "📅", "title": "Titre court", "desc": "Je vais vous...", "question": "Question complète"}}, {{"icon": "✅", "title": "Titre court", "desc": "Je vais vous...", "question": "Question complète"}} ]""" resp = self.llm.chat.completions.create( model="llama-3.3-70b-versatile", messages=[{"role": "user", "content": prompt}], temperature=0.3, max_tokens=150 ) content = resp.choices[0].message.content.strip() # تنظيف الـ JSON import re as _re m = _re.search(r'\[.*?\]', content, _re.DOTALL) if m: import json as _json suggestions = _json.loads(m.group()) return suggestions[:3] except Exception: pass # اقتراحات افتراضية إذا فشل الـ LLM if lang == "ar": return [ {"icon": "📄", "title": "وثائق ملف الترشح", "desc": "سأعطيك القائمة الكاملة للوثائق المطلوبة", "question": "ما هي وثائق ملف الترشح؟"}, {"icon": "📅", "title": "مواعيد المناظرة", "desc": "سأخبرك بالمواعيد الرسمية لفتح المناظرات", "question": "متى تفتح المناظرات القادمة؟"}, {"icon": "✅", "title": "هل أنا مؤهل؟", "desc": "سأقيّم ملفك في دقيقة واحدة", "question": "هل أنا مؤهل للترشح؟"} ] else: return [ {"icon": "📄", "title": "Documents du dossier", "desc": "Je vous donne la liste complète des documents", "question": "Quels documents pour le dossier?"}, {"icon": "📅", "title": "Dates des concours", "desc": "Je vous informe des dates officielles d'ouverture", "question": "Quand ouvrent les prochains concours?"}, {"icon": "✅", "title": "Suis-je éligible?", "desc": "J'évalue votre profil en une minute", "question": "Suis-je éligible au concours?"} ]