WafaaFraih commited on
Commit
f561907
·
verified ·
1 Parent(s): 095b447

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +101 -79
main.py CHANGED
@@ -1,6 +1,7 @@
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
@@ -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.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'},
@@ -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
- page_content=item['text'],
417
- metadata={
418
- 'article_number': item.get('article_number',''),
419
- 'law_name': item.get('law_name',''),
420
- 'law_type': item.get('law_type',''),
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
- arabic_ratio = sum(1 for c in translated if '\u0600' <= c <= '\u06ff') / max(len(translated), 1)
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
- translated = translate_with_dict(translated)
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 is_legal_question(question):
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.20:
 
551
  return True
552
  except: pass
553
- return False
 
 
 
 
 
 
 
 
 
 
 
 
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
- words = [w for w in question.split() if len(w) > 3]
631
- if not words: return 1.0
632
- all_text = ' '.join(d.page_content for d in docs)
633
- return sum(1 for w in words if w in all_text or (len(w) >= 4 and w[:4] in all_text)) / len(words)
 
 
 
 
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 is_legal_question(question):
722
- test_docs = vectorstore.similarity_search(question, k=3)
723
- if calculate_coverage(question, test_docs) < 0.20:
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.25:
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.1.0',
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.1.0'
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)