WafaaFraih commited on
Commit
d891471
·
verified ·
1 Parent(s): 9719579

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +636 -519
main.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
- Saudi Legal AI API — v3.0
3
- FastAPI + RAG + Multi-model fallback
4
  """
5
  import os, gc, re, time, logging
6
  from collections import deque, defaultdict
@@ -10,19 +10,18 @@ from dotenv import load_dotenv
10
 
11
  from fastapi import FastAPI, HTTPException, Request
12
  from fastapi.middleware.cors import CORSMiddleware
13
- from fastapi.responses import JSONResponse
14
  from pydantic import BaseModel
15
 
16
  from groq import Groq
17
- from openai import OpenAI
18
- from huggingface_hub import InferenceClient, login
19
  from langchain_core.documents import Document
20
  from langchain_text_splitters import RecursiveCharacterTextSplitter
21
  from langchain_community.vectorstores import Chroma
22
  from langchain_community.embeddings import SentenceTransformerEmbeddings
23
  from datasets import load_dataset
24
  from rank_bm25 import BM25Okapi
25
- from rapidfuzz import fuzz, process
26
  import chromadb
27
 
28
  load_dotenv()
@@ -30,345 +29,323 @@ logging.basicConfig(level=logging.INFO)
30
  logger = logging.getLogger(__name__)
31
 
32
  # ══════════════════════════════════════════════════════════
33
- # Config من Environment Variables
34
  # ══════════════════════════════════════════════════════════
35
- GROQ_API_KEY = os.getenv('GROQ_API_KEY', '')
36
- OPENROUTER_API_KEY = os.getenv('OPENROUTER_API_KEY', '')
37
- HF_TOKEN = os.getenv('HF_TOKEN', '')
38
- API_SECRET_KEY = os.getenv('API_SECRET_KEY', 'saudi-legal-2024')
39
- HF_REPO_ID = os.getenv('HF_REPO_ID', 'WafaaFraih/saudi-legal-moj')
40
- CHROMA_PATH = os.getenv('CHROMA_PATH', './chroma_db')
41
 
42
  # ══════════════════════════════════════════════════════════
43
  # Global State
44
  # ══════════════════════════════════════════════════════════
45
- vectorstore = None
46
- bm25_index = None
47
- bm25_texts = []
48
- bm25_metadatas = []
49
- embeddings = None
50
- ACTIVE_MODELS = []
51
- hf_client = None
52
- working_or_models = []
53
- groq_client = None
54
- or_client = None
55
- request_log = deque(maxlen=500)
56
- stats = {'total': 0, 'success': 0, 'blocked': 0, 'errors': 0}
57
- active_ips = {}
58
- _expansion_cache = {}
59
- _rewrite_cache = {}
60
 
61
  # ══════════════════════════════════════════════════════════
62
- # Startup: تحميل كل حاجة
63
- # ══════════════════════════════════════════════════════════
64
- @asynccontextmanager
65
- async def lifespan(app: FastAPI):
66
- await startup()
67
- yield
68
- logger.info("Shutting down...")
69
-
70
- async def startup():
71
- global vectorstore, bm25_index, bm25_texts, bm25_metadatas
72
- global embeddings, ACTIVE_MODELS, hf_client, working_or_models
73
- global groq_client, or_client
74
-
75
- logger.info("🚀 Starting Saudi Legal AI...")
76
-
77
- # ── Clients ───────────────────────────────────────────
78
- groq_client = Groq(api_key=GROQ_API_KEY)
79
- or_client = OpenAI(api_key=OPENROUTER_API_KEY, base_url='https://openrouter.ai/api/v1')
80
-
81
- # ── Groq Models ───────────────────────────────────────
82
- for m in [
83
- {'client': 'groq', 'model': 'llama-3.3-70b-versatile', 'name': 'Groq llama-3.3'},
84
- {'client': 'groq', 'model': 'llama3-70b-8192', 'name': 'Groq llama3-70b'},
85
- {'client': 'groq', 'model': 'llama-3.1-8b-instant', 'name': 'Groq llama-3.1-8b'},
86
- ]:
87
- try:
88
- groq_client.chat.completions.create(
89
- model=m['model'], messages=[{'role': 'user', 'content': 'hi'}],
90
- max_tokens=3, timeout=10)
91
- ACTIVE_MODELS.append(m)
92
- logger.info(f"✅ {m['name']}")
93
- except Exception as e:
94
- logger.warning(f"❌ {m['name']}: {str(e)[:30]}")
95
-
96
- # ── Qwen HF ───────────────────────────────────────────
97
- try:
98
- login(token=HF_TOKEN, add_to_git_credential=False)
99
- client = InferenceClient(model='Qwen/Qwen2.5-72B-Instruct', token=HF_TOKEN)
100
- client.chat_completion(messages=[{'role': 'user', 'content': 'hi'}], max_tokens=3)
101
- hf_client = client
102
- logger.info("✅ Qwen 72B HF")
103
- except Exception as e:
104
- logger.warning(f"❌ Qwen HF: {str(e)[:40]}")
105
-
106
- # ── OpenRouter ────────────────────────────────────────
107
- for model in ['qwen/qwen3-32b:free', 'meta-llama/llama-3.3-70b-instruct:free',
108
- 'deepseek/deepseek-v3:free', 'google/gemma-3-12b-it:free']:
109
- try:
110
- or_client.chat.completions.create(
111
- model=model, messages=[{'role': 'user', 'content': 'hi'}],
112
- max_tokens=3, timeout=10)
113
- working_or_models.append(model)
114
- logger.info(f"✅ OR: {model}")
115
- if len(working_or_models) >= 2: break
116
- except: pass
117
-
118
- # ── Embeddings ────────────────────────────────────────
119
- logger.info("🔄 Loading embeddings...")
120
- embeddings = SentenceTransformerEmbeddings(
121
- model_name='sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
122
-
123
- # ── ChromaDB (Persistent) ─────────────────────────────
124
- chroma_client_persist = chromadb.PersistentClient(path=CHROMA_PATH)
125
-
126
- # لو الـ DB موجودة خلاص
127
- try:
128
- collection = chroma_client_persist.get_collection('saudi_legal_v3')
129
- if collection.count() > 100:
130
- logger.info(f"✅ ChromaDB loaded from disk: {collection.count()} chunks")
131
- vectorstore = Chroma(
132
- client=chroma_client_persist,
133
- collection_name='saudi_legal_v3',
134
- embedding_function=embeddings
135
- )
136
- else:
137
- raise Exception("Empty collection")
138
- except:
139
- logger.info("📥 Loading dataset from HuggingFace...")
140
- dataset = load_dataset(HF_REPO_ID, token=HF_TOKEN, split='train')
141
- logger.info(f"✅ {len(dataset)} articles")
142
-
143
- docs = [
144
- Document(
145
- page_content=item['text'],
146
- metadata={
147
- 'article_number': item.get('article_number', ''),
148
- 'law_name': item.get('law_name', ''),
149
- 'law_type': item.get('law_type', ''),
150
- 'source': item.get('source', ''),
151
- }
152
- )
153
- for item in dataset if len(item.get('text', '')) > 30
154
- ]
155
-
156
- # قوانين إضافية
157
- for a in EXTRA_LAWS:
158
- docs.append(Document(page_content=a['text'], metadata={
159
- 'article_number': a['article_number'],
160
- 'law_name': a['law_name'],
161
- 'law_type': a['law_type'],
162
- 'source': a['source'],
163
- }))
164
-
165
- splitter = RecursiveCharacterTextSplitter(chunk_size=1500, chunk_overlap=200)
166
- chunks = splitter.split_documents(docs)
167
-
168
- vectorstore = Chroma.from_documents(
169
- documents=chunks,
170
- embedding=embeddings,
171
- client=chroma_client_persist,
172
- collection_name='saudi_legal_v3'
173
- )
174
- logger.info(f"✅ ChromaDB created: {vectorstore._collection.count()} chunks")
175
-
176
- # ── BM25 ──────────────────────────────────────────────
177
- logger.info("🔄 Building BM25...")
178
- all_chunks = vectorstore.get()
179
- bm25_texts = all_chunks['documents']
180
- bm25_metadatas = all_chunks['metadatas']
181
- stop_words = {'من','في','على','إلى','عن','مع','هي','هو','ما','لا','أن','إن'}
182
-
183
- def tokenize(text):
184
- return [w for w in text.split() if len(w) > 2 and w not in stop_words]
185
-
186
- bm25_index = BM25Okapi([tokenize(t) for t in bm25_texts])
187
- logger.info(f"✅ BM25: {len(bm25_texts)} docs")
188
- logger.info("✅ Saudi Legal AI Ready!")
189
-
190
-
191
- # ══════════════════════════════════════════════════════════
192
- # Data
193
  # ══════════════════════════════════════════════════════════
194
  EXTRA_LAWS = [
195
- {'text': 'المادة الثالثة والثمانون: عند انت��اء عقد العمل يستحق العامل مكافأة عن مدة خدمته تحسب على أساس أجر نصف شهر عن كل سنة من السنوات الخمس الأولى، وأجر شهر عن كل سنة بعد ذلك.',
196
- 'article_number': 'المادة الثالثة والثمانون', 'law_name': 'نظام العمل', 'law_type': 'نظام', 'source': 'hrsd.gov.sa'},
197
- {'text': 'المادة الثامنة والثمانون: إذا أنهى صاحب العمل عقد العمل دون سبب مشروع وجب عليه دفع تعويض يعادل أجر خمسة عشر يوماً عن كل سنة خدمة ولا يقل عن أجر شهرين.',
198
- 'article_number': 'المادة الثامنة والثمانون', 'law_name': 'نظام العمل', 'law_type': 'نظام', 'source': 'hrsd.gov.sa'},
199
- {'text': 'المادة الخامسة والستون: لا يجوز تشغيل العامل أكثر من ثماني ساعات يومياً وثمان وأربعين ساعة في الأسبوع. وفي رمضان تنخفض إلى ست ساعات.',
200
- 'article_number': 'المادة الخامسة والستون', 'law_name': 'نظام العمل', 'law_type': 'نظام', 'source': 'hrsd.gov.sa'},
201
- {'text': 'المادة الثالثة والستون: للعامل الذي أمضى سنة كاملة إجازة سنوية واحد وعشرون يوماً تزداد إلى ثلاثين يوماً إذا أمضى عشر سنوات.',
202
- 'article_number': 'المادة الثالثة والستون', 'law_name': 'نظام العمل', 'law_type': 'نظام', 'source': 'hrsd.gov.sa'},
203
- {'text': 'المادة الثالثة والعشرون: لا يجوز فصل العامل بسبب تقدمه بشكوى. ويعد الفصل تعسفياً ويحق للعامل التعويض.',
204
- 'article_number': 'المادة الثالثة والعشرون', 'law_name': 'نظام العمل', 'law_type': 'نظام', 'source': 'hrsd.gov.sa'},
205
- {'text': 'المادة الثالثة: يعاقب بالسجن مدة لا تزيد على سنة وبغرامة لا تزيد على خمسمائة ألف ريال كل شخص يرتكب جريمة الدخول غير المشروع لموقع إلكتروني.',
206
- 'article_number': 'المادة الثالثة', 'law_name': 'نظام مكافحة الجرائم المعلوماتية', 'law_type': 'نظام', 'source': 'boe.gov.sa'},
207
- {'text': 'المادة الرابعة: لا يجوز معالجة البيانات الشخصية إلا لتحقيق الغرض المشروع مع الحصول على موافقة صريحة من صاحب البيانات.',
208
- 'article_number': 'المادة الرابعة', 'law_name': 'نظام حماية البيانات الشخصية', 'law_type': 'نظام', 'source': 'boe.gov.sa'},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  ]
210
 
211
- SYSTEM_PROMPT = """أنت مساعد قانوني متخصص في الأنظمة والتشريعات السعودية.
212
-
213
- ⚠️ تنبيه مهم: هذه المعلومات للاستئناس فقط وليست استشارة قانونية معتمدة. يُنصح بمراجعة محامٍ مختص.
214
-
215
- تعامل مع كل أنواع الأسئلة (عربي، إنجليزي، عامية).
216
-
217
- طريقة الإجابة:
218
- 📋 المرجع: [اسم النظام] — [رقم المادة]
219
- الإجابة: [إجابة مباشرة]
220
- 📝 التفاصيل:رح مختصر]
221
-
222
- قواعد:
223
- 1. العربية الفصحى فقط في الإجابة
224
- 2. اذكر رقم المادة دايماً
225
- 3. لا تخترع معلومات
226
- 4. للأسئلة العملية: أجب بنعم/لا أولاً"""
227
-
228
- OUT_OF_SCOPE = """أنا مساعد قانوني متخصص في الأنظمة السعودية.
229
 
