Spaces:
Runtime error
Runtime error
Upload 4 files
Browse files- Dockerfile +22 -0
- README.md +4 -6
- main.py +799 -0
- 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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 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
|