Spaces:
Sleeping
Sleeping
| """ | |
| 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?"} | |
| ] |