WafaaFraih commited on
Commit
edd49bc
·
verified ·
1 Parent(s): 36eeede

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +22 -0
  2. README.md +4 -6
  3. main.py +799 -0
  4. requirements.txt +15 -0
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # System dependencies
6
+ RUN apt-get update && apt-get install -y \
7
+ gcc g++ curl \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Python dependencies
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # App code
15
+ COPY main.py .
16
+
17
+ # ChromaDB persistent storage
18
+ RUN mkdir -p /app/chroma_db
19
+
20
+ EXPOSE 7860
21
+
22
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md CHANGED
@@ -1,10 +1,8 @@
1
  ---
2
- title: Saudi Legal Api
3
- emoji: 🏆
4
- colorFrom: blue
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
8
  ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: Saudi Legal AI
3
+ emoji: ⚖️
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: docker
7
  pinned: false
8
  ---
 
 
main.py ADDED
@@ -0,0 +1,799 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
7
+ from datetime import datetime
8
+ from contextlib import asynccontextmanager
9
+ from dotenv import load_dotenv
10
+
11
+ from fastapi import FastAPI, HTTPException, Request, Depends
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()
29
+ 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()
375
+ while self.requests[c] and now - self.requests[c][0] > 60:
376
+ self.requests[c].popleft()
377
+
378
+ def can_use(self, c):
379
+ self._clean(c)
380
+ if time.time() - self.last_used[c] < 0.5: return False
381
+ return len(self.requests[c]) < self.limits[c]
382
+
383
+ def record(self, c):
384
+ self.requests[c].append(time.time())
385
+ self.last_used[c] = time.time()
386
+
387
+ def wait_time(self, c):
388
+ self._clean(c)
389
+ if len(self.requests[c]) < self.limits[c]: return 0
390
+ return max(0, 60 - (time.time() - self.requests[c][0]))
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():
525
+ if pattern in question:
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]:
542
+ if len(results) >= k or scores[idx] < 0.1: break
543
+ meta = bm25_metadatas[idx]
544
+ if target_law and meta.get('law_name') != target_law: continue
545
+ results.append(Document(page_content=bm25_texts[idx], metadata=meta))
546
+ return results
547
+
548
+ def rerank_docs(docs, question, target_law=None):
549
+ qwords = [w for w in question.split() if len(w) > 2]
550
+ scored = []
551
+ for doc in docs:
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)
559
+ return [d for _, d in scored]
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)
567
+
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
+
607
+ for q in queries:
608
+ if target_law:
609
+ docs = vectorstore.similarity_search(q, k=k_per_query, filter={'law_name': target_law})
610
+ if len(docs) < 2:
611
+ extra = vectorstore.similarity_search(q, k=2)
612
+ docs += [d for d in extra if d not in docs]
613
+ else:
614
+ docs = vectorstore.similarity_search(q, k=k_per_query)
615
+ all_docs.extend(docs)
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})
623
+ all_words = set(w for q in queries for w in q.split() if len(w) > 2)
624
+ scored_kw = [(sum(1 for w in all_words if w in dt), dt, dm)
625
+ for dt, dm in zip(all_in_law['documents'], all_in_law['metadatas'])
626
+ if sum(1 for w in all_words if w in dt) >= 1]
627
+ scored_kw.sort(reverse=True)
628
+ keyword_docs = [Document(page_content=dt, metadata=dm) for _, dt, dm in scored_kw[:5]]
629
+
630
+ seen, combined = set(), []
631
+ for d in keyword_docs + bm25_docs + all_docs:
632
+ key = d.page_content[:50]
633
+ if key not in seen: seen.add(key); combined.append(d)
634
+
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
+ api_key: str = Depends(verify_api_key)
743
+ ):
744
+ if not req.question.strip():
745
+ raise HTTPException(status_code=400, detail='السؤال فاضي!')
746
+
747
+ if len(req.question) > 1000:
748
+ raise HTTPException(status_code=400, detail='السؤال طويل جداً (الحد 1000 حرف)')
749
+
750
+ ip = request.headers.get('X-Forwarded-For', 'unknown').split(',')[0].strip()
751
+ t0 = time.time()
752
+
753
+ try:
754
+ result = ask_legal_core(req.question)
755
+ except Exception as e:
756
+ logger.error(f"Error: {e}")
757
+ stats['errors'] += 1
758
+ raise HTTPException(status_code=500, detail='خطأ في المعالجة')
759
+
760
+ ms = int((time.time() - t0) * 1000)
761
+ status = 'blocked' if result['model'] in ['out_of_scope', 'quality_check'] else 'success'
762
+
763
+ # Log
764
+ request_log.appendleft({
765
+ 'time': datetime.now().strftime('%H:%M:%S'),
766
+ 'ip': ip, 'question': req.question[:60],
767
+ 'model': result['model'], 'status': status, 'ms': ms
768
+ })
769
+ stats['total'] += 1
770
+ stats[status if status in stats else 'errors'] += 1
771
+ active_ips[ip] = active_ips.get(ip, 0) + 1
772
+
773
+ return QuestionResponse(
774
+ answer = result['answer'],
775
+ sources = result['sources'] if req.include_sources else [],
776
+ coverage = result['coverage'],
777
+ model = result['model'],
778
+ duration_ms = ms
779
+ )
780
+
781
+ @app.get('/stats')
782
+ def get_stats(api_key: str = Depends(verify_api_key)):
783
+ return {
784
+ 'total': stats['total'],
785
+ 'success': stats['success'],
786
+ 'blocked': stats['blocked'],
787
+ 'errors': stats['errors'],
788
+ 'unique_ips': len(active_ips),
789
+ 'top_users': sorted(active_ips.items(), key=lambda x: -x[1])[:5],
790
+ 'recent': list(request_log)[:10]
791
+ }
792
+
793
+
794
+ # ══════════════════════════════════════════════════════════
795
+ # Run
796
+ # ══════════════════════════════════════════════════════════
797
+ if __name__ == '__main__':
798
+ import uvicorn
799
+ uvicorn.run('main:app', host='0.0.0.0', port=8000, reload=False)
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn==0.30.6
3
+ pydantic==2.9.2
4
+ groq==0.11.0
5
+ openai==1.51.0
6
+ huggingface-hub==0.25.2
7
+ langchain-core==0.3.10
8
+ langchain-text-splitters==0.3.0
9
+ langchain-community==0.3.1
10
+ chromadb==0.5.15
11
+ sentence-transformers==3.1.1
12
+ datasets==3.0.1
13
+ rank-bm25==0.2.2
14
+ rapidfuzz==3.10.0
15
+ python-dotenv==1.0.1