"""
Indian Legal AI Assistant — Hugging Face Spaces entry point.
Loads pre-built vector DB artifacts from ./data/ and launches the Gradio UI.
No PDF processing, no package installation, no Kaggle/Colab paths.
"""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from threading import Lock
import inspect
import json
import os
import pickle
import re
import time
from typing import Dict, Iterable, List, Tuple
import gradio as gr
import numpy as np
import requests
# ── Artifact location ─────────────────────────────────────────────────────────
# All four files (chunks_data.pkl, embeddings.npy, legal_faiss_index.index,
# manifest.json) must be uploaded to the Space's data/ folder.
ARTIFACT_DIR = Path(os.getenv("ARTIFACT_DIR", "./data"))
# ── Model & inference settings ────────────────────────────────────────────────
EMBEDDING_MODEL_NAME = "BAAI/bge-large-en-v1.5"
TOP_K = 3
MIN_RELEVANCE_SCORE = 0.30
MAX_QUERY_CHARS = 1800
MAX_HISTORY_TURNS = 4
MAX_CONTEXT_WORDS = 500
MAX_OUTPUT_TOKENS = 1200
BATCH_SIZE = 32
REQUESTS_PER_MINUTE = 8
MIN_REQUEST_INTERVAL = 2.5
QUEUE_MAX_SIZE = 16
DEFAULT_CONCURRENCY = 2
DEFAULT_PROVIDER = "openai"
ALLOWED_PROVIDERS = ("openai", "groq", "gemini")
PROVIDER_SPECS = {
"openai": {"label": "OpenAI", "base_url": "https://api.openai.com/v1"},
"groq": {"label": "Groq", "base_url": "https://api.groq.com/openai/v1"},
"gemini": {"label": "Gemini (Google)", "base_url": "https://generativelanguage.googleapis.com/v1beta/openai"},
}
# ── Exceptions ────────────────────────────────────────────────────────────────
class UserFacingError(RuntimeError):
"""Safe exception whose message is shown directly in the UI."""
# ── Helpers ───────────────────────────────────────────────────────────────────
def sanitize_text(value: str | None, max_chars: int) -> str:
text = (value or "").replace("\x00", " ").strip()
text = re.sub(r"\r\n?", "\n", text)
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text[:max_chars].rstrip() if len(text) > max_chars else text
def redact_sensitive(text: str) -> str:
for pattern in [
r"sk-[A-Za-z0-9_-]{12,}",
r"hf_[A-Za-z0-9]{12,}",
r"gsk_[A-Za-z0-9_-]{12,}",
r"AIza[A-Za-z0-9_-]{35}",
]:
text = re.sub(pattern, "[REDACTED]", text)
return text
def safe_error_message(exc: Exception) -> str:
if isinstance(exc, UserFacingError):
return str(exc)
detail = redact_sensitive(str(exc)).strip()
return f"Something went wrong. Detail: {detail[:180]}" if detail else "Something went wrong."
def normalize_rows(values: np.ndarray) -> np.ndarray:
arr = np.asarray(values, dtype=np.float32)
if arr.ndim == 1:
arr = arr.reshape(1, -1)
norms = np.linalg.norm(arr, axis=1, keepdims=True)
norms[norms == 0] = 1.0
return arr / norms
def detect_intent(query: str) -> str:
q = query.lower().strip()
legal_indicators = [
# Acts and codes
"section", "act", "law", "ipc", "bns", "bnss", "bnns", "pocso", "ndps",
"crpc", "it act", "constitution", "article", "schedule", "clause",
# People and status
"accused", "victim", "complainant", "witness", "juvenile", "minor",
"child", "abscond", "detain", "detention", "custody", "prisoner",
# Proceedings
"court", "tribunal", "magistrate", "judge", "appeal", "petition",
"bail", "arrest", "warrant", "summons", "charge", "trial", "acquit",
# Liability and culpability
"criminal", "civil", "liable", "liability", "culpable", "negligence",
"intent", "mens rea", "actus reus", "abetment", "conspiracy",
# Outcomes
"penalty", "offense", "offence", "crime", "rights", "punishment",
"sentence", "fine", "imprisonment", "provisions", "legal", "illegal",
"exempt", "immunity", "pardon", "remission",
]
if any(ind in q for ind in legal_indicators):
return "LEGAL"
if re.search(r"\b(section|sec|article|art)\s*\d+", q):
return "LEGAL"
if len(q.split()) <= 3:
return "CONVERSATIONAL"
return "CONVERSATIONAL"
def history_to_messages(history: list | None, max_turns: int, max_chars: int) -> list[dict]:
if not history:
return []
normalized: list[dict] = []
for item in history:
if isinstance(item, dict):
role = str(item.get("role", "")).strip().lower()
content = item.get("content")
if role in {"user", "assistant"} and isinstance(content, str):
c = sanitize_text(content, max_chars)
if c:
normalized.append({"role": role, "content": c})
elif isinstance(item, (list, tuple)) and len(item) == 2:
u = sanitize_text(str(item[0] or ""), max_chars)
a = sanitize_text(str(item[1] or ""), max_chars)
if u:
normalized.append({"role": "user", "content": u})
if a:
normalized.append({"role": "assistant", "content": a})
return normalized[-(max_turns * 2):]
# ── Rate limiter ──────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class RateLimitPolicy:
requests_per_minute: int
min_interval_seconds: float
class SessionRateLimiter:
def __init__(self, policy: RateLimitPolicy) -> None:
self.policy = policy
self._events: dict[str, deque[float]] = {}
self._lock = Lock()
def check(self, session_id: str) -> None:
if not session_id:
return
now = time.monotonic()
with self._lock:
bucket = self._events.setdefault(session_id, deque())
while bucket and now - bucket[0] > 60:
bucket.popleft()
if bucket and now - bucket[-1] < self.policy.min_interval_seconds:
wait = self.policy.min_interval_seconds - (now - bucket[-1])
raise UserFacingError(f"Please wait {wait:.1f}s before sending another request.")
if len(bucket) >= self.policy.requests_per_minute:
raise UserFacingError("Too many requests. Pause for a minute and try again.")
bucket.append(now)
# ── Embedder ──────────────────────────────────────────────────────────────────
class SentenceTransformerEmbedder:
def __init__(self, model_name: str) -> None:
from sentence_transformers import SentenceTransformer # type: ignore
# HF Spaces CPU — no GPU available on free tier
self.model = SentenceTransformer(model_name, device="cpu")
def encode(self, texts: list[str]) -> np.ndarray:
return np.asarray(
self.model.encode(texts, show_progress_bar=False,
batch_size=BATCH_SIZE, convert_to_numpy=True),
dtype=np.float32,
)
# ── Vector index ──────────────────────────────────────────────────────────────
class NumpyVectorIndex:
def __init__(self, embeddings: np.ndarray) -> None:
self.embeddings = normalize_rows(embeddings)
def search(self, query_embedding: np.ndarray, top_k: int):
scores = np.matmul(normalize_rows(query_embedding), self.embeddings.T)
indices = np.argsort(-scores, axis=1)[:, :top_k]
return np.take_along_axis(scores, indices, axis=1).astype(np.float32), indices.astype(np.int64)
# ── Retriever ─────────────────────────────────────────────────────────────────
class OptimizedLegalRetriever:
QUERY_PREFIX = "Represent this query for retrieving relevant legal passages: "
EXPANSIONS = {
r"\bBNS\b": "Bharatiya Nyaya Sanhita",
r"\bBNNS\b": "Bharatiya Nagarik Suraksha Sanhita",
r"\bCrPC\b": "Criminal Procedure Code",
r"\bPOCSO\b": "Protection of Children from Sexual Offences",
r"\bIT Act\b": "Information Technology Act",
r"\bSec\.?\b": "Section",
r"\bArt\.?\b": "Article",
}
def __init__(self, embedding_model, vector_index, all_chunks, chunk_metadata) -> None:
self.embedding_model = embedding_model
self.index = vector_index
self.all_chunks = all_chunks
self.chunk_metadata = chunk_metadata
def preprocess(self, query: str) -> str:
for abbr, full in self.EXPANSIONS.items():
query = re.sub(abbr, full, query, flags=re.IGNORECASE)
return query.strip()
def retrieve(self, query: str, top_k: int = 3) -> list[dict]:
processed = self.preprocess(query)
qe = normalize_rows(self.embedding_model.encode([self.QUERY_PREFIX + processed]))
scores, indices = self.index.search(qe, top_k * 2)
keywords = set(processed.lower().split())
candidates = []
for score, idx in zip(scores[0], indices[0]):
idx = int(idx)
if 0 <= idx < len(self.all_chunks):
chunk = self.all_chunks[idx]
overlap = len(keywords & set(chunk.lower().split())) / max(len(keywords), 1)
candidates.append({
"chunk": chunk,
"metadata": self.chunk_metadata[idx],
"score": float(score) + overlap * 0.15,
"index": idx,
})
candidates.sort(key=lambda x: x["score"], reverse=True)
return candidates[:top_k]
def format_retrieved_context(chunks_data: list[dict], max_words: int) -> str:
parts = []
for i, data in enumerate(chunks_data, 1):
meta = data["metadata"]
chunk = data["chunk"]
words = chunk.split()
if len(words) > max_words:
chunk = " ".join(words[:max_words]) + " ..."
source = meta.get("source_file", "Unknown").replace(".pdf", "")
sec = f" - {meta.get('section_type','Section')} {meta['section_number']}" \
if "section_number" in meta else ""
parts.append(f"[Reference {i}: {source}{sec}]\n{chunk}")
return "\n\n---\n\n".join(parts)
# ── Load artifacts ────────────────────────────────────────────────────────────
def load_runtime():
manifest_path = ARTIFACT_DIR / "manifest.json"
chunks_path = ARTIFACT_DIR / "chunks_data.pkl"
embeddings_path= ARTIFACT_DIR / "embeddings.npy"
for p in (manifest_path, chunks_path, embeddings_path):
if not p.exists():
raise UserFacingError(
f"Artifact file not found: {p}\n"
"Make sure all 4 files are uploaded to the Space's data/ folder."
)
with manifest_path.open("r", encoding="utf-8") as f:
manifest = json.load(f)
with chunks_path.open("rb") as f:
data = pickle.load(f)
embeddings = np.load(embeddings_path).astype(np.float32)
# Try FAISS first, fall back to numpy
faiss_path = ARTIFACT_DIR / "legal_faiss_index.index"
index = None
if faiss_path.exists():
try:
import faiss # type: ignore
index = faiss.read_index(str(faiss_path))
print("FAISS index loaded.")
except Exception as e:
print(f"FAISS load failed ({e}), using numpy search.")
if index is None:
index = NumpyVectorIndex(embeddings)
embedder = SentenceTransformerEmbedder(EMBEDDING_MODEL_NAME)
retriever = OptimizedLegalRetriever(
embedding_model=embedder,
vector_index=index,
all_chunks=list(data["all_chunks"]),
chunk_metadata=list(data["chunk_metadata"]),
)
print(f"Runtime ready — {manifest.get('vector_count','?')} chunks from "
f"{manifest.get('source_pdf_count','?')} PDFs.")
return retriever, manifest
# ── Provider streaming ────────────────────────────────────────────────────────
class ProviderClient:
def __init__(self) -> None:
self.session = requests.Session()
def stream_chat(
self,
provider_name: str,
model_name: str,
api_key: str,
messages: list[dict],
*,
temperature: float,
max_tokens: int,
) -> Iterable[str]:
key = provider_name.strip().lower()
if key not in ALLOWED_PROVIDERS:
raise UserFacingError(f"Unknown provider '{provider_name}'.")
api_key = sanitize_text(api_key, 500)
if not api_key:
raise UserFacingError("API key is required. Open ⚙️ Settings and paste your key.")
model = sanitize_text(model_name, 200)
if not model:
raise UserFacingError("Model name is required. Open ⚙️ Settings and enter a model.")
spec = PROVIDER_SPECS[key]
try:
with self.session.post(
f"{spec['base_url']}/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": model, "messages": messages,
"temperature": temperature, "max_tokens": max_tokens, "stream": True},
timeout=(10, 180),
stream=True,
) as resp:
if resp.status_code >= 400:
raw = ""
try:
ep = resp.json()
if isinstance(ep, list):
ep = ep[0] if ep else {}
if isinstance(ep, dict):
err = ep.get("error") or {}
raw = (err.get("message") if isinstance(err, dict) else str(err)) \
or ep.get("message") or ""
else:
raw = str(ep)
except ValueError:
raw = resp.text[:300]
raw = redact_sensitive(str(raw)).strip()
s = resp.status_code
if s == 401:
msg = f"❌ Invalid API key for {spec['label']}. Check the key has no extra spaces."
elif s == 403:
msg = f"❌ Access denied ({spec['label']} 403). Your plan may not include this model."
elif s == 404:
msg = f"❌ Model '{model}' not found on {spec['label']} (404). Check the model name."
elif s == 429:
msg = f"⏳ Rate limit exceeded on {spec['label']} (429). Wait a moment and retry."
elif s == 500:
msg = f"🔥 {spec['label']} server error (500). Wait a minute and retry."
elif s in (502, 503, 504):
msg = f"🔌 {spec['label']} temporarily unavailable ({s}). Retry in a few seconds."
else:
msg = f"❌ {spec['label']} rejected the request (HTTP {s})."
if raw and raw.lower() not in msg.lower():
msg += f"\n\nProvider said: {raw[:300]}"
raise UserFacingError(msg)
for raw_line in resp.iter_lines(decode_unicode=True):
if not raw_line:
continue
line = raw_line.strip()
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
if isinstance(chunk, list):
chunk = chunk[0] if chunk else {}
if not isinstance(chunk, dict):
continue
choices = chunk.get("choices")
if not choices or not isinstance(choices, list):
continue
fc = choices[0]
if not isinstance(fc, dict):
continue
delta = fc.get("delta") or {}
if not isinstance(delta, dict):
continue
token = delta.get("content")
if token:
yield token
except requests.RequestException as exc:
raise UserFacingError(
f"{spec['label']} request failed. Check your internet connection."
) from exc
# ── Bootstrap ─────────────────────────────────────────────────────────────────
RETRIEVER: OptimizedLegalRetriever | None = None
MANIFEST: dict | None = None
LOAD_ERROR: Exception | None = None
CLIENT = ProviderClient()
RATE_LIMITER = SessionRateLimiter(
RateLimitPolicy(requests_per_minute=REQUESTS_PER_MINUTE,
min_interval_seconds=MIN_REQUEST_INTERVAL)
)
try:
RETRIEVER, MANIFEST = load_runtime()
except Exception as _e:
LOAD_ERROR = _e
print(f"ERROR loading runtime: {_e}")
# ── Message builders ──────────────────────────────────────────────────────────
def build_conversational_messages(message: str, history: list) -> list[dict]:
return [
{"role": "system",
"content": "You are a helpful Indian legal AI assistant. "
"For casual conversation reply briefly in 2-3 sentences."},
*history_to_messages(history, MAX_HISTORY_TURNS, MAX_QUERY_CHARS),
{"role": "user", "content": message},
]
def build_legal_messages(query: str, chunks_data: list[dict]) -> list[dict]:
context = format_retrieved_context(chunks_data, MAX_CONTEXT_WORDS)
return [
{"role": "system",
"content": "You are an expert on Indian law. Answer only from the provided legal "
"references. Cite sections, acts, and provisions where possible. "
"If the context is incomplete, say so plainly."},
{"role": "user",
"content": f"Legal References:\n{context}\n\nQuestion: {query}\n\n"
"Provide a grounded answer based only on the references above."},
]
def stream_reply(provider: str, model: str, key: str,
messages: list[dict], *, temperature: float):
full = ""
for token in CLIENT.stream_chat(provider, model, key, messages,
temperature=temperature, max_tokens=MAX_OUTPUT_TOKENS):
full += token
yield full.strip()
def test_connection(provider: str, model: str, key: str) -> str:
try:
msgs = [{"role": "system",
"content": "Reply in one short sentence confirming the API works."},
{"role": "user", "content": "Say: API connection successful."}]
sample = ""
for p in stream_reply(provider, model, key, msgs, temperature=0.1):
sample = p
if len(sample) >= 120:
break
return f"✅ Connection OK. Reply: {sample[:160]}" if sample \
else "Connection reached the provider but returned no text."
except Exception as exc:
return safe_error_message(exc)
# ── Main chat handler ─────────────────────────────────────────────────────────
def chat(message: str, history: list,
provider_name: str, model_name: str, user_api_key: str,
request: gr.Request):
try:
sid = getattr(request, "session_hash", None) or "anon"
RATE_LIMITER.check(sid)
msg = sanitize_text(message, MAX_QUERY_CHARS)
if not msg:
raise UserFacingError("Please enter a question.")
intent = detect_intent(msg)
if intent == "CONVERSATIONAL":
yield "Thinking..."
for p in stream_reply(provider_name, model_name, user_api_key,
build_conversational_messages(msg, history),
temperature=0.6):
yield p
return
if RETRIEVER is None:
err = safe_error_message(LOAD_ERROR) if LOAD_ERROR else "Vector DB not loaded."
yield f"⚠️ The vector database is not ready.\n\n{err}"
return
chunks = RETRIEVER.retrieve(msg, top_k=TOP_K)
if not chunks or all(c["score"] < MIN_RELEVANCE_SCORE for c in chunks):
yield ("No relevant legal documents found for this question. "
"Try being more specific — mention the act, section, or offence.")
return
# Build footnote before streaming so it always appears
sources_line = "\n\n---\n*Sources: " + " · ".join(
dict.fromkeys(
c["metadata"].get("source_file", "?").replace(".pdf", "")
for c in chunks
)
) + "*"
yield "⚖️ Searching legal documents..."
current = ""
try:
for p in stream_reply(provider_name, model_name, user_api_key,
build_legal_messages(msg, chunks),
temperature=0.1):
current = p
yield current
except Exception as stream_exc:
err_msg = safe_error_message(stream_exc)
yield (current.strip() + "\n\n" + err_msg if current else err_msg) + sources_line
return
yield current.strip() + sources_line
except Exception as exc:
yield safe_error_message(exc)
# ── Gradio UI ─────────────────────────────────────────────────────────────────
def _qkw(blocks, **kw):
sup = inspect.signature(blocks.queue).parameters
return {k: v for k, v in kw.items() if k in sup}
def build_demo() -> gr.Blocks:
PROVIDER_HELP = {
"openai": "e.g. gpt-4o · gpt-4o-mini · gpt-3.5-turbo",
"groq": "e.g. llama3-70b-8192 · mixtral-8x7b-32768 · gemma2-9b-it",
"gemini": "e.g. gemini-1.5-flash · gemini-1.5-pro",
}
with gr.Blocks(title="Indian Legal AI Assistant") as demo:
# ── Title ─────────────────────────────────────────────────────────────
gr.Markdown(
"# 🏛️ Indian Legal AI Assistant\n"
"Ask questions about Indian legal documents "
"(POCSO, NDPS, IT Act, BNS, BNSS, and 40+ other acts)"
)
# ── Settings accordion ─────────────────────────────────────────────────
with gr.Accordion("⚙️ Model & API Settings — click to expand", open=False):
provider = gr.Dropdown(
choices=list(ALLOWED_PROVIDERS),
value=DEFAULT_PROVIDER,
label="Provider",
)
model_hint = gr.Markdown(
f"{PROVIDER_HELP[DEFAULT_PROVIDER]}"
)
model_name = gr.Textbox(
label="Model name",
placeholder="Type the exact model string for your provider",
)
user_api_key = gr.Textbox(
type="password",
label="API Key",
placeholder="Paste your key here — never stored or logged",
)
test_btn = gr.Button("🔍 Test Connection", variant="primary")
key_status = gr.Markdown(
"Enter key + model, then click Test Connection."
)
# ── Chat ───────────────────────────────────────────────────────────────
ci = gr.ChatInterface(
fn=chat,
additional_inputs=[provider, model_name, user_api_key],
additional_inputs_accordion=gr.Accordion(visible=False, open=False),
title=None,
description=None,
submit_btn="Send ➤",
stop_btn="Stop",
show_progress="minimal",
save_history=False,
flagging_mode="never",
api_name=False,
examples=[
["Hello, how can you help me?"],
["What does POCSO stand for?"],
["What is the punishment for child abuse under POCSO Act?"],
["Explain Section 27 of NDPS Act"],
["What are the provisions for cybercrime in IT Act?"],
["Thank you for your help!"],
],
)
# ── Event wiring ───────────────────────────────────────────────────────
provider.change(
fn=lambda p: f"{PROVIDER_HELP.get(p, '')}",
inputs=provider,
outputs=model_hint,
)
test_btn.click(
fn=test_connection,
inputs=[provider, model_name, user_api_key],
outputs=key_status,
)
demo.queue(
**_qkw(demo, api_open=False, max_size=QUEUE_MAX_SIZE,
default_concurrency_limit=DEFAULT_CONCURRENCY)
)
return demo
demo = build_demo()
if __name__ == "__main__":
demo.launch()