Spaces:
Runtime error
Runtime error
Update main.py
Browse files
main.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
-
Saudi Legal AI API —
|
| 3 |
-
FastAPI + RAG +
|
| 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 |
-
|
| 18 |
-
from huggingface_hub import
|
| 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
|
| 34 |
# ══════════════════════════════════════════════════════════
|
| 35 |
-
GROQ_API_KEY
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
CHROMA_PATH = os.getenv('CHROMA_PATH', './chroma_db')
|
| 41 |
|
| 42 |
# ══════════════════════════════════════════════════════════
|
| 43 |
# Global State
|
| 44 |
# ══════════════════════════════════════════════════════════
|
| 45 |
-
vectorstore
|
| 46 |
-
bm25_index
|
| 47 |
-
bm25_texts
|
| 48 |
-
bm25_metadatas
|
| 49 |
-
embeddings
|
| 50 |
-
ACTIVE_MODELS
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
groq_client
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
|
| 61 |
# ══════════════════════════════════════════════════════════
|
| 62 |
-
#
|
| 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 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
]
|
| 210 |
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 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 |
-
|
| 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 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
'ا
|
| 289 |
-
|
| 290 |
-
]
|
|
|
|
| 291 |
|
| 292 |
-
|
| 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 |
-
|
| 331 |
-
'ايه':'ما','إيه':'ما','ايش':'ما','شو':'ما',
|
| 332 |
-
'اللي':'الذي','عشان':'لأن','ازاي':'كيف',
|
| 333 |
-
'امتى':'متى','فين':'أين','مين':'من','ليه':'لماذا',
|
| 334 |
-
}
|
| 335 |
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
'هل أستحق','حقوقي','مستحقاتي',
|
| 341 |
-
]
|
| 342 |
|
| 343 |
-
|
| 344 |
-
'نظام','قانون','مادة','عقوبة','غرامة','سجن',
|
| 345 |
-
'حق','شرط','إجراء','محكمة','دعوى','ساعة',
|
| 346 |
-
'إجازة','مكافأة','تعويض','فصل','عقد عمل',
|
| 347 |
-
'غسل الأموال','جرائم معلوماتية','بيانات شخصية',
|
| 348 |
-
'أجر','راتب','دوام',
|
| 349 |
-
]
|
| 350 |
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 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()
|
| 370 |
-
self.limits = {'groq': 28
|
| 371 |
-
self.last_used = {'groq': 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 |
-
#
|
| 397 |
# ══════════════════════════════════════════════════════════
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
try:
|
| 402 |
-
|
| 403 |
-
model=m['model'],
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
return answer, m['name']
|
| 408 |
except Exception as e:
|
| 409 |
-
|
| 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 |
-
|
| 418 |
-
for model in working_or_models:
|
| 419 |
try:
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
|
|
|
|
|
|
|
|
|
| 424 |
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
continue
|
| 440 |
else:
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
|
| 451 |
# ══════════════════════════════════════════════════════════
|
| 452 |
-
# Pipeline
|
| 453 |
# ══════════════════════════════════════════════════════════
|
| 454 |
-
def
|
| 455 |
-
|
| 456 |
-
for
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 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 |
-
|
| 475 |
-
q_lower
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
question = ' '.join(question.split())
|
| 495 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 504 |
q_lower = question.lower()
|
| 505 |
-
if any(p in question for p in
|
| 506 |
-
if any(kw in question for kw in
|
| 507 |
-
if any(kw in
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
return False
|
| 512 |
|
| 513 |
-
def
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 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
|
| 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 |
-
|
| 530 |
-
|
| 531 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
if words: result.append(' '.join(words[:4]))
|
| 533 |
_expansion_cache[question] = result
|
| 534 |
return result
|
| 535 |
|
| 536 |
-
def
|
| 537 |
-
|
| 538 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|
| 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
|
| 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 |
-
|
|
|
|
| 574 |
return '\n\n'.join(parts)
|
| 575 |
|
| 576 |
def post_process(answer, docs):
|
| 577 |
answer = answer.strip()
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 583 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
|
| 585 |
def ask_legal_core(question: str) -> dict:
|
| 586 |
original = question
|
| 587 |
-
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.
|
| 592 |
-
return {'answer':
|
|
|
|
| 593 |
|
| 594 |
-
queries
|
| 595 |
-
|
| 596 |
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
break
|
| 602 |
|
| 603 |
-
top_k =
|
| 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(
|
| 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
|
| 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.
|
| 646 |
-
|
| 647 |
-
|
| 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}
|
| 667 |
])
|
| 668 |
|
| 669 |
if not answer:
|
| 670 |
-
return {'answer': 'كل الموديلات محجوزة حالياً.
|
| 671 |
|
| 672 |
return {
|
| 673 |
-
'answer':
|
| 674 |
-
'sources':
|
| 675 |
-
'coverage':
|
| 676 |
-
'model':
|
| 677 |
-
'
|
| 678 |
}
|
| 679 |
|
| 680 |
-
|
| 681 |
# ══════════════════════════════════════════════════════════
|
| 682 |
-
# FastAPI
|
| 683 |
# ══════════════════════════════════════════════════════════
|
| 684 |
app = FastAPI(
|
| 685 |
-
title='
|
| 686 |
description='نظام الذكاء الاصطناعي للقانون السعودي — وزارة العدل',
|
| 687 |
-
version='
|
| 688 |
lifespan=lifespan
|
| 689 |
)
|
| 690 |
|
| 691 |
-
app.add_middleware(CORSMiddleware,
|
| 692 |
-
allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])
|
| 693 |
-
|
| 694 |
|
| 695 |
-
|
| 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:
|
| 710 |
-
sources:
|
| 711 |
-
coverage:
|
| 712 |
-
model:
|
| 713 |
duration_ms: int
|
| 714 |
-
disclaimer:
|
| 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 |
-
'
|
| 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='السؤال طويل جداً
|
| 748 |
|
| 749 |
-
ip = request.headers.get('X-Forwarded-For',
|
| 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',
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 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
|
| 774 |
-
sources
|
| 775 |
-
coverage
|
| 776 |
-
model
|
| 777 |
-
duration_ms = ms
|
| 778 |
)
|
| 779 |
|
| 780 |
@app.get('/stats')
|
| 781 |
def get_stats():
|
| 782 |
return {
|
| 783 |
-
'total':
|
| 784 |
-
'
|
| 785 |
-
'
|
| 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=
|
|
|
|
| 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)
|