Spaces:
Runtime error
Runtime error
Update main.py
Browse files
main.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
-
Saudi Legal AI API — v4.
|
| 3 |
FastAPI + RAG + Groq + Gemini Multi-Model
|
|
|
|
| 4 |
"""
|
| 5 |
import os, gc, re, time, logging
|
| 6 |
from collections import deque, defaultdict
|
|
@@ -56,6 +57,7 @@ active_ips = {}
|
|
| 56 |
_expansion_cache = {}
|
| 57 |
_rewrite_cache = {}
|
| 58 |
_translation_cache = {}
|
|
|
|
| 59 |
|
| 60 |
GEMINI_MODELS = [
|
| 61 |
'models/gemini-2.5-flash',
|
|
@@ -83,6 +85,8 @@ EXTRA_LAWS = [
|
|
| 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':'المادة الحادية والثلاثون: يُعيَّن القضاة بمرسوم ملكي. ويشترط في القاضي: أن يكون سعودي الجنسية، متمتعاً بالأهلية الكاملة، حاصلاً على مؤهل شرعي، ناجحاً في الاختبار.',
|
|
@@ -108,11 +112,21 @@ COLLOQUIAL = {
|
|
| 108 |
'امتى':'متى','فين':'أين','مين':'من','ليه':'لماذا',
|
| 109 |
'عندي':'لدي','عندك':'لديك',
|
| 110 |
'اشتغلت':'عملت','فصلوني':'تم فصلي','طردوني':'تم فصلي',
|
|
|
|
| 111 |
'مش':'لا','كمان':'أيضاً','برضو':'أيضاً',
|
| 112 |
'زي':'مثل','اللازم':'يجب','المفروض':'يجب',
|
| 113 |
'مكافاه':'مكافأة','مكافأه':'مكافأة',
|
| 114 |
'رخصه':'رخصة','رخصت':'رخصة',
|
| 115 |
'مزاوله':'مزاولة','المحاماه':'المحاماة',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
NUMBERS_AR = {
|
|
@@ -191,7 +205,6 @@ LAW_KEYWORDS = {
|
|
| 191 |
'توثيق':'نظام التوثيق','كاتب عدل':'نظام التوثيق','موثق':'نظام التوثيق',
|
| 192 |
'مزاولة مهنة المحاماة':'نظام المحاماة','مزاولة مهنة':'نظام المحاماة',
|
| 193 |
'محامي':'نظام المحاماة','محاماة':'نظام المحاماة',
|
| 194 |
-
'ترخيص المحاماة':'نظام المحاماة',
|
| 195 |
'إفلاس':'نظام الإفلاس','تحكيم':'نظام التحكيم',
|
| 196 |
'إثبات':'نظام الإثبات','تنفيذ':'نظام التنفيذ',
|
| 197 |
'متهم':'نظام الإجراءات الجزائية','جزائي':'نظام الإجراءات الجزائية',
|
|
@@ -200,14 +213,13 @@ LAW_KEYWORDS = {
|
|
| 200 |
'نفقة':'نظام الأحوال الشخصية','حضانة':'نظام الأحوال الشخصية',
|
| 201 |
'عقار':'نظام التسجيل العيني للعقار',
|
| 202 |
'قضاء':'نظام القضاء','قاضي':'نظام القضاء',
|
| 203 |
-
'تعيين القضاة':'نظام القضاء',
|
| 204 |
'غسل أموال':'نظام مكافحة غسل الأموال',
|
| 205 |
'غسل الأموال':'نظام مكافحة غسل الأموال',
|
| 206 |
'غسيل الأموال':'نظام مكافحة غسل الأموال',
|
| 207 |
'أركان العقد':'نظام المعاملات المدنية',
|
| 208 |
'معاملات مدنية':'نظام المعاملات المدنية',
|
| 209 |
-
'مكافأة نهاية الخدمة':'نظام العمل','
|
| 210 |
-
'نهاية الخدمة':'نظام العمل','بدل نهاية':'نظام العمل',
|
| 211 |
'صاحب عمل':'نظام العمل','عقد عمل':'نظام العمل',
|
| 212 |
'ساعات العمل':'نظام العمل','إجازة سنوية':'نظام العمل',
|
| 213 |
'فصل تعسفي':'نظام العمل','الفصل التعسفي':'نظام العمل',
|
|
@@ -215,10 +227,14 @@ LAW_KEYWORDS = {
|
|
| 215 |
'مكافأة':'نظام العمل','اشتغلت':'نظام العمل','عملت':'نظام العمل',
|
| 216 |
'فصلوني':'نظام العمل','طردوني':'نظام العمل',
|
| 217 |
'تم فصلي':'نظام العمل','فُصلت':'نظام العمل',
|
|
|
|
| 218 |
'راتب':'نظام العمل','أجر':'نظام العمل',
|
| 219 |
-
'استقالة':'نظام العمل','
|
| 220 |
'حقوقي':'نظام العمل','مستحقاتي':'نظام العمل',
|
| 221 |
-
'ل
|
|
|
|
|
|
|
|
|
|
| 222 |
'جرائم معلوماتية':'نظام مكافحة الجرائم المعلوماتية',
|
| 223 |
'اختراق':'نظام مكافحة الجرائم المعلوماتية',
|
| 224 |
'بيانات شخصية':'نظام حماية البيانات الشخصية',
|
|
@@ -238,13 +254,22 @@ LEGAL_KEYWORDS = [
|
|
| 238 |
|
| 239 |
PRACTICAL_PATTERNS = [
|
| 240 |
'أنا موظف','أنا عامل','اشتغلت','فصلوني','هل لي',
|
| 241 |
-
'هل يحق','هل أستحق','صاحب العمل',
|
| 242 |
-
'
|
| 243 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
]
|
| 245 |
|
| 246 |
QUERY_EXPANSION = {
|
| 247 |
-
'شروط مزاولة مهنة المحاماة': 'يشترط فيمن يزاول مهنة المحاماة سعودي الجنسية مقيداً جدول المحامين
|
| 248 |
'شروط رخصة الموثق': 'يشترط في الموثق سعودي شريعة امتحان',
|
| 249 |
'إجراءات الإفلاس': 'إجراء التصفية التسوية الوقائية',
|
| 250 |
'أركان العقد': 'أركان العقد الإيجاب والقبول',
|
|
@@ -253,24 +278,24 @@ QUERY_EXPANSION = {
|
|
| 253 |
'أحكام الحضانة': 'الحضانة حاضن محضون حفظ الولد',
|
| 254 |
'حقوق المتهم': 'يحق للمتهم محامي دفاع تحقيق',
|
| 255 |
'شروط اتفاق التحكيم': 'اتفاق التحكيم مكتوب باطل',
|
| 256 |
-
'
|
| 257 |
-
'شروط ت
|
| 258 |
-
'متطلبات تعيين القضاة': 'يشترط فيمن يُعيَّن في وظائف القضاء سعودي شريعة إسلامية امتحان',
|
| 259 |
-
'شروط ترخيص المحاماة': 'يشترط فيمن يزاول مهنة المحاماة أن يكون سعودي الجنسية مقيداً في جدول المحامين',
|
| 260 |
'مكافأة نهاية الخدمة': 'يستحق العامل مكافأة عن مدة خدمته نصف شهر سنة',
|
| 261 |
'هل لي مكافأة': 'يستحق العامل مكافأة نهاية الخدمة',
|
|
|
|
| 262 |
'اشتغلت': 'يستحق العامل مكافأة عن مدة خدمته',
|
| 263 |
'عملت': 'يستحق العامل مكافأة عن مدة خدمته',
|
| 264 |
'فصل بدون سبب': 'إنهاء عقد العمل تعسف تعويض',
|
| 265 |
'ساعات العمل': 'لا يجوز تشغيل العامل أكثر من ثماني ساعات',
|
| 266 |
-
'الإجازة السنوية': 'مدة إجازة العامل السنوية واحد وعشرون يوماً',
|
| 267 |
'فصلوني': 'إنهاء عقد العمل تعسفي تعويض فصل',
|
| 268 |
'طردوني': 'إنهاء عقد العمل تعسفي تعويض فصل',
|
| 269 |
'تم فصلي': 'إنهاء عقد العمل تعسفي تعويض فصل',
|
| 270 |
-
'
|
|
|
|
| 271 |
'حقوقي': 'حقوق العامل يستحق مكافأة تعويض نظام العمل',
|
| 272 |
'مستحقاتي': 'يستحق العامل مكافأة نهاية الخدمة تعويض',
|
| 273 |
-
'
|
|
|
|
| 274 |
}
|
| 275 |
|
| 276 |
FUZZY_WHITELIST = {
|
|
@@ -302,7 +327,7 @@ SYSTEM_PROMPT = """أنت مساعد قانوني متخصص في القانون
|
|
| 302 |
قواعد:
|
| 303 |
1. العربية الفصحى فقط
|
| 304 |
2. اذكر رقم المادة واسم النظام دائماً
|
| 305 |
-
3. لو السؤال عملي أجب
|
| 306 |
4. لا تخترع معلومات
|
| 307 |
5. ابدأ بـ 📋 مباشرة"""
|
| 308 |
|
|
@@ -360,9 +385,8 @@ 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.
|
| 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'},
|
|
@@ -377,7 +401,6 @@ async def startup():
|
|
| 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:
|
|
@@ -388,12 +411,10 @@ async def startup():
|
|
| 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')
|
|
@@ -410,24 +431,19 @@ async def startup():
|
|
| 410 |
login(token=HF_TOKEN, add_to_git_credential=False)
|
| 411 |
dataset = load_dataset(HF_REPO_ID, token=HF_TOKEN, split='train')
|
| 412 |
logger.info(f"✅ {len(dataset)} articles")
|
| 413 |
-
|
| 414 |
docs = [
|
| 415 |
-
Document(
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
'source': item.get('source',''),
|
| 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(
|
|
@@ -435,15 +451,13 @@ async def startup():
|
|
| 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
|
|
@@ -475,8 +489,7 @@ def translate_with_llm(question):
|
|
| 475 |
{'role':'user','content':question}
|
| 476 |
])
|
| 477 |
translated = r.choices[0].message.content.strip()
|
| 478 |
-
|
| 479 |
-
if arabic_ratio > 0.5:
|
| 480 |
_translation_cache[question] = translated
|
| 481 |
return translated
|
| 482 |
except: pass
|
|
@@ -486,16 +499,14 @@ def smart_translate(question):
|
|
| 486 |
english_ratio = detect_language_ratio(question)
|
| 487 |
if english_ratio > 0.40:
|
| 488 |
translated = translate_with_llm(question)
|
| 489 |
-
|
| 490 |
-
return translated, 'LLM'
|
| 491 |
elif english_ratio > 0.10:
|
| 492 |
translated = translate_with_dict(question)
|
| 493 |
if translated != question: return translated, 'dict'
|
| 494 |
return question, None
|
| 495 |
|
| 496 |
def fuzzy_correct_word(word, threshold=88):
|
| 497 |
-
if len(word) < 5: return word
|
| 498 |
-
if word in FUZZY_WHITELIST: return word
|
| 499 |
result = fuzz_process.extractOne(word, LEGAL_VOCABULARY, scorer=fuzz.ratio, score_cutoff=threshold)
|
| 500 |
if result:
|
| 501 |
matched, score, _ = result
|
|
@@ -529,8 +540,9 @@ def normalize_question(question):
|
|
| 529 |
if question and not question.endswith('؟'): question += '؟'
|
| 530 |
return question, log
|
| 531 |
|
| 532 |
-
def
|
| 533 |
q_lower = question.lower()
|
|
|
|
| 534 |
if any(p in question for p in PRACTICAL_PATTERNS): return True
|
| 535 |
if any(kw in question for kw in LEGAL_KEYWORDS): return True
|
| 536 |
if any(kw in question for kw in LAW_KEYWORDS): return True
|
|
@@ -543,14 +555,34 @@ def is_legal_question(question):
|
|
| 543 |
'fraud','bribery','hacking','evidence','dismissed','termination','wrongful',
|
| 544 |
]
|
| 545 |
if any(kw in q_lower for kw in english_legal): return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
arabic_words = [w for w in question.split() if len(w) > 3 and any('\u0600' <= c <= '\u06ff' for c in w)]
|
| 547 |
if len(arabic_words) >= 3:
|
| 548 |
try:
|
| 549 |
test_docs = vectorstore.similarity_search(question, k=3)
|
| 550 |
-
if test_docs and calculate_coverage(question, test_docs) >= 0.
|
|
|
|
| 551 |
return True
|
| 552 |
except: pass
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
|
| 555 |
def detect_target_law(question):
|
| 556 |
for keyword, law in sorted(LAW_KEYWORDS.items(), key=lambda x: -len(x[0])):
|
|
@@ -566,18 +598,18 @@ def expand_query(question):
|
|
| 566 |
_expansion_cache[question] = result
|
| 567 |
return result
|
| 568 |
if any(p in question for p in PRACTICAL_PATTERNS):
|
| 569 |
-
if any(w in question for w in ['تعويض','مكافأة','اشتغلت','عملت','سنوات']):
|
| 570 |
result.append('مكافأة نهاية الخدمة يستحق العامل سنوات خدمة')
|
| 571 |
-
elif any(w in question for w in ['فصل','فُصلت','فصلوني','طردوني','تم فصلي']):
|
| 572 |
result.append('إنهاء عقد العمل تعويض فصل تعسفي')
|
| 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
|
| 580 |
-
{'هي','هو','ما','في','على','من','إلى','عن','هل','لي','يحق','يجب'}]
|
| 581 |
if words: result.append(' '.join(words[:4]))
|
| 582 |
_expansion_cache[question] = result
|
| 583 |
return result
|
|
@@ -585,7 +617,7 @@ def expand_query(question):
|
|
| 585 |
def rewrite_query(question):
|
| 586 |
if question in _rewrite_cache: return _rewrite_cache[question]
|
| 587 |
TRIGGER = ['متطلبات','ضوابط','وضح','اشرح','هل لي','هل يحق','أنا','عندي',
|
| 588 |
-
'فصلوني','طردوني','تم فصلي','حقوقي','مستحقاتي']
|
| 589 |
if not any(w in question for w in TRIGGER): return expand_query(question)
|
| 590 |
try:
|
| 591 |
if not ACTIVE_MODELS: return expand_query(question)
|
|
@@ -627,10 +659,14 @@ def rerank_docs(docs, question, target_law=None):
|
|
| 627 |
|
| 628 |
def calculate_coverage(question, docs):
|
| 629 |
if not docs: return 0.0
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
|
| 635 |
def build_context(docs):
|
| 636 |
parts = []
|
|
@@ -674,7 +710,6 @@ 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)
|
|
@@ -682,11 +717,9 @@ def _call_gemini_chain(messages):
|
|
| 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):
|
|
@@ -699,12 +732,8 @@ def generate_with_fallback(messages):
|
|
| 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))
|
|
@@ -718,17 +747,16 @@ def ask_legal_core(question: str) -> dict:
|
|
| 718 |
original = question
|
| 719 |
question, norm_log = normalize_question(question)
|
| 720 |
|
| 721 |
-
if not
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
return {'answer': OUT_OF_SCOPE_RESPONSE, 'sources': [], 'coverage': 0,
|
| 725 |
-
'model': 'out_of_scope', 'normalized': question}
|
| 726 |
|
| 727 |
queries = rewrite_query(question)
|
| 728 |
target_law = detect_target_law(question) or detect_target_law(original)
|
| 729 |
|
| 730 |
work_clues = ['فصل','تعويض','مكافأة','راتب','أجر','ساعات','إجازة',
|
| 731 |
-
'موظف','عامل','
|
|
|
|
| 732 |
if not target_law and any(w in question or w in original for w in work_clues):
|
| 733 |
target_law = 'نظام العمل'
|
| 734 |
|
|
@@ -777,7 +805,7 @@ def ask_legal_core(question: str) -> dict:
|
|
| 777 |
final_docs = rerank_docs(combined, question, target_law)[:top_k]
|
| 778 |
coverage = calculate_coverage(question, final_docs)
|
| 779 |
|
| 780 |
-
if len(final_docs) == 0 or coverage < 0.
|
| 781 |
return {'answer': OUT_OF_SCOPE_RESPONSE, 'sources': [], 'coverage': 0,
|
| 782 |
'model': 'quality_check', 'normalized': question}
|
| 783 |
|
|
@@ -804,10 +832,9 @@ def ask_legal_core(question: str) -> dict:
|
|
| 804 |
app = FastAPI(
|
| 805 |
title='Saudi Legal AI',
|
| 806 |
description='نظام الذكاء الاصطناعي للقانون السعودي — وزارة العدل',
|
| 807 |
-
version='4.
|
| 808 |
lifespan=lifespan
|
| 809 |
)
|
| 810 |
-
|
| 811 |
app.add_middleware(CORSMiddleware, allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])
|
| 812 |
|
| 813 |
user_requests = defaultdict(list)
|
|
@@ -833,12 +860,7 @@ class QuestionResponse(BaseModel):
|
|
| 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():
|
|
@@ -847,7 +869,7 @@ def health():
|
|
| 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.
|
| 851 |
}
|
| 852 |
|
| 853 |
@app.post('/ask', response_model=QuestionResponse)
|
|
|
|
| 1 |
"""
|
| 2 |
+
Saudi Legal AI API — v4.2
|
| 3 |
FastAPI + RAG + Groq + Gemini Multi-Model
|
| 4 |
+
Pipeline: Normalize → Translate → Fuzzy → LLM Detection → RAG
|
| 5 |
"""
|
| 6 |
import os, gc, re, time, logging
|
| 7 |
from collections import deque, defaultdict
|
|
|
|
| 57 |
_expansion_cache = {}
|
| 58 |
_rewrite_cache = {}
|
| 59 |
_translation_cache = {}
|
| 60 |
+
_legal_cache = {}
|
| 61 |
|
| 62 |
GEMINI_MODELS = [
|
| 63 |
'models/gemini-2.5-flash',
|
|
|
|
| 85 |
'article_number':'المادة الثالثة والثلاثون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 86 |
{'text':'المادة الخامسة والسبعون: إذا أراد أحد طرفي عقد العمل غير المحدد المدة إنهاءه وجب عليه إخطار الطرف الآخر كتابةً قبل الإنهاء بستين يوماً إذا كان أجر العامل يدفع شهرياً.',
|
| 87 |
'article_number':'المادة الخامسة والسبعون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 88 |
+
{'text':'المادة السابعة والعشرون: عند انتهاء عقد العمل يلتزم صاحب العمل بإعطاء العامل شهادة خبرة مجانية توضح تاريخ التحاقه بالعمل وتاريخ انتهائه وطبيعة عمله وما أداه من مهام.',
|
| 89 |
+
'article_number':'المادة السابعة والعشرون','law_name':'نظام العمل','law_type':'نظام','source':'hrsd.gov.sa'},
|
| 90 |
{'text':'المادة الثانية والثلاثون: يشترط فيمن يُعيَّن في وظائف القضاء أن يكون سعودي الجنسية بالأصل، وأن يكون مسلماً، وأن يكون حاصلاً على شهادة الأهلية في الشريعة الإسلامية أو ما يعادلها، وأن ينجح في الامتحان المقرر.',
|
| 91 |
'article_number':'المادة الثانية والثلاثون','law_name':'نظام القضاء','law_type':'نظام','source':'boe.gov.sa'},
|
| 92 |
{'text':'المادة الحادية والثلاثون: يُعيَّن القضاة بمرسوم ملكي. ويشترط في القاضي: أن يكون سعودي الجنسية، متمتعاً بالأهلية الكاملة، حاصلاً على مؤهل شرعي، ناجحاً في الاختبار.',
|
|
|
|
| 112 |
'امتى':'متى','فين':'أين','مين':'من','ليه':'لماذا',
|
| 113 |
'عندي':'لدي','عندك':'لديك',
|
| 114 |
'اشتغلت':'عملت','فصلوني':'تم فصلي','طردوني':'تم فصلي',
|
| 115 |
+
'اتطردت':'تم فصلي','اتطرد':'تم فصله',
|
| 116 |
'مش':'لا','كمان':'أيضاً','برضو':'أيضاً',
|
| 117 |
'زي':'مثل','اللازم':'يجب','المفروض':'يجب',
|
| 118 |
'مكافاه':'مكافأة','مكافأه':'مكافأة',
|
| 119 |
'رخصه':'رخصة','رخصت':'رخصة',
|
| 120 |
'مزاوله':'مزاولة','المحاماه':'المحاماة',
|
| 121 |
+
'ليا':'لي','هل ليا':'هل يحق لي',
|
| 122 |
+
'فلوس':'تعويض مالي','هل ليا فلوس':'هل يحق لي تعويض',
|
| 123 |
+
'الشغل':'العمل','شغل':'عمل',
|
| 124 |
+
'علاوة':'زيادة في الراتب','العلاوة':'الزيادة في الراتب',
|
| 125 |
+
'بتدفش':'لا يدفع','ما بتدفش':'لا يدفع','مش دافع':'لا يدفع',
|
| 126 |
+
'عاوز':'أريد','عايز':'أريد','عاوزة':'أريد','عايزة':'أريد',
|
| 127 |
+
'ايه اللازم':'ما هي الإجراءات','ايه حقي':'ما هي حقوقي',
|
| 128 |
+
'يديني':'يعطيني','باخد':'آخذ','هياخد':'يأخذ',
|
| 129 |
+
'مش دافعلي':'لم يدفع لي',
|
| 130 |
}
|
| 131 |
|
| 132 |
NUMBERS_AR = {
|
|
|
|
| 205 |
'توثيق':'نظام التوثيق','كاتب عدل':'نظام التوثيق','موثق':'نظام التوثيق',
|
| 206 |
'مزاولة مهنة المحاماة':'نظام المحاماة','مزاولة مهنة':'نظام المحاماة',
|
| 207 |
'محامي':'نظام المحاماة','محاماة':'نظام المحاماة',
|
|
|
|
| 208 |
'إفلاس':'نظام الإفلاس','تحكيم':'نظام التحكيم',
|
| 209 |
'إثبات':'نظام الإثبات','تنفيذ':'نظام التنفيذ',
|
| 210 |
'متهم':'نظام الإجراءات الجزائية','جزائي':'نظام الإجراءات الجزائية',
|
|
|
|
| 213 |
'نفقة':'نظام الأحوال الشخصية','حضانة':'نظام الأحوال الشخصية',
|
| 214 |
'عقار':'نظام التسجيل العيني للعقار',
|
| 215 |
'قضاء':'نظام القضاء','قاضي':'نظام القضاء',
|
| 216 |
+
'تعيين القضاة':'نظام القضاء',
|
| 217 |
'غسل أموال':'نظام مكافحة غسل الأموال',
|
| 218 |
'غسل الأموال':'نظام مكافحة غسل الأموال',
|
| 219 |
'غسيل الأموال':'نظام مكافحة غسل الأموال',
|
| 220 |
'أركان العقد':'نظام المعاملات المدنية',
|
| 221 |
'معاملات مدنية':'نظام المعاملات المدنية',
|
| 222 |
+
'مكافأة نهاية الخدمة':'نظام العمل','نهاية الخدمة':'نظام العمل',
|
|
|
|
| 223 |
'صاحب عمل':'نظام العمل','عقد عمل':'نظام العمل',
|
| 224 |
'ساعات العمل':'نظام العمل','إجازة سنوية':'نظام العمل',
|
| 225 |
'فصل تعسفي':'نظام العمل','الفصل التعسفي':'نظام العمل',
|
|
|
|
| 227 |
'مكافأة':'نظام العمل','اشتغلت':'نظام العمل','عملت':'نظام العمل',
|
| 228 |
'فصلوني':'نظام العمل','طردوني':'نظام العمل',
|
| 229 |
'تم فصلي':'نظام العمل','فُصلت':'نظام العمل',
|
| 230 |
+
'اتطردت':'نظام العمل','اتطرد':'نظام العمل',
|
| 231 |
'راتب':'نظام العمل','أجر':'نظام العمل',
|
| 232 |
+
'استقالة':'نظام العمل','شهادة خبرة':'نظام العمل',
|
| 233 |
'حقوقي':'نظام العمل','مستحقاتي':'نظام العمل',
|
| 234 |
+
'علاوة':'نظام العمل','العلاوة':'نظام العمل',
|
| 235 |
+
'فلوس':'نظام العمل','ليا فلوس':'نظام العمل',
|
| 236 |
+
'تسجيل شركة':'نظام الشركات','أسجل شركة':'نظام الشركات',
|
| 237 |
+
'تهمة':'نظام الإجراءات الجزائية',
|
| 238 |
'جرائم معلوماتية':'نظام مكافحة الجرائم المعلوماتية',
|
| 239 |
'اختراق':'نظام مكافحة الجرائم المعلوماتية',
|
| 240 |
'بيانات شخصية':'نظام حماية البيانات الشخصية',
|
|
|
|
| 254 |
|
| 255 |
PRACTICAL_PATTERNS = [
|
| 256 |
'أنا موظف','أنا عامل','اشتغلت','فصلوني','هل لي',
|
| 257 |
+
'هل يحق','هل أستحق','صاحب العمل','طردوني',
|
| 258 |
+
'حقي','حقوقي','مستحقاتي','تم فصلي','فُصلت','عملت',
|
| 259 |
+
'هل ليا','ليا فلوس','اتطردت','عاوز أعرف',
|
| 260 |
+
'عايز أعرف','ما دفعوا','ما عطوني','هل لازم',
|
| 261 |
+
]
|
| 262 |
+
|
| 263 |
+
NON_LEGAL_KEYWORDS = [
|
| 264 |
+
'كورة','فريق','لاعب','مباراة','دوري','كأس','بطولة',
|
| 265 |
+
'طبخ','أكل','وصفة','مطبخ','طعام',
|
| 266 |
+
'سعر','دولار','ذهب','بورصة','عملة','سهم',
|
| 267 |
+
'فيلم','مسلسل','ممثل','غنية','مطرب','موسيقى',
|
| 268 |
+
'طقس','حرارة','مطر','رياح',
|
| 269 |
]
|
| 270 |
|
| 271 |
QUERY_EXPANSION = {
|
| 272 |
+
'شروط مزاولة مهنة المحاماة': 'يشترط فيمن يزاول مهنة المحاماة سعودي الجنسية مقيداً جدول المحامين',
|
| 273 |
'شروط رخصة الموثق': 'يشترط في الموثق سعودي شريعة امتحان',
|
| 274 |
'إجراءات الإفلاس': 'إجراء التصفية التسوية الوقائية',
|
| 275 |
'أركان العقد': 'أركان العقد الإيجاب والقبول',
|
|
|
|
| 278 |
'أحكام الحضانة': 'الحضانة حاضن محضون حفظ الولد',
|
| 279 |
'حقوق المتهم': 'يحق للمتهم محامي دفاع تحقيق',
|
| 280 |
'شروط اتفاق التحكيم': 'اتفاق التحكيم مكتوب باطل',
|
| 281 |
+
'شروط تعيين القاضي': 'يشترط فيمن يُعيَّن ��ي وظائف القضاء سعودي الجنسية بالأصل',
|
| 282 |
+
'شروط ترخيص المحاماة': 'يشترط فيمن يزاول مهنة المحاماة سعودي الجنسية مقيداً في جدول المحامين',
|
|
|
|
|
|
|
| 283 |
'مكافأة نهاية الخدمة': 'يستحق العامل مكافأة عن مدة خدمته نصف شهر سنة',
|
| 284 |
'هل لي مكافأة': 'يستحق العامل مكافأة نهاية الخدمة',
|
| 285 |
+
'هل ليا فلوس': 'يستحق العامل تعويض فصل تعسفي مكافأة نهاية الخدمة',
|
| 286 |
'اشتغلت': 'يستحق العامل مكافأة عن مدة خدمته',
|
| 287 |
'عملت': 'يستحق العامل مكافأة عن مدة خدمته',
|
| 288 |
'فصل بدون سبب': 'إنهاء عقد العمل تعسف تعويض',
|
| 289 |
'ساعات العمل': 'لا يجوز تشغيل العامل أكثر من ثماني ساعات',
|
|
|
|
| 290 |
'فصلوني': 'إنهاء عقد العمل تعسفي تعويض فصل',
|
| 291 |
'طردوني': 'إنهاء عقد العمل تعسفي تعويض فصل',
|
| 292 |
'تم فصلي': 'إنهاء عقد العمل تعسفي تعويض فصل',
|
| 293 |
+
'اتطردت': 'تم فصلي إنهاء عقد العمل تعويض فصل تعسفي',
|
| 294 |
+
'حقوقي لو': 'حقوق العامل عند الفصل التعسفي تعويض مكافأة',
|
| 295 |
'حقوقي': 'حقوق العامل يستحق مكافأة تعويض نظام العمل',
|
| 296 |
'مستحقاتي': 'يستحق العامل مكافأة نهاية الخدمة تعويض',
|
| 297 |
+
'شهادة خبرة': 'يلتزم صاحب العمل بإعطاء العامل شهادة خبرة عند انتهاء العقد',
|
| 298 |
+
'العلاوة السنوية': 'يستحق العامل زيادة في الأجر علاوة سنوية',
|
| 299 |
}
|
| 300 |
|
| 301 |
FUZZY_WHITELIST = {
|
|
|
|
| 327 |
قواعد:
|
| 328 |
1. العربية الفصحى فقط
|
| 329 |
2. اذكر رقم المادة واسم النظام دائماً
|
| 330 |
+
3. لو السؤال عملي أجب بنعم/لا ثم اشرح الحق
|
| 331 |
4. لا تخترع معلومات
|
| 332 |
5. ابدأ بـ 📋 مباشرة"""
|
| 333 |
|
|
|
|
| 385 |
global vectorstore, bm25_index, bm25_texts, bm25_metadatas
|
| 386 |
global embeddings, ACTIVE_MODELS, GEMINI_AVAILABLE, gemini_clients, groq_client
|
| 387 |
|
| 388 |
+
logger.info("Starting Saudi Legal AI v4.2...")
|
| 389 |
|
|
|
|
| 390 |
groq_client = Groq(api_key=GROQ_API_KEY)
|
| 391 |
for m in [
|
| 392 |
{'model': 'llama-3.3-70b-versatile', 'name': 'Groq llama-3.3'},
|
|
|
|
| 401 |
except Exception as e:
|
| 402 |
logger.warning(f"❌ {m['name']}: {str(e)[:30]}")
|
| 403 |
|
|
|
|
| 404 |
if GEMINI_KEY:
|
| 405 |
genai.configure(api_key=GEMINI_KEY)
|
| 406 |
for model_name in GEMINI_MODELS:
|
|
|
|
| 411 |
except Exception as e:
|
| 412 |
logger.warning(f"❌ {model_name}: {str(e)[:40]}")
|
| 413 |
|
|
|
|
| 414 |
logger.info("Loading embeddings...")
|
| 415 |
embeddings = SentenceTransformerEmbeddings(
|
| 416 |
model_name='sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
|
| 417 |
|
|
|
|
| 418 |
chroma_client_persist = chromadb.PersistentClient(path=CHROMA_PATH)
|
| 419 |
try:
|
| 420 |
collection = chroma_client_persist.get_collection('saudi_legal_v4')
|
|
|
|
| 431 |
login(token=HF_TOKEN, add_to_git_credential=False)
|
| 432 |
dataset = load_dataset(HF_REPO_ID, token=HF_TOKEN, split='train')
|
| 433 |
logger.info(f"✅ {len(dataset)} articles")
|
|
|
|
| 434 |
docs = [
|
| 435 |
+
Document(page_content=item['text'], metadata={
|
| 436 |
+
'article_number': item.get('article_number',''),
|
| 437 |
+
'law_name': item.get('law_name',''),
|
| 438 |
+
'law_type': item.get('law_type',''),
|
| 439 |
+
'source': item.get('source',''),
|
| 440 |
+
}) for item in dataset if len(item.get('text','')) > 30
|
|
|
|
|
|
|
|
|
|
| 441 |
]
|
| 442 |
for a in EXTRA_LAWS:
|
| 443 |
docs.append(Document(page_content=a['text'], metadata={
|
| 444 |
'article_number': a['article_number'], 'law_name': a['law_name'],
|
| 445 |
'law_type': a['law_type'], 'source': a['source'],
|
| 446 |
}))
|
|
|
|
| 447 |
splitter = RecursiveCharacterTextSplitter(chunk_size=1500, chunk_overlap=200)
|
| 448 |
chunks = splitter.split_documents(docs)
|
| 449 |
vectorstore = Chroma.from_documents(
|
|
|
|
| 451 |
client=chroma_client_persist, collection_name='saudi_legal_v4')
|
| 452 |
logger.info(f"✅ ChromaDB created: {vectorstore._collection.count()} chunks")
|
| 453 |
|
|
|
|
| 454 |
stop_words = {'من','في','على','إلى','عن','مع','هي','هو','ما','لا','أن','إن'}
|
| 455 |
def tokenize(text): return [w for w in text.split() if len(w) > 2 and w not in stop_words]
|
| 456 |
all_chunks = vectorstore.get()
|
| 457 |
bm25_texts = all_chunks['documents']
|
| 458 |
bm25_metadatas = all_chunks['metadatas']
|
| 459 |
bm25_index = BM25Okapi([tokenize(t) for t in bm25_texts])
|
| 460 |
+
logger.info(f"✅ BM25: {len(bm25_texts)} docs | Saudi Legal AI v4.2 Ready!")
|
|
|
|
| 461 |
|
| 462 |
# ══════════════════════════════════════════════════════════
|
| 463 |
# Pipeline Functions
|
|
|
|
| 489 |
{'role':'user','content':question}
|
| 490 |
])
|
| 491 |
translated = r.choices[0].message.content.strip()
|
| 492 |
+
if sum(1 for c in translated if '\u0600' <= c <= '\u06ff') / max(len(translated),1) > 0.5:
|
|
|
|
| 493 |
_translation_cache[question] = translated
|
| 494 |
return translated
|
| 495 |
except: pass
|
|
|
|
| 499 |
english_ratio = detect_language_ratio(question)
|
| 500 |
if english_ratio > 0.40:
|
| 501 |
translated = translate_with_llm(question)
|
| 502 |
+
return translate_with_dict(translated), 'LLM'
|
|
|
|
| 503 |
elif english_ratio > 0.10:
|
| 504 |
translated = translate_with_dict(question)
|
| 505 |
if translated != question: return translated, 'dict'
|
| 506 |
return question, None
|
| 507 |
|
| 508 |
def fuzzy_correct_word(word, threshold=88):
|
| 509 |
+
if len(word) < 5 or word in FUZZY_WHITELIST: return word
|
|
|
|
| 510 |
result = fuzz_process.extractOne(word, LEGAL_VOCABULARY, scorer=fuzz.ratio, score_cutoff=threshold)
|
| 511 |
if result:
|
| 512 |
matched, score, _ = result
|
|
|
|
| 540 |
if question and not question.endswith('؟'): question += '؟'
|
| 541 |
return question, log
|
| 542 |
|
| 543 |
+
def is_legal_question_base(question):
|
| 544 |
q_lower = question.lower()
|
| 545 |
+
if any(kw in question for kw in NON_LEGAL_KEYWORDS): return False
|
| 546 |
if any(p in question for p in PRACTICAL_PATTERNS): return True
|
| 547 |
if any(kw in question for kw in LEGAL_KEYWORDS): return True
|
| 548 |
if any(kw in question for kw in LAW_KEYWORDS): return True
|
|
|
|
| 555 |
'fraud','bribery','hacking','evidence','dismissed','termination','wrongful',
|
| 556 |
]
|
| 557 |
if any(kw in q_lower for kw in english_legal): return True
|
| 558 |
+
return False
|
| 559 |
+
|
| 560 |
+
def is_legal_question_llm(question: str) -> bool:
|
| 561 |
+
if question in _legal_cache: return _legal_cache[question]
|
| 562 |
+
if is_legal_question_base(question):
|
| 563 |
+
_legal_cache[question] = True
|
| 564 |
+
return True
|
| 565 |
arabic_words = [w for w in question.split() if len(w) > 3 and any('\u0600' <= c <= '\u06ff' for c in w)]
|
| 566 |
if len(arabic_words) >= 3:
|
| 567 |
try:
|
| 568 |
test_docs = vectorstore.similarity_search(question, k=3)
|
| 569 |
+
if test_docs and calculate_coverage(question, test_docs) >= 0.3:
|
| 570 |
+
_legal_cache[question] = True
|
| 571 |
return True
|
| 572 |
except: pass
|
| 573 |
+
try:
|
| 574 |
+
r = groq_client.chat.completions.create(
|
| 575 |
+
model=ACTIVE_MODELS[-1]['model'], max_tokens=5, temperature=0,
|
| 576 |
+
messages=[
|
| 577 |
+
{'role':'system','content':'أنت محلل قانوني. أجب بـ YES إذا السؤال متعلق بالقانون السعودي أو حقوق العمل أو الأنظمة. أجب بـ NO لأي سؤال آخر. لا تكتب غير YES أو NO.'},
|
| 578 |
+
{'role':'user','content':question}
|
| 579 |
+
])
|
| 580 |
+
result = 'YES' in r.choices[0].message.content.strip().upper()
|
| 581 |
+
_legal_cache[question] = result
|
| 582 |
+
return result
|
| 583 |
+
except:
|
| 584 |
+
_legal_cache[question] = False
|
| 585 |
+
return False
|
| 586 |
|
| 587 |
def detect_target_law(question):
|
| 588 |
for keyword, law in sorted(LAW_KEYWORDS.items(), key=lambda x: -len(x[0])):
|
|
|
|
| 598 |
_expansion_cache[question] = result
|
| 599 |
return result
|
| 600 |
if any(p in question for p in PRACTICAL_PATTERNS):
|
| 601 |
+
if any(w in question for w in ['تعويض','مكافأة','اشتغلت','عملت','سنوات','فلوس']):
|
| 602 |
result.append('مكافأة نهاية الخدمة يستحق العامل سنوات خدمة')
|
| 603 |
+
elif any(w in question for w in ['فصل','فُصلت','فصلوني','طردوني','تم فصلي','اتطردت']):
|
| 604 |
result.append('إنهاء عقد العمل تعويض فصل تعسفي')
|
| 605 |
elif any(w in question for w in ['حقوق','حقوقي','مستحقات']):
|
| 606 |
result.append('حقوق العامل يستحق مكافأة تعويض نظام العمل')
|
| 607 |
cleaned = question
|
| 608 |
+
for prefix in ['ما هي ','ما هو ','هل ','متى ','كيف ','أريد ']: cleaned = cleaned.replace(prefix,'')
|
| 609 |
cleaned = cleaned.replace('؟','').strip()
|
| 610 |
if cleaned != question and len(cleaned) > 5: result.append(cleaned)
|
| 611 |
words = [w for w in question.split() if len(w) > 3 and w not in
|
| 612 |
+
{'هي','هو','ما','في','على','من','إلى','عن','هل','لي','يحق','يجب','أريد'}]
|
| 613 |
if words: result.append(' '.join(words[:4]))
|
| 614 |
_expansion_cache[question] = result
|
| 615 |
return result
|
|
|
|
| 617 |
def rewrite_query(question):
|
| 618 |
if question in _rewrite_cache: return _rewrite_cache[question]
|
| 619 |
TRIGGER = ['متطلبات','ضوابط','وضح','اشرح','هل لي','هل يحق','أنا','عندي',
|
| 620 |
+
'فصلوني','طردوني','تم فصلي','حقوقي','مستحقاتي','اتطردت','أريد']
|
| 621 |
if not any(w in question for w in TRIGGER): return expand_query(question)
|
| 622 |
try:
|
| 623 |
if not ACTIVE_MODELS: return expand_query(question)
|
|
|
|
| 659 |
|
| 660 |
def calculate_coverage(question, docs):
|
| 661 |
if not docs: return 0.0
|
| 662 |
+
laws_found = set(d.metadata.get('law_name','') for d in docs)
|
| 663 |
+
has_known_law = any(law in laws_found for law in LAW_KEYWORDS.values())
|
| 664 |
+
base_coverage = 0.5 if has_known_law else 0.0
|
| 665 |
+
all_words = [w for w in question.split() if len(w) > 3]
|
| 666 |
+
if not all_words: return base_coverage
|
| 667 |
+
all_text = ' '.join(d.page_content for d in docs)
|
| 668 |
+
matched = sum(1 for w in all_words if w in all_text or (len(w) >= 4 and w[:4] in all_text))
|
| 669 |
+
return max(base_coverage, matched / len(all_words))
|
| 670 |
|
| 671 |
def build_context(docs):
|
| 672 |
parts = []
|
|
|
|
| 710 |
system = next((m['content'] for m in messages if m['role']=='system'), '')
|
| 711 |
user = next((m['content'] for m in messages if m['role']=='user'), '')
|
| 712 |
prompt = f'{system}\n\n{user}'
|
|
|
|
| 713 |
for model_name, client in gemini_clients.items():
|
| 714 |
try:
|
| 715 |
r = client.generate_content(prompt)
|
|
|
|
| 717 |
if not answer: continue
|
| 718 |
arabic = sum(1 for c in answer if '\u0600' <= c <= '\u06ff')
|
| 719 |
if arabic / max(len([c for c in answer if c.strip()]),1) < 0.3: continue
|
|
|
|
| 720 |
return answer, model_name.split('/')[-1]
|
| 721 |
except Exception as e:
|
| 722 |
if '429' in str(e): continue
|
|
|
|
| 723 |
return None, None
|
| 724 |
|
| 725 |
def generate_with_fallback(messages):
|
|
|
|
| 732 |
except Exception as e:
|
| 733 |
if '429' in str(e) or 'rate' in str(e).lower():
|
| 734 |
for _ in range(28): rate_limiter.requests['groq'].append(time.time())
|
|
|
|
|
|
|
|
|
|
| 735 |
result, model = _call_gemini_chain(messages)
|
| 736 |
if result: return result, model
|
|
|
|
| 737 |
wait = rate_limiter.wait_time('groq')
|
| 738 |
if wait > 0:
|
| 739 |
time.sleep(min(wait + 1, 15))
|
|
|
|
| 747 |
original = question
|
| 748 |
question, norm_log = normalize_question(question)
|
| 749 |
|
| 750 |
+
if not is_legal_question_llm(question):
|
| 751 |
+
return {'answer': OUT_OF_SCOPE_RESPONSE, 'sources': [], 'coverage': 0,
|
| 752 |
+
'model': 'out_of_scope', 'normalized': question}
|
|
|
|
|
|
|
| 753 |
|
| 754 |
queries = rewrite_query(question)
|
| 755 |
target_law = detect_target_law(question) or detect_target_law(original)
|
| 756 |
|
| 757 |
work_clues = ['فصل','تعويض','مكافأة','راتب','أجر','ساعات','إجازة',
|
| 758 |
+
'موظف','عامل','نهاية الخدمة','حقوقي','مستحقاتي',
|
| 759 |
+
'اتطردت','فلوس','علاوة','شهادة خبرة']
|
| 760 |
if not target_law and any(w in question or w in original for w in work_clues):
|
| 761 |
target_law = 'نظام العمل'
|
| 762 |
|
|
|
|
| 805 |
final_docs = rerank_docs(combined, question, target_law)[:top_k]
|
| 806 |
coverage = calculate_coverage(question, final_docs)
|
| 807 |
|
| 808 |
+
if len(final_docs) == 0 or coverage < 0.20:
|
| 809 |
return {'answer': OUT_OF_SCOPE_RESPONSE, 'sources': [], 'coverage': 0,
|
| 810 |
'model': 'quality_check', 'normalized': question}
|
| 811 |
|
|
|
|
| 832 |
app = FastAPI(
|
| 833 |
title='Saudi Legal AI',
|
| 834 |
description='نظام الذكاء الاصطناعي للقانون السعودي — وزارة العدل',
|
| 835 |
+
version='4.2.0',
|
| 836 |
lifespan=lifespan
|
| 837 |
)
|
|
|
|
| 838 |
app.add_middleware(CORSMiddleware, allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])
|
| 839 |
|
| 840 |
user_requests = defaultdict(list)
|
|
|
|
| 860 |
|
| 861 |
@app.get('/')
|
| 862 |
def root():
|
| 863 |
+
return {'name':'Saudi Legal AI','version':'4.2.0','status':'running','docs':'/docs'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 864 |
|
| 865 |
@app.get('/health')
|
| 866 |
def health():
|
|
|
|
| 869 |
'chunks': vectorstore._collection.count() if vectorstore else 0,
|
| 870 |
'groq_models': [m['name'] for m in ACTIVE_MODELS],
|
| 871 |
'gemini_models': list(gemini_clients.keys()),
|
| 872 |
+
'version': '4.2.0'
|
| 873 |
}
|
| 874 |
|
| 875 |
@app.post('/ask', response_model=QuestionResponse)
|