230
- سؤالك خارج نطاق اختصاصي. ممكن أساعدك في:
231
- • أنظمة وزارة العدل
232
- • نظام العمل السعودي
233
- • نظام مكافحة الجرائم المعلوماتية
234
- • نظام الشركات وحماية البيانات
235
 
236
- ⚠️ تنبيه: المعلومات للاستئناس فقط وليست استشارة قانونية معتمدة."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
  LAW_KEYWORDS = {
239
  'توثيق':'نظام التوثيق','كاتب عدل':'نظام التوثيق','موثق':'نظام التوثيق',
 
240
  'محامي':'نظام المحاماة','محاماة':'نظام المحاماة',
241
- 'مزاولة مهنة':'نظام المحاماة',
242
  'إفلاس':'نظام الإفلاس','تحكيم':'نظام التحكيم',
243
  'إثبات':'نظام الإثبات','تنفيذ':'نظام التنفيذ',
244
- 'متهم':'نظام الإجراءات الجزائية',
 
245
  'زواج':'نظام الأحوال الشخصية','طلاق':'نظام الأحوال الشخصية',
246
  'نفقة':'نظام الأحوال الشخصية','حضانة':'نظام الأحوال الشخصية',
247
  'عقار':'نظام التسجيل العيني للعقار',
248
  'قضاء':'نظام القضاء','قاضي':'نظام القضاء',
 
249
  'غسل أموال':'نظام مكافحة غسل الأموال',
 
 
250
  'أركان العقد':'نظام المعاملات المدنية',
 
 
 
 
 
 
251
  'موظف':'نظام العمل','عا��ل':'نظام العمل',
252
- صل':'نظام العمل','إجازة':'نظام العمل',
253
- هاية خدمة':'نظام العمل','صاحب عمل':'نظام العمل',
254
- 'اشتغلت':'نظام العمل','مكافأة':'نظام العمل',
 
 
 
 
 
255
  'جرائم معلوماتية':'نظام مكافحة الجرائم المعلوماتية',
 
256
  'بيانات شخصية':'نظام حماية البيانات الشخصية',
 
257
  }
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  QUERY_EXPANSION = {
260
- 'شروط رخصة الموثق': 'يشترط في الموثق ما يأتي',
261
- 'شروط مزاولة مهنة المحاماة': 'يشترط فيمن يزاول مهنة المحاماة مقيداً جدول ممارسين',
262
- 'أركان العقد': 'أركان العقد الإيجاب والقبول',
263
- 'عقوبات غسل الأموال': 'يعاقب على جريمة غسل الأموال',
264
- 'أحكام الطلاق': لطلاق حل عقد الزواج رجعي بائن',
265
- قوق المتهم': 'يحق للمتهم الاستعانة بمحامي',
266
- 'هل لي مكافأة': 'يستحق العامل مكافأة نهاية الخدمة',
267
- شتغلت': 'مكافأة نهاية الخدمة يستحق العامل سنوات',
268
- صلوني': 'إنهاء عقد العمل تعويض تعسف',
269
- 'ساعات العمل': ا يجوز تشغيل العامل أكثر من ثماني ساعات',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  }
271
 
272
- SPELL_CORRECTIONS = {
273
- 'مزاولت':'مزاولة','مهنه':هنة','المحاماه':لمحاماة',
274
- 'عقوبت':قوبة','رخصه':'رخصة','السعوديه':لسعودية',
275
- لجزائيه':لجزائية','مكافاه':كافأة',
276
- جرات':'إجراءات','الاموال':'الأموال',
277
- 'احكام':'أحكام','القاضى':'القاضي','فى':'في',
278
- 'تعين':'تعيين','ساعت':'ساعات','غسيل':'غسل',
279
- '٣':'3','٤':'4','٥':'5','١':'1','٢':'2',
280
- 'penality':'penalty','laudering':'laundering',
281
- 'calculat':'calculate','servise':'service',
282
- 'lawer':'lawyer','condtions':'conditions',
283
  }
284
 
285
- LEGAL_TERMS_AR = [
286
- 'إجراءات','محاماة','توثيق','إفلاس','تحكيم',
287
- 'مكافأة','الغرامة','العقوبة','السجن','غسل الأموال',
288
- لقاضي','المحكمة','الزواج','الطلاق','الحضانة',
289
- 'ساعات العمل','مكافأة نهاية الخدمة',
290
- ]
 
291
 
292
- ENGLISH_TO_ARABIC = {
293
- 'end of service': 'مكافأة نهاية الخدمة',
294
- 'end of servise': 'مكافأة نهاية الخدمة',
295
- 'money laundering': 'غسل الأموال',
296
- 'money laudering': 'غسل الأموال',
297
- 'wrongfully terminated': 'فصل تعسفي',
298
- 'wrongful termination': 'فصل تعسفي',
299
- 'if fired': 'عند الفصل',
300
- 'if terminated': 'عند الفصل',
301
- 'i was fired': 'تم فصلي',
302
- 'am i entitled': 'هل يحق لي',
303
- 'what are my rights': 'ما هي حقوقي',
304
- 'my rights': 'حقوقي',
305
- 'how to calculate': 'كيف تحسب',
306
- 'how is calculated': 'كيف تحسب',
307
- 'working hours': 'ساعات العمل',
308
- 'annual leave': 'الإجازة السنوية',
309
- 'lawyer license': 'رخصة المحامي',
310
- 'lawer license': 'رخصة المحامي',
311
- 'data protection': 'حماية البيانات الشخصية',
312
- 'cybercrime': 'الجرائم المعلوماتية',
313
- 'my employer': 'صاحب العمل',
314
- 'i worked': 'اشتغلت',
315
- 'labor law': 'نظام العمل',
316
- 'labour law': 'نظام العمل',
317
- 'saudi arabia': 'المملكة العربية السعودية',
318
- 'penalty for': 'عقوبة',
319
- 'penality for': 'عقوبة',
320
- 'conditions for': 'شروط',
321
- 'what r ': 'ما هي ',
322
- 'labor':'عمل','labour':'عمل','arbitration':'تحكيم',
323
- 'bankruptcy':'إفلاس','lawyer':'محامي','judge':'قاضي',
324
- 'marriage':'زواج','divorce':'طلاق','custody':'حضانة',
325
- 'salary':'الأجر','employee':'عامل','employer':'صاحب عمل',
326
- 'penalty':'عقوبة','fine':'غرامة','rights':'حقوق',
327
- 'terminated':'فُصلت','dismissed':'فُصلت',
328
- }
329
 
330
- COLLOQUIAL = {
331
- 'ايه':'ما','إيه':'ما','ايش':'ما','شو':'ما',
332
- 'اللي':'الذي','عشان':'لأن','ازاي':'كيف',
333
- 'امتى':'متى','فين':'أين','مين':'من','ليه':'لماذا',
334
- }
335
 
336
- ARABIC_PRACTICAL = [
337
- 'كم ساعة','كم يوم','كم مدة','كم سنة','كم راتب',
338
- 'ساعات العمل','أنا موظف','أنا عامل','اشتغلت',
339
- 'فُصلت','فصلوني','صاحب العمل','هل لي','هل يحق',
340
- 'هل أستحق','حقوقي','مستحقاتي',
341
- ]
342
 
343
- LEGAL_KEYWORDS_ALL = list(LAW_KEYWORDS.keys()) + [
344
- 'نظام','قانون','مادة','عقوبة','غرامة','سجن',
345
- 'حق','شرط','إجراء','محكمة','دعوى','ساعة',
346
- 'إجازة','مكافأة','تعويض','فصل','عقد عمل',
347
- 'غسل الأموال','جرائم معلوماتية','بيانات شخصية',
348
- 'أجر','راتب','دوام',
349
- ]
350
 
351
- LEGAL_ENGLISH_ALL = [
352
- 'law','legal','court','judge','regulation','article',
353
- 'penalty','fine','imprisonment','right','obligation',
354
- 'contract','labor','labour','employment',
355
- 'arbitration','bankruptcy','notary','lawyer','attorney',
356
- 'cybercrime','data protection','money laundering',
357
- 'saudi','marriage','divorce','custody','salary',
358
- 'wage','employee','employer','annual leave',
359
- 'terminated','dismissed','wrongful','working hours',
360
- 'end of service','maternity','overtime',
361
- ]
362
 
 
 
 
 
 
 
 
 
 
363
 
364
  # ══════════════════════════════════════════════════════════
365
  # Rate Limiter
366
  # ══════════════════════════════════════════════════════════
367
  class SmartRateLimiter:
368
  def __init__(self):
369
- self.requests = {'groq': deque(), 'qwen_hf': deque(), 'or': deque()}
370
- self.limits = {'groq': 28, 'qwen_hf': 25, 'or': 18}
371
- self.last_used = {'groq': 0, 'qwen_hf': 0, 'or': 0}
372
 
373
  def _clean(self, c):
374
  now = time.time()
@@ -391,134 +368,222 @@ class SmartRateLimiter:
391
 
392
  rate_limiter = SmartRateLimiter()
393
 
394
-
395
  # ══════════════════════════════════════════════════════════
396
- # Generation
397
  # ══════════════════════════════════════════════════════════
398
- def _call_groq(messages):
399
- for m in sorted([m for m in ACTIVE_MODELS if m['client'] == 'groq'],
400
- key=lambda x: 0 if '70b' in x['model'] else 1):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  try:
402
- r = groq_client.chat.completions.create(
403
- model=m['model'], max_tokens=1000, temperature=0.1, messages=messages)
404
- answer = r.choices[0].message.content
405
- arabic = sum(1 for c in answer if '\u0600' <= c <= '\u06ff')
406
- if arabic / max(len([c for c in answer if c.strip()]), 1) < 0.6: continue
407
- return answer, m['name']
408
  except Exception as e:
409
- if '429' in str(e): continue
410
- return None, None
411
-
412
- def _call_qwen(messages):
413
- if not hf_client: return None, None
414
- r = hf_client.chat_completion(messages=messages, max_tokens=800)
415
- return r.choices[0].message.content, 'Qwen 72B HF'
416
 
417
- def _call_or(messages):
418
- for model in working_or_models:
419
  try:
420
- r = or_client.chat.completions.create(model=model, max_tokens=1000, messages=messages)
421
- return r.choices[0].message.content, f'OR-{model.split("/")[1]}'
422
- except: continue
423
- return None, None
 
 
 
424
 
425
- def generate_with_fallback(messages):
426
- for client_name, call_fn in [('groq', lambda: _call_groq(messages)),
427
- ('qwen_hf', lambda: _call_qwen(messages)),
428
- ('or', lambda: _call_or(messages))]:
429
- if rate_limiter.can_use(client_name):
430
- try:
431
- result, model = call_fn()
432
- if result and len(result.strip()) > 50:
433
- rate_limiter.record(client_name)
434
- return result, model
435
- except Exception as e:
436
- if '429' in str(e) or 'rate' in str(e).lower():
437
- for _ in range(rate_limiter.limits[client_name]):
438
- rate_limiter.requests[client_name].append(time.time())
439
- continue
440
  else:
441
- wait = rate_limiter.wait_time(client_name)
442
- if wait > 0:
443
- time.sleep(min(wait + 1, 10))
444
- result, model = call_fn()
445
- if result:
446
- rate_limiter.record(client_name)
447
- return result, model
448
- return None, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
 
 
 
 
 
 
 
 
 
 
450
 
451
  # ══════════════════════════════════════════════════════════
452
- # Pipeline: Spell → Translate → Normalize → RAG
453
  # ══════════════════════════════════════════════════════════
454
- def correct_spelling(text: str) -> str:
455
- words, result = text.split(), []
456
- for word in words:
457
- clean = word.strip('؟،.')
458
- if clean in SPELL_CORRECTIONS:
459
- result.append(SPELL_CORRECTIONS[clean] + word[len(clean):])
460
- continue
461
- if len(word) >= 4 and any('\u0600' <= c <= '\u06ff' for c in word):
462
- match = process.extractOne(word, LEGAL_TERMS_AR, scorer=fuzz.ratio, score_cutoff=75)
463
- if match:
464
- result.append(match[0])
465
- continue
466
- result.append(word)
467
- return ' '.join(result)
468
-
469
- def translate_to_arabic(question: str) -> str:
470
  q_lower = question.lower()
471
- result = question
472
  for eng, ar in sorted(ENGLISH_TO_ARABIC.items(), key=lambda x: -len(x[0])):
473
  if eng.lower() in q_lower:
474
- result = re.sub(re.escape(eng), ar, result, flags=re.IGNORECASE)
475
- q_lower = result.lower()
476
- remaining = [w for w in question.split() if any(c.isascii() and c.isalpha() for c in w) and len(w) > 3]
477
- for eng_word in remaining:
478
- match = process.extractOne(eng_word.lower(), list(ENGLISH_TO_ARABIC.keys()), scorer=fuzz.ratio, score_cutoff=70)
479
- if match:
480
- result = re.sub(re.escape(eng_word), ENGLISH_TO_ARABIC[match[0]], result, flags=re.IGNORECASE)
481
- return result.strip()
482
-
483
- def full_pipeline_normalize(question: str):
484
- log = []
485
- corrected = correct_spelling(question)
486
- if corrected != question:
487
- log.append(f'spell: {corrected}')
488
- question = corrected
489
- if any(c.isascii() and c.isalpha() for c in question):
490
- translated = translate_to_arabic(question)
491
- if translated != question:
492
- log.append(f'translated: {translated}')
493
- question = translated
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  question = ' '.join(question.split())
495
- question = re.sub(r'[؟?]+', '؟', question)
 
 
 
 
 
 
 
 
 
 
496
  for col, formal in COLLOQUIAL.items():
497
  question = re.sub(rf'\b{col}\b', formal, question, flags=re.IGNORECASE)
498
- question = question.strip()
499
- if question and not question.endswith('؟'):
500
- question += '؟'
501
  return question, log
502
 
503
- def is_legal_question(question: str) -> bool:
504
  q_lower = question.lower()
505
- if any(p in question for p in ARABIC_PRACTICAL): return True
506
- if any(kw in question for kw in LEGAL_KEYWORDS_ALL): return True
507
- if any(kw in q_lower for kw in LEGAL_ENGLISH_ALL): return True
508
- practical_en = ['my employer','i work','i worked','i was fired','am i entitled',
509
- 'my rights','terminated','dismissed','wrongfully','working hours']
510
- if any(p in q_lower for p in practical_en): return True
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  return False
512
 
513
- def detect_question_type(question: str) -> str:
514
- q_lower = question.lower()
515
- practical = ['أنا موظف','أنا عامل','اشتغلت','فصلوني','هل لي','هل يحق',
516
- 'my employer','i worked','am i entitled','if fired']
517
- if any(p in q_lower for p in practical): return 'practical'
518
- if any(p in q_lower for p in ['penalty','عقوبة','غرامة','سجن','fine']): return 'penalty'
519
- return 'general'
520
 
521
- def expand_query(question: str) -> list:
522
  if question in _expansion_cache: return _expansion_cache[question]
523
  result = [question]
524
  for pattern, expansion in QUERY_EXPANSION.items():
@@ -526,16 +591,44 @@ def expand_query(question: str) -> list:
526
  result = [question, expansion]
527
  _expansion_cache[question] = result
528
  return result
529
- cleaned = re.sub(r'^(ما هي|ما هو|هل|كيف|متى)\s+', '', question).replace('؟', '').strip()
530
- if cleaned and cleaned != question: result.append(cleaned)
531
- words = [w for w in question.split() if len(w) > 3 and w not in {'هي','هو','ما','في','على','من','إلى'}]
 
 
 
 
 
 
 
 
 
 
532
  if words: result.append(' '.join(words[:4]))
533
  _expansion_cache[question] = result
534
  return result
535
 
536
- def bm25_search_fn(query, k=5, target_law=None):
537
- stop_words = {'من','في','على','إلى','عن','مع','هي','هو','ما','لا','أن','إن'}
538
- tokens = [w for w in query.split() if len(w) > 2 and w not in stop_words]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  scores = bm25_index.get_scores(tokens)
540
  results = []
541
  for idx in scores.argsort()[::-1]:
@@ -552,7 +645,7 @@ def rerank_docs(docs, question, target_law=None):
552
  score = 0
553
  if target_law and doc.metadata.get('law_name') == target_law: score += 8
554
  score += sum(2 for w in qwords if w in doc.page_content)
555
- if 'المادة' in doc.metadata.get('article_number', ''): score += 3
556
  score += min(len(doc.page_content) // 200, 3)
557
  scored.append((score, doc))
558
  scored.sort(key=lambda x: x[0], reverse=True)
@@ -560,7 +653,7 @@ def rerank_docs(docs, question, target_law=None):
560
 
561
  def calculate_coverage(question, docs):
562
  if not docs: return 0.0
563
- words = [w for w in question.split() if len(w) > 3]
564
  if not words: return 1.0
565
  all_text = ' '.join(d.page_content for d in docs)
566
  return sum(1 for w in words if w in all_text or (len(w) >= 4 and w[:4] in all_text)) / len(words)
@@ -568,39 +661,90 @@ def calculate_coverage(question, docs):
568
  def build_context(docs):
569
  parts = []
570
  for i, doc in enumerate(docs):
571
- law = doc.metadata.get('law_name', '')
572
- article = doc.metadata.get('article_number', '')
573
- parts.append(f'[{"الأكثر صلة" if i==0 else f"مرجع {i+1}"}] {law} — {article}\n{doc.page_content}\n{"─"*40}')
 
574
  return '\n\n'.join(parts)
575
 
576
  def post_process(answer, docs):
577
  answer = answer.strip()
578
- lines = answer.split('\n')
579
- clean = [l for l in lines
580
- if sum(1 for c in l if '\u0600' <= c <= '\u06ff') / max(len(l.replace(' ','')), 1) > 0.3
581
- or any(s in l for s in ['📋','✅','📝','⚠️','•','-','─'])]
582
- return '\n'.join(clean).strip() if clean else answer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
583
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
 
585
  def ask_legal_core(question: str) -> dict:
586
  original = question
587
- question, log = full_pipeline_normalize(question)
588
 
589
  if not is_legal_question(question):
590
  test_docs = vectorstore.similarity_search(question, k=3)
591
- if calculate_coverage(question, test_docs) < 0.4:
592
- return {'answer': OUT_OF_SCOPE, 'sources': [], 'coverage': 0, 'model': 'out_of_scope', 'log': log}
 
593
 
594
- queries = expand_query(question)
595
- q_type = detect_question_type(question)
596
 
597
- target_law = None
598
- for keyword, law in sorted(LAW_KEYWORDS.items(), key=lambda x: -len(x[0])):
599
- if keyword in question:
600
- target_law = law
601
- break
602
 
603
- top_k = 6 if len(question.split()) <= 5 else 8 if len(question.split()) <= 10 else 10
604
  k_per_query = max(3, top_k // len(queries))
605
  all_docs = []
606
 
@@ -616,7 +760,7 @@ def ask_legal_core(question: str) -> dict:
616
 
617
  bm25_docs = []
618
  keyword_docs = []
619
- for q in queries: bm25_docs.extend(bm25_search_fn(q, k=5, target_law=target_law))
620
 
621
  if target_law:
622
  all_in_law = vectorstore.get(where={'law_name': target_law})
@@ -635,120 +779,97 @@ def ask_legal_core(question: str) -> dict:
635
  final_docs = rerank_docs(combined, question, target_law)[:top_k]
636
  coverage = calculate_coverage(question, final_docs)
637
 
 
 
 
 
638
  if coverage < 0.5 and target_law:
639
- for d in vectorstore.similarity_search(question, k=top_k) + bm25_search_fn(question, k=5):
640
  key = d.page_content[:50]
641
  if key not in seen: seen.add(key); combined.append(d)
642
  final_docs = rerank_docs(combined, question, target_law)[:top_k]
643
  coverage = calculate_coverage(question, final_docs)
644
 
645
- if len(final_docs) == 0 or coverage < 0.35:
646
- if q_type == 'practical':
647
- docs = vectorstore.similarity_search(question, k=8, filter={'law_name': 'نظام العمل'})
648
- if docs:
649
- context = build_context(docs[:5])
650
- answer, model = generate_with_fallback([
651
- {'role': 'system', 'content': SYSTEM_PROMPT},
652
- {'role': 'user', 'content': f'المواد:\n{context}\n\nالسؤال: {question}\nملاحظة: سؤال عملي.'}
653
- ])
654
- if answer:
655
- return {'answer': post_process(answer, docs[:5]),
656
- 'sources': [{'law': d.metadata.get('law_name',''), 'article': d.metadata.get('article_number','')} for d in docs[:3]],
657
- 'coverage': 50, 'model': model, 'log': log}
658
- return {'answer': OUT_OF_SCOPE, 'sources': [], 'coverage': 0, 'model': 'quality_check', 'log': log}
659
-
660
- context = build_context(final_docs)
661
- type_hint = '\nملاحظة: سؤال عملي — أجب بنعم/لا ثم اشرح الحق.' if q_type == 'practical' else \
662
- '\nملاحظة: اذكر العقوبة بدقة مع رقم المادة.' if q_type == 'penalty' else ''
663
 
 
664
  answer, model_used = generate_with_fallback([
665
  {'role': 'system', 'content': SYSTEM_PROMPT},
666
- {'role': 'user', 'content': f'المواد:\n{context}\n\nالسؤال: {question}{type_hint}'}
667
  ])
668
 
669
  if not answer:
670
- return {'answer': 'كل الموديلات محجوزة حالياً. حاول مرة أخرى.', 'sources': [], 'coverage': 0, 'model': '', 'log': log}
671
 
672
  return {
673
- 'answer': post_process(answer, final_docs),
674
- 'sources': [{'law': d.metadata.get('law_name',''), 'article': d.metadata.get('article_number','')} for d in final_docs[:3]],
675
- 'coverage': round(coverage * 100),
676
- 'model': model_used,
677
- 'log': log
678
  }
679
 
680
-
681
  # ══════════════════════════════════════════════════════════
682
- # FastAPI App
683
  # ══════════════════════════════════════════════════════════
684
  app = FastAPI(
685
- title='⚖️ Saudi Legal AI',
686
  description='نظام الذكاء الاصطناعي للقانون السعودي — وزارة العدل',
687
- version='3.0.0',
688
  lifespan=lifespan
689
  )
690
 
691
- app.add_middleware(CORSMiddleware,
692
- allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])
693
-
694
 
695
- # ── Auth ──────────────────────────────────────────────────
696
- def verify_api_key(request: Request):
697
- api_key = request.headers.get('X-API-Key') or request.query_params.get('api_key')
698
- if api_key != API_SECRET_KEY:
699
- raise HTTPException(status_code=401, detail='API Key غلط أو ناقص')
700
- return api_key
701
 
 
 
 
 
 
 
702
 
703
- # ── Models ────────────────────────────────────────────────
704
  class QuestionRequest(BaseModel):
705
  question: str
706
  include_sources: bool = True
707
 
708
  class QuestionResponse(BaseModel):
709
- answer: str
710
- sources: list
711
- coverage: int
712
- model: str
713
  duration_ms: int
714
- disclaimer: str = "⚠️ هذه المعلومات للاستئناس فقط وليست استشارة قانونية معتمدة. يُنصح بمراجعة محامٍ مختص."
715
 
716
-
717
- # ── Endpoints ─────────────────────────────────────────────
718
  @app.get('/')
719
  def root():
720
- return {
721
- 'name': 'Saudi Legal AI ⚖️',
722
- 'version': '3.0.0',
723
- 'status': 'running',
724
- 'docs': '/docs'
725
- }
726
 
727
  @app.get('/health')
728
  def health():
729
  return {
730
  'status': 'healthy',
731
  'chunks': vectorstore._collection.count() if vectorstore else 0,
732
- 'models': [m['name'] for m in ACTIVE_MODELS],
733
- 'qwen': bool(hf_client),
734
- 'or_models': len(working_or_models),
735
- 'version': '3.0.0'
736
  }
737
 
738
  @app.post('/ask', response_model=QuestionResponse)
739
- async def ask(
740
- req: QuestionRequest,
741
- request: Request,
742
- ):
743
  if not req.question.strip():
744
  raise HTTPException(status_code=400, detail='السؤال فاضي!')
745
-
746
  if len(req.question) > 1000:
747
- raise HTTPException(status_code=400, detail='السؤال طويل جداً (الحد 1000 حرف)')
748
 
749
- ip = request.headers.get('X-Forwarded-For', 'unknown').split(',')[0].strip()
750
- t0 = time.time()
751
 
 
 
 
 
752
  try:
753
  result = ask_legal_core(req.question)
754
  except Exception as e:
@@ -757,42 +878,38 @@ async def ask(
757
  raise HTTPException(status_code=500, detail='خطأ في المعالجة')
758
 
759
  ms = int((time.time() - t0) * 1000)
760
- status = 'blocked' if result['model'] in ['out_of_scope', 'quality_check'] else 'success'
761
-
762
- # Log
763
- request_log.appendleft({
764
- 'time': datetime.now().strftime('%H:%M:%S'),
765
- 'ip': ip, 'question': req.question[:60],
766
- 'model': result['model'], 'status': status, 'ms': ms
767
- })
768
  stats['total'] += 1
769
  stats[status if status in stats else 'errors'] += 1
770
  active_ips[ip] = active_ips.get(ip, 0) + 1
771
 
772
  return QuestionResponse(
773
- answer = result['answer'],
774
- sources = result['sources'] if req.include_sources else [],
775
- coverage = result['coverage'],
776
- model = result['model'],
777
- duration_ms = ms
778
  )
779
 
780
  @app.get('/stats')
781
  def get_stats():
782
  return {
783
- 'total': stats['total'],
784
- 'success': stats['success'],
785
- 'blocked': stats['blocked'],
786
- 'errors': stats['errors'],
787
- 'unique_ips': len(active_ips),
788
- 'top_users': sorted(active_ips.items(), key=lambda x: -x[1])[:5],
789
- 'recent': list(request_log)[:10]
790
  }
791
 
 
 
 
 
 
 
792
 
793
- # ══════════════════════════════════════════════════════════
794
- # Run
795
- # ══════════════════════════════════════════════════════════
796
  if __name__ == '__main__':
797
  import uvicorn
798
- uvicorn.run('main:app', host='0.0.0.0', port=8000, reload=False)
 
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
 
10
 
11
  from fastapi import FastAPI, HTTPException, Request
12
  from fastapi.middleware.cors import CORSMiddleware
 
13
  from pydantic import BaseModel
14
 
15
  from groq import Groq
16
+ import google.generativeai as genai
17
+ from huggingface_hub import login
18
  from langchain_core.documents import Document
19
  from langchain_text_splitters import RecursiveCharacterTextSplitter
20
  from langchain_community.vectorstores import Chroma
21
  from langchain_community.embeddings import SentenceTransformerEmbeddings
22
  from datasets import load_dataset
23
  from rank_bm25 import BM25Okapi
24
+ from rapidfuzz import fuzz, process as fuzz_process
25
  import chromadb
26
 
27
  load_dotenv()
 
29
  logger = logging.getLogger(__name__)
30
 
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'},
98
  ]
99
 
100
+ # ══════════════════════════════════════════════════════════
101
+ # Dictionaries
102
+ # ══════════════════════════════════════════════════════════
103
+ COLLOQUIAL = {
104
+ 'ايه':'ما','إيه':'ما','ايش':'ما','شو':'ما','وش':'ما',
105
+ 'اللي':'الذي','عشان':'لأن','ازاي':'كيف','إزاي':'كيف',
106
+ 'امتى':'متى','فين':'أين','مين':'من','ليه':'لماذا',
107
+ 'عندي':'لدي','عندك':'لديك',
108
+ 'اشتغلت':ملت','فصلوني':'تم فصلي','طردوني':'تم فصلي',
109
+ 'مش':'لا','كمان':'أيضاً','برضو':'أيضاً',
110
+ 'زي':'مثل','اللازم':'يجب','المفروض':'يجب',
111
+ 'مكافاه':'مكافأة','مكافأه':'مكافأة',
112
+ 'رخصه':'رخصة','رخصت':'رخصة',
113
+ 'مزاوله':'مزاولة','المحاماه':'المحاماة',
114
+ }
 
 
 
115
 
116
+ NUMBERS_AR = {
117
+ '1':'واحد','2':'اثنين','3':'ثلاث','4':'أربع','5':'خمس',
118
+ '6':'ست','7':'سبع','8':'ثماني','9':'تسع','10':'عشر',
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': 'كيف تحسب',
135
+ 'how to calculat': 'كيف تحسب',
136
+ 'i was dismissed': 'تم فصلي',
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 = {
208
  'توثيق':'نظام التوثيق','كاتب عدل':'نظام التوثيق','موثق':'نظام التوثيق',
209
+ 'مزاولة مهنة المحاماة':'نظام المحاماة','مزاولة مهنة':'نظام المحاماة',
210
  'محامي':'نظام المحاماة','محاماة':'نظام المحاماة',
211
+ 'ترخيص المحاماة':'نظام المحاماة',
212
  'إفلاس':'نظام الإفلاس','تحكيم':'نظام التحكيم',
213
  'إثبات':'نظام الإثبات','تنفيذ':'نظام التنفيذ',
214
+ 'متهم':'نظام الإجراءات الجزائية','جزائي':'نظام الإجراءات الجزائية',
215
+ 'مرافعات':'نظام المرافعات الشرعية',
216
  'زواج':'نظام الأحوال الشخصية','طلاق':'نظام الأحوال الشخصية',
217
  'نفقة':'نظام الأحوال الشخصية','حضانة':'نظام الأحوال الشخصية',
218
  'عقار':'نظام التسجيل العيني للعقار',
219
  'قضاء':'نظام القضاء','قاضي':'نظام القضاء',
220
+ 'تعيين القضاة':'نظام القضاء','متطلبات تعيين':'نظام القضاء',
221
  'غسل أموال':'نظام مكافحة غسل الأموال',
222
+ 'غسل الأموال':'نظام مكافحة غسل الأموال',
223
+ 'غسيل الأموال':'نظام مكافحة غسل الأموال',
224
  'أركان العقد':'نظام المعاملات المدنية',
225
+ 'معاملات مدنية':'نظام المعاملات المدنية',
226
+ 'مكافأة نهاية الخدمة':'نظام العمل','مكافأة نهاية':'نظام العمل',
227
+ 'نهاية الخدمة':'نظام العمل','بدل نهاية':'نظام العمل',
228
+ 'صاحب عمل':'نظام العمل','عقد عمل':'نظام العمل',
229
+ 'ساعات العمل':'نظام العمل','إجازة سنوية':'نظام العمل',
230
+ 'فصل تعسفي':'نظام العمل','الفصل التعسفي':'نظام العمل',
231
  'موظف':'نظام العمل','عا��ل':'نظام العمل',
232
+ 'مكافأة':'نظام العمل','اشتغلت':'نظام العمل','عملت':'نظام العمل',
233
+ 'فصلوني':'نظام العمل','طردوني':'نظام العمل',
234
+ م فصلي':'نظام العمل','فُصلت':'نظام العمل',
235
+ 'راتب':'نظام العمل','أجر':'نظام العمل',
236
+ 'استقالة':'نظام العمل','إشعار':'نظام العمل',
237
+ 'حقوقي':'نظام العمل','مستحقاتي':'نظام العمل',
238
+ 'كيفية حساب بدل':'نظام العمل',
239
+ 'لقد تم فصلي':'نظام العمل','ما هي حقوقي':'نظام العمل',
240
  'جرائم معلوماتية':'نظام مكافحة الجرائم المعلوماتية',
241
+ 'اختراق':'نظام مكافحة الجرائم المعلوماتية',
242
  'بيانات شخصية':'نظام حماية البيانات الشخصية',
243
+ 'حماية البيانات':'نظام حماية البيانات الشخصية',
244
  }
245
 
246
+ LEGAL_KEYWORDS = [
247
+ 'نظام','قانون','لائحة','مادة','عقوبة','غرامة','سجن',
248
+ 'محكمة','قاضي','حكم','دعوى','متهم','عقد','زواج',
249
+ 'طلاق','حضانة','إفلاس','تحكيم','توثيق','محامي',
250
+ 'تسجيل','عقار','إجراء','شرط','حق','التزام','رخصة',
251
+ 'جريمة','اتفاق','تنفيذ','مرافعة','موظف','عامل',
252
+ 'مكافأة','تعويض','إجازة','أجر','فصل','خدمة',
253
+ 'بيانات','معلوماتية','راتب','استقالة',
254
+ 'حقوق','حقوقي','مستحقات','مستحقاتي','ترخيص',
255
+ ]
256
+
257
+ PRACTICAL_PATTERNS = [
258
+ 'أنا موظف','أنا عامل','اشتغلت','فصلوني','هل لي',
259
+ 'هل يحق','هل أستحق','صاحب العمل','في شركة',
260
+ 'طردوني','حقي','حقوقي','مستحقاتي',
261
+ 'تم فصلي','فُصلت','عملت',
262
+ ]
263
+
264
  QUERY_EXPANSION = {
265
+ 'شروط مزاولة مهنة المحاماة': 'يشترط فيمن يزاول مهنة المحاماة سعودي الجنسية مقيداً جدول المحامين الممارسين',
266
+ 'شروط رخصة الموثق': 'يشترط في الموثق سعودي شريعة امتحان',
267
+ 'إجراءات الإفلاس': 'إجراء التصفية التسوية الوقائية',
268
+ 'أركان العقد': 'أركان العقد الإيجاب والقبول',
269
+ 'عقوبات غسل الأموال': 'يعاقب على جريمة غسل الأموال سجن غرامة',
270
+ 'أحكام الطلاق': 'الطلاق رجعي بائن حل عقد الزواج',
271
+ 'أحكام الحضانة': 'الحضانة حاضن محضون حفظ الولد',
272
+ 'حقوق المتهم': حق للمتهم محامي دفاع تحقيق',
273
+ 'شروط اتفاق التحكيم': تفاق التحكيم مكتوب باطل',
274
+ 'إجراءات تسجيل العقار': 'طلب القيد إدارة التسجيل العقاري',
275
+ 'شروط تعيين القاضي': 'يشترط فيمن يُعيَّن في وظائف القضاء أن يكون سعودي الجنسية بالأصل',
276
+ 'متطلبات تعيين القضاة': 'يشترط فيمن يُعيَّن في وظائف القضاء سعودي شريعة إسلامية',
277
+ 'شروط ترخيص المحاماة': 'يشترط فيمن يزاول مهنة المحاماة أن يكون سعودي الجنسية مقيداً في جدول المحامين',
278
+ 'ترخيص المحاماة': 'يشترط فيمن يزاول مهنة المحاماة سعودي الجنسية شهادة شريعة',
279
+ 'مكافأة نهاية الخدمة': 'يستحق العامل مكافأة عن مدة خدمته نصف شهر سنة',
280
+ 'هل لي مكافأة': 'يستحق العامل مكافأة نهاية الخدمة',
281
+ 'اشتغلت': 'يستحق العامل مكافأة عن مدة خدمته',
282
+ 'عملت': 'يستحق العامل مكافأة عن مدة خدمته',
283
+ 'فصل بدون سبب': 'إنهاء عقد العمل تعسف تعويض',
284
+ 'ساعات العمل': 'لا يجوز تشغيل العامل أكثر من ثماني ساعات',
285
+ 'الإجازة السنوية': 'مدة إجازة العامل السنوية واحد وعشرون يوماً',
286
+ 'فصلوني': 'إنهاء عقد العمل تعسفي تعويض فصل',
287
+ 'طردوني': 'إنهاء عقد العمل تعسفي تعويض فصل',
288
+ 'تم فصلي': 'إنهاء عقد العمل تعسفي تعويض فصل',
289
+ 'فُصلت': 'إنهاء عقد العمل تعسفي تعويض فصل',
290
+ 'حقوقي': 'حقوق العامل يستحق مكافأة تعويض نظام العمل',
291
+ 'مستحقاتي': 'يستحق العامل مكافأة نهاية الخدمة تعويض',
292
+ 'كيفية حساب بدل نهاية': 'مكافأة نهاية الخدمة يستحق العامل نصف شهر سنة',
293
+ 'عقوبة غسيل الأموال': 'يعاقب على جريمة غسل الأموال سجن غرامة',
294
  }
295
 
296
+ FUZZY_WHITELIST = {
297
+ 'شروط','حقوق','تعويض',كافأة','إجازة','ساعات',
298
+ 'موظف',امل','محامي','قاضي','زواج','طلاق','حضانة',
299
+ 'نظام','قانون','مادة','غرامة','سجن','جريمة',
300
+ 'مستحقات','راتب','استقالة','توثيق','تحكيم','إفلاس',
 
 
 
 
 
 
301
  }
302
 
303
+ LEGAL_VOCABULARY = list(set(
304
+ list(LAW_KEYWORDS.keys()) + list(COLLOQUIAL.keys()) +
305
+ LEGAL_KEYWORDS + list(FUZZY_WHITELIST) +
306
+ ['مكافأة','نهاية','الخدمة','تعويض','فصل','تعسفي',
307
+ 'محكمة','عقوبة','إثبات','تنفيذ','مرافعة','أموال',
308
+ 'عقار','تسجيل','صاحب','شركة','أجر','حقوق']
309
+ ))
310
 
311
+ SYSTEM_PROMPT = """أنت مساعد قانوني متخصص في القانون السعودي.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
+ نطاق عملك: أنظمة وزارة العدل + نظام العمل + الجرائم المعلوماتية + حماية البيانات.
 
 
 
 
314
 
315
+ طريقة الإجابة الإلزامية:
316
+ 📋 المرجع: [اسم النظام] — [رقم المادة]
317
+ الإجابة: [إجابة مباشرة وواضحة]
318
+ 📝 التفاصيل: [شرح مختصر من نص المادة]
 
 
319
 
320
+ ⚠️ تنبيه: المعلومات للاستئناس فقط وليست استشارة قانونية معتمدة.
 
 
 
 
 
 
321
 
322
+ قواعد:
323
+ 1. العربية الفصحى فقط
324
+ 2. اذكر رقم المادة واسم النظام دائماً
325
+ 3. لو السؤال عملي أجب مباشرة بناءً على النصوص
326
+ 4. لا تخترع معلومات
327
+ 5. ابدأ بـ 📋 مباشرة"""
328
+
329
+ OUT_OF_SCOPE_RESPONSE = """أنا مساعد قانوني متخصص في الأنظمة السعودية.
 
 
 
330
 
331
+ يمكنني مساعدتك في:
332
+ • أنظمة وزارة العدل (محاماة، توثيق، أحوال شخصية، إفلاس...)
333
+ • نظام العمل (مكافأة نهاية الخدمة، ساعات العمل، الفصل...)
334
+ • نظام مكافحة الجرائم المعلوماتية
335
+ • نظام حماية البيانات الشخصية
336
+
337
+ ⚠️ تنبيه: المعلومات للاستئناس فقط وليست استشارة قانونية معتمدة.
338
+
339
+ سؤالك خارج نطاق اختصاصي."""
340
 
341
  # ══════════════════════════════════════════════════════════
342
  # Rate Limiter
343
  # ══════════════════════════════════════════════════════════
344
  class SmartRateLimiter:
345
  def __init__(self):
346
+ self.requests = {'groq': deque()}
347
+ self.limits = {'groq': 28}
348
+ self.last_used = {'groq': 0}
349
 
350
  def _clean(self, c):
351
  now = time.time()
 
368
 
369
  rate_limiter = SmartRateLimiter()
370
 
 
371
  # ══════════════════════════════════════════════════════════
372
+ # Startup
373
  # ══════════════════════════════════════════════════════════
374
+ @asynccontextmanager
375
+ async def lifespan(app: FastAPI):
376
+ await startup()
377
+ yield
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'},
390
+ ]:
391
  try:
392
+ groq_client.chat.completions.create(
393
+ model=m['model'], messages=[{'role':'user','content':'hi'}],
394
+ max_tokens=3, timeout=10)
395
+ ACTIVE_MODELS.append(m)
396
+ logger.info(f"✅ {m['name']}")
 
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:
419
+ logger.info(f"✅ ChromaDB loaded: {collection.count()} chunks")
420
+ vectorstore = Chroma(
421
+ client=chroma_client_persist,
422
+ collection_name='saudi_legal_v4',
423
+ embedding_function=embeddings)
 
424
  else:
425
+ raise Exception("Empty")
426
+ except:
427
+ logger.info("Loading dataset from HuggingFace...")
428
+ login(token=HF_TOKEN, add_to_git_credential=False)
429
+ dataset = load_dataset(HF_REPO_ID, token=HF_TOKEN, split='train')
430
+ logger.info(f"✅ {len(dataset)} articles")
431
+
432
+ docs = [
433
+ Document(
434
+ page_content=item['text'],
435
+ 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
+ })
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
472
  # ══════════════════════════════════════════════════════════
473
+ def tokenize_arabic(text):
474
+ stop_words = {'من','في','على','إلى','عن','مع','هي','هو','ما','لا','أن','إن'}
475
+ return [w for w in text.split() if len(w) > 2 and w not in stop_words]
476
+
477
+ def detect_language_ratio(text):
478
+ alpha = [c for c in text if c.isalpha()]
479
+ if not alpha: return 0.0
480
+ return len([c for c in alpha if c.isascii()]) / len(alpha)
481
+
482
+ def translate_with_dict(question):
 
 
 
 
 
 
483
  q_lower = question.lower()
 
484
  for eng, ar in sorted(ENGLISH_TO_ARABIC.items(), key=lambda x: -len(x[0])):
485
  if eng.lower() in q_lower:
486
+ question = re.sub(re.escape(eng), ar, question, flags=re.IGNORECASE)
487
+ q_lower = question.lower()
488
+ return question
489
+
490
+ def translate_with_llm(question):
491
+ if question in _translation_cache: return _translation_cache[question]
492
+ try:
493
+ r = groq_client.chat.completions.create(
494
+ model=ACTIVE_MODELS[0]['model'], max_tokens=200, temperature=0,
495
+ messages=[
496
+ {'role':'system','content':'أنت مترجم متخصص في القانون السعودي. ترجم للعربية الفصحى القانونية فقط بدون شرح.'},
497
+ {'role':'user','content':question}
498
+ ])
499
+ translated = r.choices[0].message.content.strip()
500
+ arabic_ratio = sum(1 for c in translated if '\u0600' <= c <= '\u06ff') / max(len(translated), 1)
501
+ if arabic_ratio > 0.5:
502
+ _translation_cache[question] = translated
503
+ return translated
504
+ except: pass
505
+ return translate_with_dict(question)
506
+
507
+ def smart_translate(question):
508
+ english_ratio = detect_language_ratio(question)
509
+ if english_ratio > 0.40:
510
+ translated = translate_with_llm(question)
511
+ translated = translate_with_dict(translated)
512
+ return translated, 'LLM'
513
+ elif english_ratio > 0.10:
514
+ translated = translate_with_dict(question)
515
+ if translated != question: return translated, 'dict'
516
+ return question, None
517
+
518
+ def fuzzy_correct_word(word, threshold=88):
519
+ if len(word) < 5: return word
520
+ if word in FUZZY_WHITELIST: return word
521
+ result = fuzz_process.extractOne(word, LEGAL_VOCABULARY, scorer=fuzz.ratio, score_cutoff=threshold)
522
+ if result:
523
+ matched, score, _ = result
524
+ if matched != word and matched not in FUZZY_WHITELIST: return matched
525
+ return word
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 = []
540
  question = ' '.join(question.split())
541
+ for n, ar in NUMBERS_AR.items():
542
+ question = re.sub(rf'\b{n}\s*سنين\b', f'{ar} سنوات', question)
543
+ question = re.sub(rf'\b{n}\s*سنة\b', f'{ar} سنوات', question)
544
+ translated, method = smart_translate(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()
555
+ if question and not question.endswith('؟'): question += '؟'
 
556
  return question, log
557
 
558
+ def is_legal_question(question):
559
  q_lower = question.lower()
560
+ if any(p in question for p in PRACTICAL_PATTERNS): return True
561
+ if any(kw in question for kw in LEGAL_KEYWORDS): return True
562
+ if any(kw in question for kw in LAW_KEYWORDS): return True
563
+ english_legal = [
564
+ 'law','legal','court','judge','penalty','fine','imprisonment',
565
+ 'contract','labor','labour','employment','bankruptcy','arbitration',
566
+ 'cybercrime','data protection','money laundering','salary',
567
+ 'employee','employer','end of service','gratuity','rights',
568
+ 'divorce','custody','marriage','notary','lawyer','attorney',
569
+ 'fraud','bribery','hacking','evidence','dismissed','termination','wrongful',
570
+ ]
571
+ if any(kw in q_lower for kw in english_legal): return True
572
+ arabic_words = [w for w in question.split() if len(w) > 3 and any('\u0600' <= c <= '\u06ff' for c in w)]
573
+ if len(arabic_words) >= 3:
574
+ try:
575
+ test_docs = vectorstore.similarity_search(question, k=3)
576
+ if test_docs and calculate_coverage(question, test_docs) >= 0.20:
577
+ return True
578
+ except: pass
579
  return False
580
 
581
+ def detect_target_law(question):
582
+ for keyword, law in sorted(LAW_KEYWORDS.items(), key=lambda x: -len(x[0])):
583
+ if keyword in question: return law
584
+ return None
 
 
 
585
 
586
+ def expand_query(question):
587
  if question in _expansion_cache: return _expansion_cache[question]
588
  result = [question]
589
  for pattern, expansion in QUERY_EXPANSION.items():
 
591
  result = [question, expansion]
592
  _expansion_cache[question] = result
593
  return result
594
+ if any(p in question for p in PRACTICAL_PATTERNS):
595
+ if any(w in question for w in ['تعويض','مكافأة','اشتغلت','عملت','سنوات']):
596
+ result.append('مكافأة نهاية الخدمة يستحق العامل سنوات خدمة')
597
+ elif any(w in question for w in ['فصل','فُصلت','فصلوني','طردوني','تم فصلي']):
598
+ result.append('إنهاء عقد العمل تعويض فصل تعسفي')
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
606
+ {'هي','هو','ما','في','على','من','إلى','عن','هل','لي','يحق','يجب'}]
607
  if words: result.append(' '.join(words[:4]))
608
  _expansion_cache[question] = result
609
  return result
610
 
611
+ def rewrite_query(question):
612
+ if question in _rewrite_cache: return _rewrite_cache[question]
613
+ TRIGGER = ['متطلبات','ضوابط','وضح','اشرح','هل لي','هل يحق','أنا','عندي',
614
+ 'فصلوني','طردوني','تم فصلي','حقوقي','مستحقاتي']
615
+ if not any(w in question for w in TRIGGER): return expand_query(question)
616
+ try:
617
+ if not ACTIVE_MODELS: return expand_query(question)
618
+ r = groq_client.chat.completions.create(
619
+ model=ACTIVE_MODELS[0]['model'], max_tokens=200, temperature=0.2,
620
+ messages=[
621
+ {'role':'system','content':'أعد صياغة السؤال القانوني بـ 3 طرق مختلفة باستخدام مصطلحات النظام السعودي. أرجع 3 أسئلة فقط مفصولة بسطر جديد بدون ترقيم.'},
622
+ {'role':'user','content':question}
623
+ ])
624
+ lines = [l.strip() for l in r.choices[0].message.content.strip().split('\n') if l.strip() and len(l.strip()) > 10][:3]
625
+ result = [question] + lines
626
+ _rewrite_cache[question] = result
627
+ return result
628
+ except: return expand_query(question)
629
+
630
+ def bm25_search(query, k=5, target_law=None):
631
+ tokens = tokenize_arabic(query)
632
  scores = bm25_index.get_scores(tokens)
633
  results = []
634
  for idx in scores.argsort()[::-1]:
 
645
  score = 0
646
  if target_law and doc.metadata.get('law_name') == target_law: score += 8
647
  score += sum(2 for w in qwords if w in doc.page_content)
648
+ if 'المادة' in doc.metadata.get('article_number',''): score += 3
649
  score += min(len(doc.page_content) // 200, 3)
650
  scored.append((score, doc))
651
  scored.sort(key=lambda x: x[0], reverse=True)
 
653
 
654
  def calculate_coverage(question, docs):
655
  if not docs: return 0.0
656
+ words = [w for w in question.split() if len(w) > 3]
657
  if not words: return 1.0
658
  all_text = ' '.join(d.page_content for d in docs)
659
  return sum(1 for w in words if w in all_text or (len(w) >= 4 and w[:4] in all_text)) / len(words)
 
661
  def build_context(docs):
662
  parts = []
663
  for i, doc in enumerate(docs):
664
+ law = doc.metadata.get('law_name','')
665
+ article = doc.metadata.get('article_number','')
666
+ label = 'الأكثر صلة' if i == 0 else f'مرجع {i+1}'
667
+ parts.append(f'[{label}] {law} — {article}\n{doc.page_content}\n{"─"*40}')
668
  return '\n\n'.join(parts)
669
 
670
  def post_process(answer, docs):
671
  answer = answer.strip()
672
+ if docs and '📋' not in answer and 'لم أجد' not in answer:
673
+ law = docs[0].metadata.get('law_name','')
674
+ article = docs[0].metadata.get('article_number','')
675
+ if law and article: answer = f'📋 المرجع: {law} — {article}\n\n{answer}'
676
+ lines = answer.split('\n')
677
+ clean = [l for l in lines if
678
+ sum(1 for c in l if '\u0600' <= c <= '\u06ff') / max(len(l.replace(' ','')),1) > 0.3
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:
685
+ r = groq_client.chat.completions.create(
686
+ model=m['model'], max_tokens=1000, temperature=0.1, messages=messages)
687
+ answer = r.choices[0].message.content
688
+ arabic = sum(1 for c in answer if '\u0600' <= c <= '\u06ff')
689
+ if arabic / max(len([c for c in answer if c.strip()]),1) < 0.6: continue
690
+ return answer, m['name']
691
+ except Exception as e:
692
+ if '429' in str(e): continue
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'):
713
+ try:
714
+ result, model = _call_groq(messages)
715
+ if result:
716
+ rate_limiter.record('groq')
717
+ return result, model
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)
732
 
733
  if not is_legal_question(question):
734
  test_docs = vectorstore.similarity_search(question, k=3)
735
+ if calculate_coverage(question, test_docs) < 0.20:
736
+ return {'answer': OUT_OF_SCOPE_RESPONSE, 'sources': [], 'coverage': 0,
737
+ 'model': 'out_of_scope', 'normalized': question}
738
 
739
+ queries = rewrite_query(question)
740
+ target_law = detect_target_law(question) or detect_target_law(original)
741
 
742
+ work_clues = ['فصل','تعويض','مكافأة','راتب','أجر','ساعات','إجازة',
743
+ 'موظف','عامل','شركة','نهاية الخدمة','حقوقي','مستحقاتي']
744
+ if not target_law and any(w in question or w in original for w in work_clues):
745
+ target_law = 'نظام العمل'
 
746
 
747
+ top_k = 8 if len(question.split()) > 5 else 6
748
  k_per_query = max(3, top_k // len(queries))
749
  all_docs = []
750
 
 
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:
766
  all_in_law = vectorstore.get(where={'law_name': target_law})
 
779
  final_docs = rerank_docs(combined, question, target_law)[:top_k]
780
  coverage = calculate_coverage(question, final_docs)
781
 
782
+ if target_law and not any(d.metadata.get('law_name') == target_law for d in final_docs):
783
+ forced = vectorstore.similarity_search(question, k=top_k, filter={'law_name': target_law})
784
+ if forced: final_docs = forced; coverage = calculate_coverage(question, final_docs)
785
+
786
  if coverage < 0.5 and target_law:
787
+ for d in vectorstore.similarity_search(question, k=top_k, filter={'law_name': target_law}):
788
  key = d.page_content[:50]
789
  if key not in seen: seen.add(key); combined.append(d)
790
  final_docs = rerank_docs(combined, question, target_law)[:top_k]
791
  coverage = calculate_coverage(question, final_docs)
792
 
793
+ if len(final_docs) == 0 or coverage < 0.25:
794
+ return {'answer': OUT_OF_SCOPE_RESPONSE, 'sources': [], 'coverage': 0,
795
+ 'model': 'quality_check', 'normalized': question}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
796
 
797
+ context = build_context(final_docs)
798
  answer, model_used = generate_with_fallback([
799
  {'role': 'system', 'content': SYSTEM_PROMPT},
800
+ {'role': 'user', 'content': f'المواد القانونية:\n{context}\n\nالسؤال: {question}'}
801
  ])
802
 
803
  if not answer:
804
+ return {'answer': 'كل الموديلات محجوزة حالياً.', 'sources': [], 'coverage': 0, 'model': ''}
805
 
806
  return {
807
+ 'answer': post_process(answer, final_docs),
808
+ 'sources': [{'law': d.metadata.get('law_name',''), 'article': d.metadata.get('article_number','')} for d in final_docs[:3]],
809
+ 'coverage': round(coverage * 100),
810
+ 'model': model_used,
811
+ 'normalized': question,
812
  }
813
 
 
814
  # ══════════════════════════════════════════════════════════
815
+ # FastAPI
816
  # ══════════════════════════════════════════════════════════
817
  app = FastAPI(
818
+ title='Saudi Legal AI',
819
  description='نظام الذكاء الاصطناعي للقانون السعودي — وزارة العدل',
820
+ version='4.0.0',
821
  lifespan=lifespan
822
  )
823
 
824
+ app.add_middleware(CORSMiddleware, allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])
 
 
825
 
826
+ user_requests = defaultdict(list)
 
 
 
 
 
827
 
828
+ def check_rate_limit(ip: str, max_per_minute: int = 10) -> bool:
829
+ now = time.time()
830
+ user_requests[ip] = [t for t in user_requests[ip] if now - t < 60]
831
+ if len(user_requests[ip]) >= max_per_minute: return False
832
+ user_requests[ip].append(now)
833
+ return True
834
 
 
835
  class QuestionRequest(BaseModel):
836
  question: str
837
  include_sources: bool = True
838
 
839
  class QuestionResponse(BaseModel):
840
+ answer: str
841
+ sources: list
842
+ coverage: int
843
+ model: str
844
  duration_ms: int
845
+ disclaimer: str = "⚠️ هذه المعلومات للاستئناس فقط وليست استشارة قانونية معتمدة."
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)
861
+ async def ask(req: QuestionRequest, request: Request):
 
 
 
862
  if not req.question.strip():
863
  raise HTTPException(status_code=400, detail='السؤال فاضي!')
 
864
  if len(req.question) > 1000:
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
+
872
+ t0 = time.time()
873
  try:
874
  result = ask_legal_core(req.question)
875
  except Exception as e:
 
878
  raise HTTPException(status_code=500, detail='خطأ في المعالجة')
879
 
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
889
 
890
  return QuestionResponse(
891
+ answer = result['answer'],
892
+ sources = result['sources'] if req.include_sources else [],
893
+ coverage = result['coverage'],
894
+ model = result['model'],
895
+ duration_ms = ms,
896
  )
897
 
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')
907
+ def get_laws():
908
+ law_names = sorted(set(
909
+ m.get('law_name','') for m in vectorstore.get()['metadatas'] if m.get('law_name')
910
+ ))
911
+ return {'laws': law_names, 'total': len(law_names)}
912
 
 
 
 
913
  if __name__ == '__main__':
914
  import uvicorn
915
+ uvicorn.run('main:app', host='0.0.0.0', port=7860, reload=False)