ENA-Chatbot / agentic_rag.py
Ines1994's picture
Upload agentic_rag.py
7fb82b0 verified
"""
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?"}
]