Spaces:
Runtime error
Runtime error
Update main.py
Browse files
main.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
"""
|
| 2 |
-
Saudi Legal AI API — v4.
|
| 3 |
-
FastAPI + RAG + Groq + Gemini
|
| 4 |
"""
|
| 5 |
import os, gc, re, time, logging
|
| 6 |
from collections import deque, defaultdict
|
| 7 |
from datetime import datetime
|
| 8 |
from contextlib import asynccontextmanager
|
| 9 |
from dotenv import load_dotenv
|
|
|
|
| 10 |
|
| 11 |
from fastapi import FastAPI, HTTPException, Request
|
| 12 |
from fastapi.middleware.cors import CORSMiddleware
|
|
@@ -31,67 +32,68 @@ logger = logging.getLogger(__name__)
|
|
| 31 |
# ══════════════════════════════════════════════════════════
|
| 32 |
# Config
|
| 33 |
# ══════════════════════════════════════════════════════════
|
| 34 |
-
GROQ_API_KEY
|
| 35 |
-
HF_TOKEN
|
| 36 |
-
GEMINI_KEY
|
| 37 |
-
HF_REPO_ID
|
| 38 |
-
CHROMA_PATH
|
| 39 |
|
| 40 |
# ══════════════════════════════════════════════════════════
|
| 41 |
# Global State
|
| 42 |
# ══════════════════════════════════════════════════════════
|
| 43 |
-
vectorstore
|
| 44 |
-
bm25_index
|
| 45 |
-
bm25_texts
|
| 46 |
-
bm25_metadatas
|
| 47 |
-
embeddings
|
| 48 |
-
ACTIVE_MODELS
|
| 49 |
-
GEMINI_AVAILABLE
|
| 50 |
-
|
| 51 |
-
groq_client
|
| 52 |
-
request_log
|
| 53 |
-
stats
|
| 54 |
-
active_ips
|
| 55 |
-
_expansion_cache
|
| 56 |
-
_rewrite_cache
|
| 57 |
_translation_cache = {}
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
# ══════════════════════════════════════════════════════════
|
| 60 |
# Extra Laws
|
| 61 |
# ══════════════════════════════════════════════════════════
|
| 62 |
EXTRA_LAWS = [
|
| 63 |
-
|
| 64 |
-
{'text':'المادة الثالثة والثمانون: عند انتهاء عقد العمل يستحق العامل مكافأة عن مدة خدمته تحسب على أساس أجر نصف شهر عن كل سنة من السنوات الخمس الأولى، وأجر شهر عن كل سنة بعد ذلك. وتحسب المكافأة على أساس آخر أجر تقاضاه العامل. ويستحق العامل مكافأة عن كسور السنة بنسبة ما قضاه منها في العمل.',
|
| 65 |
'article_number':'المادة الثالثة والثمانون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 66 |
-
{'text':'المادة الثامنة والثمانون: إذا أنهى صاحب العمل عقد العمل غير المحدد المدة دون سبب مشروع وجب عليه أن يدفع للعامل تعويضاً عن الفصل التعسفي يعادل أجر خمسة عشر يوماً عن كل سنة من سنوات خدمة العامل، ولا ي
|
| 67 |
'article_number':'المادة الثامنة والثمانون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 68 |
-
{'text':'المادة التاسعة والثمانون: لا يجوز لصاحب العمل فصل العامل بسبب تقدمه بشكوى
|
| 69 |
'article_number':'المادة التاسعة والثمانون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 70 |
-
{'text':'المادة الخامسة والستون: لا يجوز تشغيل العامل تشغيلاً فعلياً أكثر من ثماني ساعات في اليوم أو ثماني وأربعين ساعة في الأسبوع.
|
| 71 |
'article_number':'المادة الخامسة والستون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 72 |
{'text':'المادة الثامنة والستون: العمل الإضافي هو كل عمل يؤديه العامل زيادة على ساعات العمل النظامية. ويستحق العامل عن ساعات العمل الإضافية أجراً إضافياً لا يقل عن أجره الأصلي مضافاً إليه خمسون بالمئة.',
|
| 73 |
'article_number':'المادة الثامنة والستون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 74 |
-
{'text':'المادة الثالثة والستون: مدة إجازة العامل السنوية واحد وعشرون يوماً تزاد إلى ثلاثين يوماً إذا أمضى العامل خمس سنوات متواصلة
|
| 75 |
'article_number':'المادة الثالثة والستون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 76 |
-
{'text':'المادة الثالثة والثلاثون: تستحق العاملة إجازة وضع بأجر كامل مدتها عشرة أسابيع. ويُحظر تشغيل المرأة في الأسابيع الستة التالية للوضع
|
| 77 |
'article_number':'المادة الثالثة والثلاثون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 78 |
-
{'text':'المادة الخامسة والسبعون: إذا أراد أحد طرفي عقد العمل غير المحدد المدة إنهاءه وجب عليه إخطار الطرف الآخر كتابةً قبل الإنهاء ب
|
| 79 |
'article_number':'المادة الخامسة والسبعون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 80 |
-
|
| 81 |
-
{'text':'المادة الثانية والثلاثون: يشترط فيمن يُعيَّن في وظائف القضاء أن يكون سعودي الجنسية بالأصل، وأن يكون مسلماً، وأن يكون حاصلاً على شهادة الأهلية في الشريعة الإسلامية من المعهد العالي للقضاء أو ما يعادلها، وأن ينجح في الامتحان المقرر، وأن يكون حسن السيرة والسلوك، وألا يكون محكوماً عليه في جريمة مخلة بالشرف.',
|
| 82 |
'article_number':'المادة الثانية والثلاثون','law_name':'نظام القضاء','law_type':'نظام','source':'boe.gov.sa'},
|
| 83 |
-
{'text':'المادة الحادية والثلاثون: يُعيَّن القضاة بمرسوم ملكي
|
| 84 |
'article_number':'المادة الحادية والثلاثون','law_name':'نظام القضاء','law_type':'نظام','source':'boe.gov.sa'},
|
| 85 |
-
|
| 86 |
-
{'text':'المادة الثالثة: يشترط فيمن يزاول مهنة المحاماة أن يكون: أولاً سعودي الجنسية. ثانياً حاصلاً على شهادة البكالوريوس في تخصص الشريعة الإسلامية أو الأنظمة أو ما يعادلها. ثالثاً اسمه مقيداً في جدول المحامين الممارسين. رابعاً ألا يكون محكوماً عليه في جريمة مخلة بالشرف.',
|
| 87 |
'article_number':'المادة الثالثة','law_name':'نظام المحاماة','law_type':'نظام','source':'boe.gov.sa'},
|
| 88 |
-
|
| 89 |
-
{'text':'المادة الثالثة: يعاقب بالسجن مدة لا تزيد على سنة وبغرامة لا تزيد على خمسمائة ألف ريال كل شخص يرتكب جريمة الدخول غير المشروع إلى موقع إلكتروني أو التشهير بالآخرين عبر وسائل التقنية المعلوماتية.',
|
| 90 |
'article_number':'المادة الثالثة','law_name':'نظام مكافحة الجرائم المعلوماتية','law_type':'نظام','source':'boe.gov.sa'},
|
| 91 |
{'text':'المادة السادسة: يعاقب بالسجن مدة لا تزيد على خمس سنوات وبغرامة لا تزيد على ثلاثة ملايين ريال كل شخص يرتكب جريمة اختراق الأنظمة الحاسوبية الحكومية.',
|
| 92 |
'article_number':'المادة السادسة','law_name':'نظام مكافحة الجرائم المعلوماتية','law_type':'نظام','source':'boe.gov.sa'},
|
| 93 |
-
|
| 94 |
-
{'text':'المادة الرابعة: لا يجوز معالجة البيانات الشخصية إلا لتحقيق الغرض المشروع والمحدد الذي جُمعت من أجله. ويجب الحصول على موافقة صريحة من صاحب البيانات قبل معالجتها.',
|
| 95 |
'article_number':'المادة الرابعة','law_name':'نظام حماية البيانات الشخصية','law_type':'نظام','source':'boe.gov.sa'},
|
| 96 |
{'text':'المادة التاسعة والعشرون: يعاقب على الإفصاح عن البيانات الشخصية دون وجه حق بالسجن مدة لا تزيد على سنتين وبغرامة لا تزيد على ثلاثة ملايين ريال.',
|
| 97 |
'article_number':'المادة التاسعة والعشرون','law_name':'نظام حماية البيانات الشخصية','law_type':'نظام','source':'boe.gov.sa'},
|
|
@@ -119,16 +121,12 @@ NUMBERS_AR = {
|
|
| 119 |
}
|
| 120 |
|
| 121 |
ENGLISH_TO_ARABIC = {
|
| 122 |
-
# ── جمل كاملة (ال��طول أولاً) ──
|
| 123 |
'conditions for lawyer license': 'شروط مزاولة مهنة المحاماة',
|
| 124 |
'conditons for laywer license': 'شروط مزاولة مهنة المحاماة',
|
| 125 |
'conditions for lawyer': 'شروط مزاولة مهنة المحاماة',
|
| 126 |
-
'conditons for laywer': 'شروط مزاولة مهنة المحاماة',
|
| 127 |
'what are the conditions': 'ما هي شروط',
|
| 128 |
-
'what are conditons': 'ما هي شروط',
|
| 129 |
'what is the penalty': 'ما هي عقوبة',
|
| 130 |
'what is the penaly': 'ما هي عقوبة',
|
| 131 |
-
'what is the punishment': 'ما هي عقوبة',
|
| 132 |
'what are my rights': 'ما هي حقوقي',
|
| 133 |
'how to calculate': 'كيف تحسب',
|
| 134 |
'how is calculated': 'كيف تحسب',
|
|
@@ -137,71 +135,56 @@ ENGLISH_TO_ARABIC = {
|
|
| 137 |
'i was fired': 'تم فصلي',
|
| 138 |
'i got fired': 'تم فصلي',
|
| 139 |
'am i entitled': 'هل يحق لي',
|
| 140 |
-
'am i eligible': 'هل يحق لي',
|
| 141 |
'end of service': 'مكافأة نهاية الخدمة',
|
| 142 |
'end of servise': 'مكافأة نهاية الخدمة',
|
| 143 |
-
'end of serivce': 'مكافأة نهاية الخدمة',
|
| 144 |
'money laundering': 'غسل الأموال',
|
| 145 |
'money laudering': 'غسل الأموال',
|
| 146 |
'mony laundering': 'غسل الأموال',
|
| 147 |
-
'money laundring': 'غسل الأموال',
|
| 148 |
'moeny laundering': 'غسل الأموال',
|
| 149 |
'wrongful termination': 'فصل تعسفي',
|
| 150 |
-
'wrongful terminaton': 'فصل تعسفي',
|
| 151 |
'unfair dismissal': 'فصل تعسفي',
|
| 152 |
-
'wrongful dismissal': 'فصل تعسفي',
|
| 153 |
'working hours': 'ساعات العمل',
|
| 154 |
-
'work hours': 'ساعات العمل',
|
| 155 |
'annual leave': 'الإجازة السنوية',
|
| 156 |
'maternity leave': 'إجازة الأمومة',
|
| 157 |
'notice period': 'مهلة الإشعار',
|
| 158 |
'data protection': 'حماية البيانات الشخصية',
|
| 159 |
-
'personal data': 'البيانات الشخصية',
|
| 160 |
'labor law': 'نظام العمل',
|
| 161 |
'labour law': 'نظام العمل',
|
| 162 |
'my employer': 'صاحب العمل',
|
| 163 |
-
'my company': 'صاحب العمل',
|
| 164 |
'my rights': 'حقوقي',
|
| 165 |
'if fired': 'عند الفصل',
|
| 166 |
-
|
| 167 |
-
'
|
| 168 |
-
'
|
| 169 |
-
'
|
| 170 |
-
'
|
| 171 |
-
'
|
| 172 |
-
'
|
| 173 |
-
'
|
| 174 |
-
'
|
| 175 |
-
'
|
| 176 |
-
'
|
| 177 |
-
'
|
| 178 |
-
'
|
| 179 |
-
'
|
| 180 |
-
'
|
| 181 |
-
'
|
| 182 |
-
'
|
| 183 |
-
'
|
| 184 |
-
'
|
| 185 |
-
'
|
| 186 |
-
'
|
| 187 |
-
'
|
| 188 |
-
'
|
| 189 |
-
'
|
| 190 |
-
'
|
| 191 |
-
'
|
| 192 |
-
'
|
| 193 |
-
'
|
| 194 |
-
'
|
| 195 |
-
'
|
| 196 |
-
'
|
| 197 |
-
'
|
| 198 |
-
'probation': 'فترة التجربة',
|
| 199 |
-
'evidence': 'إثبات',
|
| 200 |
-
'lawsuit': 'دعوى قضائية',
|
| 201 |
-
'conditions': 'شروط',
|
| 202 |
-
'conditons': 'شروط',
|
| 203 |
-
'requirements': 'شروط',
|
| 204 |
-
'i worked': 'عملت',
|
| 205 |
}
|
| 206 |
|
| 207 |
LAW_KEYWORDS = {
|
|
@@ -235,7 +218,6 @@ LAW_KEYWORDS = {
|
|
| 235 |
'راتب':'نظام العمل','أجر':'نظام العمل',
|
| 236 |
'استقالة':'نظام العمل','إشعار':'نظام العمل',
|
| 237 |
'حقوقي':'نظام العمل','مستحقاتي':'نظام العمل',
|
| 238 |
-
'كيفية حساب بدل':'نظام العمل',
|
| 239 |
'لقد تم فصلي':'نظام العمل','ما هي حقوقي':'نظام العمل',
|
| 240 |
'جرائم معلوماتية':'نظام مكافحة الجرائم المعلوماتية',
|
| 241 |
'اختراق':'نظام مكافحة الجرائم المعلوماتية',
|
|
@@ -256,7 +238,7 @@ LEGAL_KEYWORDS = [
|
|
| 256 |
|
| 257 |
PRACTICAL_PATTERNS = [
|
| 258 |
'أنا موظف','أنا عامل','اشتغلت','فصلوني','هل لي',
|
| 259 |
-
'هل يحق','هل أستحق','صاحب العمل',
|
| 260 |
'طردوني','حقي','حقوقي','مستحقاتي',
|
| 261 |
'تم فصلي','فُصلت','عملت',
|
| 262 |
]
|
|
@@ -273,9 +255,8 @@ QUERY_EXPANSION = {
|
|
| 273 |
'شروط اتفاق التحكيم': 'اتفاق التحكيم مكتوب باطل',
|
| 274 |
'إجراءات تسجيل العقار': 'طلب القيد إدارة التسجيل العقاري',
|
| 275 |
'شروط تعيين القاضي': 'يشترط فيمن يُعيَّن في وظائف القضاء أن يكون سعودي الجنسية بالأصل',
|
| 276 |
-
'متطلبات تعيين القضاة': 'يشترط فيمن يُعيَّن في وظائف القضاء سعودي شريعة إسلامية',
|
| 277 |
'شروط ترخيص المحاماة': 'يشترط فيمن يزاول مهنة المحاماة أن يكون سعودي الجنسية مقيداً في جدول المحامين',
|
| 278 |
-
'ترخيص المحاماة': 'يشترط فيمن يزاول مهنة المحاماة سعودي الجنسية شهادة شريعة',
|
| 279 |
'مكافأة نهاية الخدمة': 'يستحق العامل مكافأة عن مدة خدمته نصف شهر سنة',
|
| 280 |
'هل لي مكافأة': 'يستحق العامل مكافأة نهاية الخدمة',
|
| 281 |
'اشتغلت': 'يستحق العامل مكافأة عن مدة خدمته',
|
|
@@ -289,7 +270,6 @@ QUERY_EXPANSION = {
|
|
| 289 |
'فُصلت': 'إنهاء عقد العمل تعسفي تعويض فصل',
|
| 290 |
'حقوقي': 'حقوق العامل يستحق مكافأة تعويض نظام العمل',
|
| 291 |
'مستحقاتي': 'يستحق العامل مكافأة نهاية الخدمة تعويض',
|
| 292 |
-
'كيفية حساب بدل نهاية': 'مكافأة نهاية الخدمة يستحق العامل نصف شهر سنة',
|
| 293 |
'عقوبة غسيل الأموال': 'يعاقب على جريمة غسل الأموال سجن غرامة',
|
| 294 |
}
|
| 295 |
|
|
@@ -378,12 +358,12 @@ async def lifespan(app: FastAPI):
|
|
| 378 |
|
| 379 |
async def startup():
|
| 380 |
global vectorstore, bm25_index, bm25_texts, bm25_metadatas
|
| 381 |
-
global embeddings, ACTIVE_MODELS, GEMINI_AVAILABLE,
|
| 382 |
|
| 383 |
-
logger.info("Starting Saudi Legal AI v4...")
|
| 384 |
|
|
|
|
| 385 |
groq_client = Groq(api_key=GROQ_API_KEY)
|
| 386 |
-
|
| 387 |
for m in [
|
| 388 |
{'model': 'llama-3.3-70b-versatile', 'name': 'Groq llama-3.3'},
|
| 389 |
{'model': 'llama-3.1-8b-instant', 'name': 'Groq llama-3.1-8b'},
|
|
@@ -397,22 +377,24 @@ async def startup():
|
|
| 397 |
except Exception as e:
|
| 398 |
logger.warning(f"❌ {m['name']}: {str(e)[:30]}")
|
| 399 |
|
|
|
|
| 400 |
if GEMINI_KEY:
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
|
|
|
| 410 |
logger.info("Loading embeddings...")
|
| 411 |
embeddings = SentenceTransformerEmbeddings(
|
| 412 |
model_name='sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
|
| 413 |
|
|
|
|
| 414 |
chroma_client_persist = chromadb.PersistentClient(path=CHROMA_PATH)
|
| 415 |
-
|
| 416 |
try:
|
| 417 |
collection = chroma_client_persist.get_collection('saudi_legal_v4')
|
| 418 |
if collection.count() > 100:
|
|
@@ -440,32 +422,28 @@ async def startup():
|
|
| 440 |
})
|
| 441 |
for item in dataset if len(item.get('text','')) > 30
|
| 442 |
]
|
| 443 |
-
|
| 444 |
for a in EXTRA_LAWS:
|
| 445 |
docs.append(Document(page_content=a['text'], metadata={
|
| 446 |
-
'article_number': a['article_number'],
|
| 447 |
-
'
|
| 448 |
-
'law_type': a['law_type'],
|
| 449 |
-
'source': a['source'],
|
| 450 |
}))
|
| 451 |
|
| 452 |
splitter = RecursiveCharacterTextSplitter(chunk_size=1500, chunk_overlap=200)
|
| 453 |
chunks = splitter.split_documents(docs)
|
| 454 |
-
|
| 455 |
vectorstore = Chroma.from_documents(
|
| 456 |
documents=chunks, embedding=embeddings,
|
| 457 |
client=chroma_client_persist, collection_name='saudi_legal_v4')
|
| 458 |
logger.info(f"✅ ChromaDB created: {vectorstore._collection.count()} chunks")
|
| 459 |
|
|
|
|
| 460 |
stop_words = {'من','في','على','إلى','عن','مع','هي','هو','ما','لا','أن','إن'}
|
| 461 |
def tokenize(text): return [w for w in text.split() if len(w) > 2 and w not in stop_words]
|
| 462 |
-
|
| 463 |
all_chunks = vectorstore.get()
|
| 464 |
bm25_texts = all_chunks['documents']
|
| 465 |
bm25_metadatas = all_chunks['metadatas']
|
| 466 |
bm25_index = BM25Okapi([tokenize(t) for t in bm25_texts])
|
| 467 |
logger.info(f"✅ BM25: {len(bm25_texts)} docs")
|
| 468 |
-
logger.info("✅ Saudi Legal AI v4 Ready!")
|
| 469 |
|
| 470 |
# ══════════════════════════════════════════════════════════
|
| 471 |
# Pipeline Functions
|
|
@@ -526,14 +504,12 @@ def fuzzy_correct_word(word, threshold=88):
|
|
| 526 |
|
| 527 |
def fuzzy_normalize(question):
|
| 528 |
clean = re.sub(r'[؟?،,.]', '', question)
|
| 529 |
-
words,
|
| 530 |
for word in words:
|
| 531 |
if len(word) < 5 or word.isdigit() or not any('\u0600' <= c <= '\u06ff' for c in word):
|
| 532 |
result.append(word); continue
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
result.append(corrected)
|
| 536 |
-
return ' '.join(result), corrections
|
| 537 |
|
| 538 |
def normalize_question(question):
|
| 539 |
log = []
|
|
@@ -545,10 +521,8 @@ def normalize_question(question):
|
|
| 545 |
if translated != question:
|
| 546 |
log.append(f'🌐 {translated}')
|
| 547 |
question = translated
|
| 548 |
-
corrected
|
| 549 |
-
if
|
| 550 |
-
log.append(f'✏️ {" | ".join(corrections)}')
|
| 551 |
-
question = corrected
|
| 552 |
for col, formal in COLLOQUIAL.items():
|
| 553 |
question = re.sub(rf'\b{col}\b', formal, question, flags=re.IGNORECASE)
|
| 554 |
question = re.sub(r'[؟?]+', '؟', question).strip()
|
|
@@ -599,7 +573,7 @@ def expand_query(question):
|
|
| 599 |
elif any(w in question for w in ['حقوق','حقوقي','مستحقات']):
|
| 600 |
result.append('حقوق العامل يستحق مكافأة تعويض نظام العمل')
|
| 601 |
cleaned = question
|
| 602 |
-
for prefix in ['ما هي ','ما هو ','هل ','متى ','كيف ']: cleaned = cleaned.replace(prefix,
|
| 603 |
cleaned = cleaned.replace('؟','').strip()
|
| 604 |
if cleaned != question and len(cleaned) > 5: result.append(cleaned)
|
| 605 |
words = [w for w in question.split() if len(w) > 3 and w not in
|
|
@@ -679,6 +653,9 @@ def post_process(answer, docs):
|
|
| 679 |
or any(s in l for s in ['📋','✅','📝','⚠️','•','-','─'])]
|
| 680 |
return '\n'.join(clean).strip()
|
| 681 |
|
|
|
|
|
|
|
|
|
|
| 682 |
def _call_groq(messages):
|
| 683 |
for m in sorted(ACTIVE_MODELS, key=lambda x: 0 if '70b' in x['model'] else 1):
|
| 684 |
try:
|
|
@@ -693,20 +670,24 @@ def _call_groq(messages):
|
|
| 693 |
raise e
|
| 694 |
return None, None
|
| 695 |
|
| 696 |
-
def
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 710 |
|
| 711 |
def generate_with_fallback(messages):
|
| 712 |
if rate_limiter.can_use('groq'):
|
|
@@ -718,14 +699,21 @@ def generate_with_fallback(messages):
|
|
| 718 |
except Exception as e:
|
| 719 |
if '429' in str(e) or 'rate' in str(e).lower():
|
| 720 |
for _ in range(28): rate_limiter.requests['groq'].append(time.time())
|
| 721 |
-
|
|
|
|
|
|
|
|
|
|
| 722 |
if result: return result, model
|
|
|
|
| 723 |
wait = rate_limiter.wait_time('groq')
|
| 724 |
if wait > 0:
|
| 725 |
time.sleep(min(wait + 1, 15))
|
| 726 |
return generate_with_fallback(messages)
|
| 727 |
return None, None
|
| 728 |
|
|
|
|
|
|
|
|
|
|
| 729 |
def ask_legal_core(question: str) -> dict:
|
| 730 |
original = question
|
| 731 |
question, norm_log = normalize_question(question)
|
|
@@ -758,8 +746,7 @@ def ask_legal_core(question: str) -> dict:
|
|
| 758 |
docs = vectorstore.similarity_search(q, k=k_per_query)
|
| 759 |
all_docs.extend(docs)
|
| 760 |
|
| 761 |
-
bm25_docs
|
| 762 |
-
keyword_docs = []
|
| 763 |
for q in queries: bm25_docs.extend(bm25_search(q, k=5, target_law=target_law))
|
| 764 |
|
| 765 |
if target_law:
|
|
@@ -817,7 +804,7 @@ def ask_legal_core(question: str) -> dict:
|
|
| 817 |
app = FastAPI(
|
| 818 |
title='Saudi Legal AI',
|
| 819 |
description='نظام الذكاء الاصطناعي للقانون السعودي — وزارة العدل',
|
| 820 |
-
version='4.
|
| 821 |
lifespan=lifespan
|
| 822 |
)
|
| 823 |
|
|
@@ -846,15 +833,21 @@ class QuestionResponse(BaseModel):
|
|
| 846 |
|
| 847 |
@app.get('/')
|
| 848 |
def root():
|
| 849 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 850 |
|
| 851 |
@app.get('/health')
|
| 852 |
def health():
|
| 853 |
return {
|
| 854 |
-
'status':
|
| 855 |
-
'chunks':
|
| 856 |
-
'
|
| 857 |
-
'
|
|
|
|
| 858 |
}
|
| 859 |
|
| 860 |
@app.post('/ask', response_model=QuestionResponse)
|
|
@@ -865,7 +858,6 @@ async def ask(req: QuestionRequest, request: Request):
|
|
| 865 |
raise HTTPException(status_code=400, detail='السؤال طويل جداً')
|
| 866 |
|
| 867 |
ip = request.headers.get('X-Forwarded-For','unknown').split(',')[0].strip()
|
| 868 |
-
|
| 869 |
if not check_rate_limit(ip):
|
| 870 |
raise HTTPException(status_code=429, detail='حاول مرة أخرى بعد دقيقة')
|
| 871 |
|
|
@@ -880,9 +872,11 @@ async def ask(req: QuestionRequest, request: Request):
|
|
| 880 |
ms = int((time.time() - t0) * 1000)
|
| 881 |
status = 'blocked' if result['model'] in ['out_of_scope','quality_check'] else 'success'
|
| 882 |
|
| 883 |
-
request_log.appendleft({
|
| 884 |
-
|
| 885 |
-
|
|
|
|
|
|
|
| 886 |
stats['total'] += 1
|
| 887 |
stats[status if status in stats else 'errors'] += 1
|
| 888 |
active_ips[ip] = active_ips.get(ip, 0) + 1
|
|
@@ -898,9 +892,11 @@ async def ask(req: QuestionRequest, request: Request):
|
|
| 898 |
@app.get('/stats')
|
| 899 |
def get_stats():
|
| 900 |
return {
|
| 901 |
-
'total':
|
| 902 |
-
'
|
| 903 |
-
'
|
|
|
|
|
|
|
| 904 |
}
|
| 905 |
|
| 906 |
@app.get('/laws')
|
|
|
|
| 1 |
"""
|
| 2 |
+
Saudi Legal AI API — v4.1
|
| 3 |
+
FastAPI + RAG + Groq + Gemini Multi-Model
|
| 4 |
"""
|
| 5 |
import os, gc, re, time, logging
|
| 6 |
from collections import deque, defaultdict
|
| 7 |
from datetime import datetime
|
| 8 |
from contextlib import asynccontextmanager
|
| 9 |
from dotenv import load_dotenv
|
| 10 |
+
from typing import Dict
|
| 11 |
|
| 12 |
from fastapi import FastAPI, HTTPException, Request
|
| 13 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 32 |
# ══════════════════════════════════════════════════════════
|
| 33 |
# Config
|
| 34 |
# ══════════════════════════════════════════════════════════
|
| 35 |
+
GROQ_API_KEY = os.getenv('GROQ_API_KEY', '')
|
| 36 |
+
HF_TOKEN = os.getenv('HF_TOKEN', '')
|
| 37 |
+
GEMINI_KEY = os.getenv('GEMINI_API_KEY', '')
|
| 38 |
+
HF_REPO_ID = os.getenv('HF_REPO_ID', 'WafaaFraih/saudi-legal-moj')
|
| 39 |
+
CHROMA_PATH = os.getenv('CHROMA_PATH', './chroma_db')
|
| 40 |
|
| 41 |
# ══════════════════════════════════════════════════════════
|
| 42 |
# Global State
|
| 43 |
# ══════════════════════════════════════════════════════════
|
| 44 |
+
vectorstore = None
|
| 45 |
+
bm25_index = None
|
| 46 |
+
bm25_texts = []
|
| 47 |
+
bm25_metadatas = []
|
| 48 |
+
embeddings = None
|
| 49 |
+
ACTIVE_MODELS = []
|
| 50 |
+
GEMINI_AVAILABLE = False
|
| 51 |
+
gemini_clients: Dict = {}
|
| 52 |
+
groq_client = None
|
| 53 |
+
request_log = deque(maxlen=500)
|
| 54 |
+
stats = {'total': 0, 'success': 0, 'blocked': 0, 'errors': 0}
|
| 55 |
+
active_ips = {}
|
| 56 |
+
_expansion_cache = {}
|
| 57 |
+
_rewrite_cache = {}
|
| 58 |
_translation_cache = {}
|
| 59 |
|
| 60 |
+
GEMINI_MODELS = [
|
| 61 |
+
'models/gemini-2.5-flash',
|
| 62 |
+
'models/gemini-2.0-flash',
|
| 63 |
+
'models/gemma-3-27b-it',
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
# ══════════════════════════════════════════════════════════
|
| 67 |
# Extra Laws
|
| 68 |
# ══════════════════════════════════════════════════════════
|
| 69 |
EXTRA_LAWS = [
|
| 70 |
+
{'text':'المادة الثالثة والثمانون: عند انتهاء عقد العمل يستحق العامل مكافأة عن مدة خدمته تحسب على أساس أجر نصف شهر عن كل سنة من السنوات الخمس الأولى، وأجر شهر عن كل سنة بعد ذلك. وتحسب المكافأة على أساس آخر أجر تقاضاه العامل.',
|
|
|
|
| 71 |
'article_number':'المادة الثالثة والثمانون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 72 |
+
{'text':'المادة الثامنة والثمانون: إذا أنهى صاحب العمل عقد العمل غير المحدد المدة دون سبب مشروع وجب عليه أن يدفع للعامل تعويضاً عن الفصل التعسفي يعادل أجر خمسة عشر يوماً عن كل سنة من سنوات خدمة العامل، ولا ي��ل التعويض عن أجر شهرين.',
|
| 73 |
'article_number':'المادة الثامنة والثمانون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 74 |
+
{'text':'المادة التاسعة والثمانون: لا يجوز لصاحب العمل فصل العامل بسبب تقدمه بشكوى. وإذا أثبت العامل أن الفصل كان تعسفياً وجب على صاحب العمل دفع تعويض عادل إضافة إلى مكافأة نهاية الخدمة وسائر مستحقاته.',
|
| 75 |
'article_number':'المادة التاسعة والثمانون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 76 |
+
{'text':'المادة الخامسة والستون: لا يجوز تشغيل العامل تشغيلاً فعلياً أكثر من ثماني ساعات في اليوم أو ثماني وأربعين ساعة في الأسبوع.',
|
| 77 |
'article_number':'المادة الخامسة والستون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 78 |
{'text':'المادة الثامنة والستون: العمل الإضافي هو كل عمل يؤديه العامل زيادة على ساعات العمل النظامية. ويستحق العامل عن ساعات العمل الإضافية أجراً إضافياً لا يقل عن أجره الأصلي مضافاً إليه خمسون بالمئة.',
|
| 79 |
'article_number':'المادة الثامنة والستون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 80 |
+
{'text':'المادة الثالثة والستون: مدة إجازة العامل السنوية واحد وعشرون يوماً تزاد إلى ثلاثين يوماً إذا أمضى العامل خمس سنوات متواصلة.',
|
| 81 |
'article_number':'المادة الثالثة والستون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 82 |
+
{'text':'المادة الثالثة والثلاثون: تستحق العاملة إجازة وضع بأجر كامل مدتها عشرة أسابيع. ويُحظر تشغيل المرأة في الأسابيع الستة التالية للوضع.',
|
| 83 |
'article_number':'المادة الثالثة والثلاثون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 84 |
+
{'text':'المادة الخامسة والسبعون: إذا أراد أحد طرفي عقد العمل غير المحدد المدة إنهاءه وجب عليه إخطار الطرف الآخر كتابةً قبل الإنهاء بستين يوماً إذا كان أجر العامل يدفع شهرياً.',
|
| 85 |
'article_number':'المادة الخامسة والسبعون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 86 |
+
{'text':'المادة الثانية والثلاثون: يشترط فيمن يُعيَّن في وظائف القضاء أن يكون سعودي الجنسية بالأصل، وأن يكون مسلماً، وأن يكون حاصلاً على شهادة الأهلية في الشريعة الإسلامية أو ما يعادلها، وأن ينجح في الامتحان المقرر.',
|
|
|
|
| 87 |
'article_number':'المادة الثانية والثلاثون','law_name':'نظام القضاء','law_type':'نظام','source':'boe.gov.sa'},
|
| 88 |
+
{'text':'المادة الحادية والثلاثون: يُعيَّن القضاة بمرسوم ملكي. ويشترط في القاضي: أن يكون سعودي الجنسية، متمتعاً بالأهلية الكاملة، حاصلاً على مؤهل شرعي، ناجحاً في الاختبار.',
|
| 89 |
'article_number':'المادة الحادية والثلاثون','law_name':'نظام القضاء','law_type':'نظام','source':'boe.gov.sa'},
|
| 90 |
+
{'text':'المادة الثالثة: يشترط فيمن يزاول مهنة المحاماة أن يكون: سعودي الجنسية، حاصلاً على شهادة البكالوريوس في الشريعة الإسلامية أو الأنظمة، اسمه مقيداً في جدول المحامين الممارسين.',
|
|
|
|
| 91 |
'article_number':'المادة الثالثة','law_name':'نظام المحاماة','law_type':'نظام','source':'boe.gov.sa'},
|
| 92 |
+
{'text':'المادة الثالثة: يعاقب بالسجن مدة لا تزيد على سنة وبغرامة لا تزيد على خمسمائة ألف ريال كل شخص يرتكب جريمة الدخول غير المشروع إلى موقع إلكتروني أو التشهير عبر التقنية.',
|
|
|
|
| 93 |
'article_number':'المادة الثالثة','law_name':'نظام مكافحة الجرائم المعلوماتية','law_type':'نظام','source':'boe.gov.sa'},
|
| 94 |
{'text':'المادة السادسة: يعاقب بالسجن مدة لا تزيد على خمس سنوات وبغرامة لا تزيد على ثلاثة ملايين ريال كل شخص يرتكب جريمة اختراق الأنظمة الحاسوبية الحكومية.',
|
| 95 |
'article_number':'المادة السادسة','law_name':'نظام مكافحة الجرائم المعلوماتية','law_type':'نظام','source':'boe.gov.sa'},
|
| 96 |
+
{'text':'المادة الرابعة: لا يجوز معالجة البيانات الشخصية إلا لتحقيق الغرض المشروع الذي جُمعت من أجله مع الحصول على موافقة صريحة.',
|
|
|
|
| 97 |
'article_number':'المادة الرابعة','law_name':'نظام حماية البيانات الشخصية','law_type':'نظام','source':'boe.gov.sa'},
|
| 98 |
{'text':'المادة التاسعة والعشرون: يعاقب على الإفصاح عن البيانات الشخصية دون وجه حق بالسجن مدة لا تزيد على سنتين وبغرامة لا تزيد على ثلاثة ملايين ريال.',
|
| 99 |
'article_number':'المادة التاسعة والعشرون','law_name':'نظام حماية البيانات الشخصية','law_type':'نظام','source':'boe.gov.sa'},
|
|
|
|
| 121 |
}
|
| 122 |
|
| 123 |
ENGLISH_TO_ARABIC = {
|
|
|
|
| 124 |
'conditions for lawyer license': 'شروط مزاولة مهنة المحاماة',
|
| 125 |
'conditons for laywer license': 'شروط مزاولة مهنة المحاماة',
|
| 126 |
'conditions for lawyer': 'شروط مزاولة مهنة المحاماة',
|
|
|
|
| 127 |
'what are the conditions': 'ما هي شروط',
|
|
|
|
| 128 |
'what is the penalty': 'ما هي عقوبة',
|
| 129 |
'what is the penaly': 'ما هي عقوبة',
|
|
|
|
| 130 |
'what are my rights': 'ما هي حقوقي',
|
| 131 |
'how to calculate': 'كيف تحسب',
|
| 132 |
'how is calculated': 'كيف تحسب',
|
|
|
|
| 135 |
'i was fired': 'تم فصلي',
|
| 136 |
'i got fired': 'تم فصلي',
|
| 137 |
'am i entitled': 'هل يحق لي',
|
|
|
|
| 138 |
'end of service': 'مكافأة نهاية الخدمة',
|
| 139 |
'end of servise': 'مكافأة نهاية الخدمة',
|
|
|
|
| 140 |
'money laundering': 'غسل الأموال',
|
| 141 |
'money laudering': 'غسل الأموال',
|
| 142 |
'mony laundering': 'غسل الأموال',
|
|
|
|
| 143 |
'moeny laundering': 'غسل الأموال',
|
| 144 |
'wrongful termination': 'فصل تعسفي',
|
|
|
|
| 145 |
'unfair dismissal': 'فصل تعسفي',
|
|
|
|
| 146 |
'working hours': 'ساعات العمل',
|
|
|
|
| 147 |
'annual leave': 'الإجازة السنوية',
|
| 148 |
'maternity leave': 'إجازة الأمومة',
|
| 149 |
'notice period': 'مهلة الإشعار',
|
| 150 |
'data protection': 'حماية البيانات الشخصية',
|
|
|
|
| 151 |
'labor law': 'نظام العمل',
|
| 152 |
'labour law': 'نظام العمل',
|
| 153 |
'my employer': 'صاحب العمل',
|
|
|
|
| 154 |
'my rights': 'حقوقي',
|
| 155 |
'if fired': 'عند الفصل',
|
| 156 |
+
'gratuity': 'مكافأة نهاية الخدمة',
|
| 157 |
+
'wrongful': 'تعسفي',
|
| 158 |
+
'cybercrime': 'الجرائم المعلوماتية',
|
| 159 |
+
'arbitration': 'تحكيم',
|
| 160 |
+
'bankruptcy': 'إفلاس',
|
| 161 |
+
'divorce': 'طلاق',
|
| 162 |
+
'custody': 'حضانة',
|
| 163 |
+
'notary': 'كاتب عدل',
|
| 164 |
+
'lawyer': 'محامي',
|
| 165 |
+
'attorney': 'محامي',
|
| 166 |
+
'judge': 'قاضي',
|
| 167 |
+
'penalty': 'عقوبة',
|
| 168 |
+
'penaly': 'عقوبة',
|
| 169 |
+
'fine': 'غرامة',
|
| 170 |
+
'imprisonment':'سجن',
|
| 171 |
+
'rights': 'حقوق',
|
| 172 |
+
'rigths': 'حقوق',
|
| 173 |
+
'contract': 'عقد',
|
| 174 |
+
'salary': 'الأجر',
|
| 175 |
+
'employee': 'عامل',
|
| 176 |
+
'employer': 'صاحب عمل',
|
| 177 |
+
'terminated': 'فُصلت',
|
| 178 |
+
'dismissed': 'فُصلت',
|
| 179 |
+
'dissmised': 'فُصلت',
|
| 180 |
+
'overtime': 'العمل الإضافي',
|
| 181 |
+
'evidence': 'إثبات',
|
| 182 |
+
'conditions': 'شروط',
|
| 183 |
+
'requirements':'شروط',
|
| 184 |
+
'i worked': 'عملت',
|
| 185 |
+
'fraud': 'الاحتيال',
|
| 186 |
+
'bribery': 'الرشوة',
|
| 187 |
+
'hacking': 'الاختراق الإلكتروني',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
}
|
| 189 |
|
| 190 |
LAW_KEYWORDS = {
|
|
|
|
| 218 |
'راتب':'نظام العمل','أجر':'نظام العمل',
|
| 219 |
'استقالة':'نظام العمل','إشعار':'نظام العمل',
|
| 220 |
'حقوقي':'نظام العمل','مستحقاتي':'نظام العمل',
|
|
|
|
| 221 |
'لقد تم فصلي':'نظام العمل','ما هي حقوقي':'نظام العمل',
|
| 222 |
'جرائم معلوماتية':'نظام مكافحة الجرائم المعلوماتية',
|
| 223 |
'اختراق':'نظام مكافحة الجرائم المعلوماتية',
|
|
|
|
| 238 |
|
| 239 |
PRACTICAL_PATTERNS = [
|
| 240 |
'أنا موظف','أنا عامل','اشتغلت','فصلوني','هل لي',
|
| 241 |
+
'هل يحق','هل أستحق','صاحب العمل',
|
| 242 |
'طردوني','حقي','حقوقي','مستحقاتي',
|
| 243 |
'تم فصلي','فُصلت','عملت',
|
| 244 |
]
|
|
|
|
| 255 |
'شروط اتفاق التحكيم': 'اتفاق التحكيم مكتوب باطل',
|
| 256 |
'إجراءات تسجيل العقار': 'طلب القيد إدارة التسجيل العقاري',
|
| 257 |
'شروط تعيين القاضي': 'يشترط فيمن يُعيَّن في وظائف القضاء أن يكون سعودي الجنسية بالأصل',
|
| 258 |
+
'متطلبات تعيين القضاة': 'يشترط فيمن يُعيَّن في وظائف القضاء سعودي شريعة إسلامية امتحان',
|
| 259 |
'شروط ترخيص المحاماة': 'يشترط فيمن يزاول مهنة المحاماة أن يكون سعودي الجنسية مقيداً في جدول المحامين',
|
|
|
|
| 260 |
'مكافأة نهاية الخدمة': 'يستحق العامل مكافأة عن مدة خدمته نصف شهر سنة',
|
| 261 |
'هل لي مكافأة': 'يستحق العامل مكافأة نهاية الخدمة',
|
| 262 |
'اشتغلت': 'يستحق العامل مكافأة عن مدة خدمته',
|
|
|
|
| 270 |
'فُصلت': 'إنهاء عقد العمل تعسفي تعويض فصل',
|
| 271 |
'حقوقي': 'حقوق العامل يستحق مكافأة تعويض نظام العمل',
|
| 272 |
'مستحقاتي': 'يستحق العامل مكافأة نهاية الخدمة تعويض',
|
|
|
|
| 273 |
'عقوبة غسيل الأموال': 'يعاقب على جريمة غسل الأموال سجن غرامة',
|
| 274 |
}
|
| 275 |
|
|
|
|
| 358 |
|
| 359 |
async def startup():
|
| 360 |
global vectorstore, bm25_index, bm25_texts, bm25_metadatas
|
| 361 |
+
global embeddings, ACTIVE_MODELS, GEMINI_AVAILABLE, gemini_clients, groq_client
|
| 362 |
|
| 363 |
+
logger.info("Starting Saudi Legal AI v4.1...")
|
| 364 |
|
| 365 |
+
# ── Groq ─────────────────────────────────────────────
|
| 366 |
groq_client = Groq(api_key=GROQ_API_KEY)
|
|
|
|
| 367 |
for m in [
|
| 368 |
{'model': 'llama-3.3-70b-versatile', 'name': 'Groq llama-3.3'},
|
| 369 |
{'model': 'llama-3.1-8b-instant', 'name': 'Groq llama-3.1-8b'},
|
|
|
|
| 377 |
except Exception as e:
|
| 378 |
logger.warning(f"❌ {m['name']}: {str(e)[:30]}")
|
| 379 |
|
| 380 |
+
# ── Gemini Multi-Model ────────────────────────────────
|
| 381 |
if GEMINI_KEY:
|
| 382 |
+
genai.configure(api_key=GEMINI_KEY)
|
| 383 |
+
for model_name in GEMINI_MODELS:
|
| 384 |
+
try:
|
| 385 |
+
gemini_clients[model_name] = genai.GenerativeModel(model_name)
|
| 386 |
+
GEMINI_AVAILABLE = True
|
| 387 |
+
logger.info(f"✅ {model_name}")
|
| 388 |
+
except Exception as e:
|
| 389 |
+
logger.warning(f"❌ {model_name}: {str(e)[:40]}")
|
| 390 |
+
|
| 391 |
+
# ── Embeddings ────────────────────────────────────────
|
| 392 |
logger.info("Loading embeddings...")
|
| 393 |
embeddings = SentenceTransformerEmbeddings(
|
| 394 |
model_name='sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
|
| 395 |
|
| 396 |
+
# ── ChromaDB ──────────────────────────────────────────
|
| 397 |
chroma_client_persist = chromadb.PersistentClient(path=CHROMA_PATH)
|
|
|
|
| 398 |
try:
|
| 399 |
collection = chroma_client_persist.get_collection('saudi_legal_v4')
|
| 400 |
if collection.count() > 100:
|
|
|
|
| 422 |
})
|
| 423 |
for item in dataset if len(item.get('text','')) > 30
|
| 424 |
]
|
|
|
|
| 425 |
for a in EXTRA_LAWS:
|
| 426 |
docs.append(Document(page_content=a['text'], metadata={
|
| 427 |
+
'article_number': a['article_number'], 'law_name': a['law_name'],
|
| 428 |
+
'law_type': a['law_type'], 'source': a['source'],
|
|
|
|
|
|
|
| 429 |
}))
|
| 430 |
|
| 431 |
splitter = RecursiveCharacterTextSplitter(chunk_size=1500, chunk_overlap=200)
|
| 432 |
chunks = splitter.split_documents(docs)
|
|
|
|
| 433 |
vectorstore = Chroma.from_documents(
|
| 434 |
documents=chunks, embedding=embeddings,
|
| 435 |
client=chroma_client_persist, collection_name='saudi_legal_v4')
|
| 436 |
logger.info(f"✅ ChromaDB created: {vectorstore._collection.count()} chunks")
|
| 437 |
|
| 438 |
+
# ── BM25 ──────────────────────────────────────────────
|
| 439 |
stop_words = {'من','في','على','إلى','عن','مع','هي','هو','ما','لا','أن','إن'}
|
| 440 |
def tokenize(text): return [w for w in text.split() if len(w) > 2 and w not in stop_words]
|
|
|
|
| 441 |
all_chunks = vectorstore.get()
|
| 442 |
bm25_texts = all_chunks['documents']
|
| 443 |
bm25_metadatas = all_chunks['metadatas']
|
| 444 |
bm25_index = BM25Okapi([tokenize(t) for t in bm25_texts])
|
| 445 |
logger.info(f"✅ BM25: {len(bm25_texts)} docs")
|
| 446 |
+
logger.info("✅ Saudi Legal AI v4.1 Ready!")
|
| 447 |
|
| 448 |
# ══════════════════════════════════════════════════════════
|
| 449 |
# Pipeline Functions
|
|
|
|
| 504 |
|
| 505 |
def fuzzy_normalize(question):
|
| 506 |
clean = re.sub(r'[؟?،,.]', '', question)
|
| 507 |
+
words, result = clean.split(), []
|
| 508 |
for word in words:
|
| 509 |
if len(word) < 5 or word.isdigit() or not any('\u0600' <= c <= '\u06ff' for c in word):
|
| 510 |
result.append(word); continue
|
| 511 |
+
result.append(fuzzy_correct_word(word))
|
| 512 |
+
return ' '.join(result)
|
|
|
|
|
|
|
| 513 |
|
| 514 |
def normalize_question(question):
|
| 515 |
log = []
|
|
|
|
| 521 |
if translated != question:
|
| 522 |
log.append(f'🌐 {translated}')
|
| 523 |
question = translated
|
| 524 |
+
corrected = fuzzy_normalize(question)
|
| 525 |
+
if corrected != question: question = corrected
|
|
|
|
|
|
|
| 526 |
for col, formal in COLLOQUIAL.items():
|
| 527 |
question = re.sub(rf'\b{col}\b', formal, question, flags=re.IGNORECASE)
|
| 528 |
question = re.sub(r'[؟?]+', '؟', question).strip()
|
|
|
|
| 573 |
elif any(w in question for w in ['حقوق','حقوقي','مستحقات']):
|
| 574 |
result.append('حقوق العامل يستحق مكافأة تعويض نظام العمل')
|
| 575 |
cleaned = question
|
| 576 |
+
for prefix in ['ما هي ','ما هو ','هل ','متى ','كيف ']: cleaned = cleaned.replace(prefix,'')
|
| 577 |
cleaned = cleaned.replace('؟','').strip()
|
| 578 |
if cleaned != question and len(cleaned) > 5: result.append(cleaned)
|
| 579 |
words = [w for w in question.split() if len(w) > 3 and w not in
|
|
|
|
| 653 |
or any(s in l for s in ['📋','✅','📝','⚠️','•','-','─'])]
|
| 654 |
return '\n'.join(clean).strip()
|
| 655 |
|
| 656 |
+
# ══════════════════════════════════════════════════════════
|
| 657 |
+
# Generation
|
| 658 |
+
# ══════════════════════════════════════════════════════════
|
| 659 |
def _call_groq(messages):
|
| 660 |
for m in sorted(ACTIVE_MODELS, key=lambda x: 0 if '70b' in x['model'] else 1):
|
| 661 |
try:
|
|
|
|
| 670 |
raise e
|
| 671 |
return None, None
|
| 672 |
|
| 673 |
+
def _call_gemini_chain(messages):
|
| 674 |
+
system = next((m['content'] for m in messages if m['role']=='system'), '')
|
| 675 |
+
user = next((m['content'] for m in messages if m['role']=='user'), '')
|
| 676 |
+
prompt = f'{system}\n\n{user}'
|
| 677 |
+
|
| 678 |
+
for model_name, client in gemini_clients.items():
|
| 679 |
+
try:
|
| 680 |
+
r = client.generate_content(prompt)
|
| 681 |
+
answer = r.text
|
| 682 |
+
if not answer: continue
|
| 683 |
+
arabic = sum(1 for c in answer if '\u0600' <= c <= '\u06ff')
|
| 684 |
+
if arabic / max(len([c for c in answer if c.strip()]),1) < 0.3: continue
|
| 685 |
+
logger.info(f"Gemini: {model_name.split('/')[-1]}")
|
| 686 |
+
return answer, model_name.split('/')[-1]
|
| 687 |
+
except Exception as e:
|
| 688 |
+
if '429' in str(e): continue
|
| 689 |
+
continue
|
| 690 |
+
return None, None
|
| 691 |
|
| 692 |
def generate_with_fallback(messages):
|
| 693 |
if rate_limiter.can_use('groq'):
|
|
|
|
| 699 |
except Exception as e:
|
| 700 |
if '429' in str(e) or 'rate' in str(e).lower():
|
| 701 |
for _ in range(28): rate_limiter.requests['groq'].append(time.time())
|
| 702 |
+
else:
|
| 703 |
+
pass
|
| 704 |
+
|
| 705 |
+
result, model = _call_gemini_chain(messages)
|
| 706 |
if result: return result, model
|
| 707 |
+
|
| 708 |
wait = rate_limiter.wait_time('groq')
|
| 709 |
if wait > 0:
|
| 710 |
time.sleep(min(wait + 1, 15))
|
| 711 |
return generate_with_fallback(messages)
|
| 712 |
return None, None
|
| 713 |
|
| 714 |
+
# ══════════════════════════════════════════════════════════
|
| 715 |
+
# ask_legal
|
| 716 |
+
# ══════════════════════════════════════════════════════════
|
| 717 |
def ask_legal_core(question: str) -> dict:
|
| 718 |
original = question
|
| 719 |
question, norm_log = normalize_question(question)
|
|
|
|
| 746 |
docs = vectorstore.similarity_search(q, k=k_per_query)
|
| 747 |
all_docs.extend(docs)
|
| 748 |
|
| 749 |
+
bm25_docs, keyword_docs = [], []
|
|
|
|
| 750 |
for q in queries: bm25_docs.extend(bm25_search(q, k=5, target_law=target_law))
|
| 751 |
|
| 752 |
if target_law:
|
|
|
|
| 804 |
app = FastAPI(
|
| 805 |
title='Saudi Legal AI',
|
| 806 |
description='نظام الذكاء الاصطناعي للقانون السعودي — وزارة العدل',
|
| 807 |
+
version='4.1.0',
|
| 808 |
lifespan=lifespan
|
| 809 |
)
|
| 810 |
|
|
|
|
| 833 |
|
| 834 |
@app.get('/')
|
| 835 |
def root():
|
| 836 |
+
return {
|
| 837 |
+
'name': 'Saudi Legal AI',
|
| 838 |
+
'version': '4.1.0',
|
| 839 |
+
'status': 'running',
|
| 840 |
+
'docs': '/docs'
|
| 841 |
+
}
|
| 842 |
|
| 843 |
@app.get('/health')
|
| 844 |
def health():
|
| 845 |
return {
|
| 846 |
+
'status': 'healthy',
|
| 847 |
+
'chunks': vectorstore._collection.count() if vectorstore else 0,
|
| 848 |
+
'groq_models': [m['name'] for m in ACTIVE_MODELS],
|
| 849 |
+
'gemini_models': list(gemini_clients.keys()),
|
| 850 |
+
'version': '4.1.0'
|
| 851 |
}
|
| 852 |
|
| 853 |
@app.post('/ask', response_model=QuestionResponse)
|
|
|
|
| 858 |
raise HTTPException(status_code=400, detail='السؤال طويل جداً')
|
| 859 |
|
| 860 |
ip = request.headers.get('X-Forwarded-For','unknown').split(',')[0].strip()
|
|
|
|
| 861 |
if not check_rate_limit(ip):
|
| 862 |
raise HTTPException(status_code=429, detail='حاول مرة أخرى بعد دقيقة')
|
| 863 |
|
|
|
|
| 872 |
ms = int((time.time() - t0) * 1000)
|
| 873 |
status = 'blocked' if result['model'] in ['out_of_scope','quality_check'] else 'success'
|
| 874 |
|
| 875 |
+
request_log.appendleft({
|
| 876 |
+
'time': datetime.now().strftime('%H:%M:%S'),
|
| 877 |
+
'ip': ip, 'question': req.question[:60],
|
| 878 |
+
'model': result['model'], 'status': status, 'ms': ms
|
| 879 |
+
})
|
| 880 |
stats['total'] += 1
|
| 881 |
stats[status if status in stats else 'errors'] += 1
|
| 882 |
active_ips[ip] = active_ips.get(ip, 0) + 1
|
|
|
|
| 892 |
@app.get('/stats')
|
| 893 |
def get_stats():
|
| 894 |
return {
|
| 895 |
+
'total': stats['total'],
|
| 896 |
+
'success': stats['success'],
|
| 897 |
+
'blocked': stats['blocked'],
|
| 898 |
+
'unique_ips': len(active_ips),
|
| 899 |
+
'recent': list(request_log)[:10]
|
| 900 |
}
|
| 901 |
|
| 902 |
@app.get('/laws')
|