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

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +151 -155
main.py CHANGED
@@ -1,12 +1,13 @@
1
  """
2
- Saudi Legal AI API — v4.0
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 = os.getenv('GROQ_API_KEY', '')
35
- HF_TOKEN = os.getenv('HF_TOKEN', '')
36
- GEMINI_KEY = os.getenv('GEMINI_API_KEY', '')
37
- HF_REPO_ID = os.getenv('HF_REPO_ID', 'WafaaFraih/saudi-legal-moj')
38
- CHROMA_PATH = os.getenv('CHROMA_PATH', './chroma_db')
39
 
40
  # ══════════════════════════════════════════════════════════
41
  # Global State
42
  # ══════════════════════════════════════════════════════════
43
- vectorstore = None
44
- bm25_index = None
45
- bm25_texts = []
46
- bm25_metadatas = []
47
- embeddings = None
48
- ACTIVE_MODELS = []
49
- GEMINI_AVAILABLE = False
50
- gemini_model = None
51
- groq_client = None
52
- request_log = deque(maxlen=500)
53
- stats = {'total': 0, 'success': 0, 'blocked': 0, 'errors': 0}
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
- 'gratuity': 'مكافأة نهاية الخدمة',
168
- 'indemnity': 'تعويض نهاية الخدمة',
169
- 'wrongful': عسفي',
170
- 'cybercrime': 'الجرائم المعلوماتية',
171
- 'hacking': 'الاختراق الإلكتروني',
172
- 'bribery': لرشوة',
173
- 'fraud': لاحتيال',
174
- 'arbitration': 'تحكيم',
175
- 'bankruptcy': 'إفلاس',
176
- 'divorce': 'طلاق',
177
- 'custody': 'حضانة',
178
- 'notary': 'كاتب عدل',
179
- 'lawyer': 'محامي',
180
- 'attorney': 'محامي',
181
- 'judge': اضي',
182
- 'penalty': 'عقوبة',
183
- 'penaly': 'عقوبة',
184
- 'penelty': 'عقوبة',
185
- 'fine': 'غرامة',
186
- 'imprisonment': 'سجن',
187
- 'rights': 'حقوق',
188
- 'rigths': 'حقوق',
189
- 'contract': 'عقد',
190
- 'salary': 'الأجر',
191
- 'wage': لأجر',
192
- 'employee': 'عامل',
193
- 'employer': 'صاحب عمل',
194
- 'terminated': 'فُصلت',
195
- 'dismissed': 'فُصلت',
196
- 'dissmised': 'فُصلت',
197
- 'overtime': 'العمل الإضافي',
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, gemini_model, groq_client
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
- try:
402
- genai.configure(api_key=GEMINI_KEY)
403
- gemini_model = genai.GenerativeModel('models/gemma-3-1b-it')
404
- gemini_model.generate_content('hi')
405
- GEMINI_AVAILABLE = True
406
- logger.info("✅ Gemini")
407
- except Exception as e:
408
- logger.warning(f"❌ Gemini: {str(e)[:40]}")
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
- 'law_name': a['law_name'],
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, corrections, result = clean.split(), [], []
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
- corrected = fuzzy_correct_word(word)
534
- if corrected != word: corrections.append(f'{word}→{corrected}')
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, corrections = fuzzy_normalize(question)
549
- if corrections:
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 _call_gemini(messages):
697
- if not GEMINI_AVAILABLE: return None, None
698
- try:
699
- system = next((m['content'] for m in messages if m['role']=='system'),'')
700
- user = next((m['content'] for m in messages if m['role']=='user'),'')
701
- r = gemini_model.generate_content(f'{system}\n\n{user}')
702
- answer = r.text
703
- if not answer: return None, None
704
- arabic = sum(1 for c in answer if '\u0600' <= c <= '\u06ff')
705
- if arabic / max(len([c for c in answer if c.strip()]),1) < 0.3: return None, None
706
- return answer, 'Gemini'
707
- except Exception as e:
708
- logger.warning(f"Gemini: {str(e)[:40]}")
709
- return None, None
 
 
 
 
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
- result, model = _call_gemini(messages)
 
 
 
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.0.0',
821
  lifespan=lifespan
822
  )
823
 
@@ -846,15 +833,21 @@ class QuestionResponse(BaseModel):
846
 
847
  @app.get('/')
848
  def root():
849
- return {'name': 'Saudi Legal AI', 'version': '4.0.0', 'status': 'running', 'docs': '/docs'}
 
 
 
 
 
850
 
851
  @app.get('/health')
852
  def health():
853
  return {
854
- 'status': 'healthy',
855
- 'chunks': vectorstore._collection.count() if vectorstore else 0,
856
- 'models': [m['name'] for m in ACTIVE_MODELS] + (['Gemini'] if GEMINI_AVAILABLE else []),
857
- 'version': '4.0.0'
 
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({'time': datetime.now().strftime('%H:%M:%S'),
884
- 'ip': ip, 'question': req.question[:60],
885
- 'model': result['model'], 'status': status, 'ms': ms})
 
 
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': stats['total'], 'success': stats['success'],
902
- 'blocked': stats['blocked'], 'unique_ips': len(active_ips),
903
- 'recent': list(request_log)[:10]
 
 
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')