Deploy LexAI v3.4
Browse files- .gitignore +0 -0
- .streamlit/config.toml +7 -0
- README.md +17 -13
- app.py +1110 -0
- assets/styles.css +287 -0
- config.py +55 -0
- data/processed/cluster_topics.json +5 -0
- data/processed/eval_metrics.json +11 -0
- data/processed/eval_metrics_retrieval.json +37 -0
- data/processed/gaps.json +28 -0
- download_data.py +110 -0
- packages.txt +11 -0
- requirements.txt +31 -3
- src/__init__.py +1 -0
- src/__pycache__/__init__.cpython-311.pyc +0 -0
- src/__pycache__/__init__.cpython-314.pyc +0 -0
- src/__pycache__/eval_pipeline.cpython-314.pyc +0 -0
- src/__pycache__/explanation_engine.cpython-311.pyc +0 -0
- src/__pycache__/explanation_engine.cpython-314.pyc +0 -0
- src/__pycache__/fetcher.cpython-311.pyc +0 -0
- src/__pycache__/fetcher.cpython-314.pyc +0 -0
- src/__pycache__/nlp_pipeline.cpython-311.pyc +0 -0
- src/__pycache__/nlp_pipeline.cpython-314.pyc +0 -0
- src/__pycache__/query_validator.cpython-311.pyc +0 -0
- src/__pycache__/query_validator.cpython-314.pyc +0 -0
- src/__pycache__/reranker.cpython-311.pyc +0 -0
- src/__pycache__/reranker.cpython-314.pyc +0 -0
- src/__pycache__/search_pipeline.cpython-311.pyc +0 -0
- src/__pycache__/search_pipeline.cpython-314.pyc +0 -0
- src/embedder.py +34 -0
- src/eval_pipeline.py +284 -0
- src/explanation_engine.py +154 -0
- src/fetcher.py +357 -0
- src/inconsistency.py +75 -0
- src/nlp_pipeline.py +263 -0
- src/query_validator.py +149 -0
- src/reranker.py +80 -0
- src/retrieval.py +55 -0
- src/search_pipeline.py +266 -0
.gitignore
ADDED
|
Binary file (56 Bytes). View file
|
|
|
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[theme]
|
| 2 |
+
base="dark"
|
| 3 |
+
primaryColor="#3b82f6"
|
| 4 |
+
backgroundColor="#0f172a"
|
| 5 |
+
secondaryBackgroundColor="#1e293b"
|
| 6 |
+
textColor="#f1f5f9"
|
| 7 |
+
font="sans serif"
|
README.md
CHANGED
|
@@ -1,20 +1,24 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
- streamlit
|
| 10 |
pinned: false
|
| 11 |
-
short_description: Streamlit template space
|
| 12 |
license: mit
|
|
|
|
| 13 |
---
|
| 14 |
|
| 15 |
-
#
|
| 16 |
|
| 17 |
-
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: LexAI — Indian Court Judgment AI
|
| 3 |
+
emoji: ⚖️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.32.0
|
| 8 |
+
app_file: app.py
|
|
|
|
| 9 |
pinned: false
|
|
|
|
| 10 |
license: mit
|
| 11 |
+
short_description: Semantic search + legal gap detection for Indian court judgments
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# ⚖️ LexAI — Indian Court Judgment AI
|
| 15 |
|
| 16 |
+
Semantic legal research powered by **LegalBERT + FAISS**.
|
| 17 |
+
Find similar past Indian court judgments, understand why they match, and detect verdict inconsistencies.
|
| 18 |
|
| 19 |
+
- **5,007** Indian court judgments indexed
|
| 20 |
+
- **~270ms** search latency (FAISS direct retrieval)
|
| 21 |
+
- **MRR@5: 0.5269** · **NDCG@5: 0.5746**
|
| 22 |
+
- Zero LLM inference — fully deterministic explanations
|
| 23 |
+
|
| 24 |
+
> ⚠️ First startup may take 2–3 minutes while data files are downloaded from HuggingFace Datasets.
|
app.py
ADDED
|
@@ -0,0 +1,1110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LexAI v3.4 — Streamlit Dashboard.
|
| 2 |
+
|
| 3 |
+
STRICTLY display-only. All ML logic lives in src/search_pipeline.py.
|
| 4 |
+
"""
|
| 5 |
+
import streamlit as st
|
| 6 |
+
import plotly.express as px
|
| 7 |
+
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import json, os, hashlib, logging, time, numpy as np
|
| 10 |
+
|
| 11 |
+
# Must be first Streamlit call
|
| 12 |
+
st.set_page_config(
|
| 13 |
+
page_title="LexAI - Legal Judgment Analyzer",
|
| 14 |
+
page_icon="\u2696\uFE0F",
|
| 15 |
+
layout="wide",
|
| 16 |
+
initial_sidebar_state="expanded"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# ── HuggingFace data bootstrap (runs once on cold start) ─────────────────────
|
| 20 |
+
# Downloads cases.json, faiss.index, embeddings.npy from HF Hub if missing.
|
| 21 |
+
# Safe to call every startup — skips download if files already exist.
|
| 22 |
+
try:
|
| 23 |
+
from download_data import download_if_missing
|
| 24 |
+
_data_ready = download_if_missing(verbose=True)
|
| 25 |
+
if not _data_ready:
|
| 26 |
+
st.error(
|
| 27 |
+
"⚠️ Some data files could not be downloaded from HuggingFace. "
|
| 28 |
+
"Search will not work until all files are available. "
|
| 29 |
+
"Check the app logs for details."
|
| 30 |
+
)
|
| 31 |
+
except Exception as _dl_err:
|
| 32 |
+
st.warning(f"Data download check skipped: {_dl_err}")
|
| 33 |
+
|
| 34 |
+
# Initialize feedback store in session_state
|
| 35 |
+
if "feedback" not in st.session_state:
|
| 36 |
+
st.session_state["feedback"] = {}
|
| 37 |
+
# structure: { "query_hash|case_id": "relevant" | "not_relevant" }
|
| 38 |
+
|
| 39 |
+
# ── Dark theme CSS ────────────────────────────────────────────────────────
|
| 40 |
+
with open("assets/styles.css", encoding="utf-8") as f:
|
| 41 |
+
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@st.cache_data
|
| 46 |
+
def get_metrics():
|
| 47 |
+
with open("data/processed/eval_metrics.json", encoding="utf-8") as f:
|
| 48 |
+
return json.load(f)
|
| 49 |
+
|
| 50 |
+
@st.cache_data
|
| 51 |
+
def get_cluster_data():
|
| 52 |
+
with open("data/processed/cases.json", encoding="utf-8") as f: cases = json.load(f)
|
| 53 |
+
with open("data/processed/cluster_topics.json", encoding="utf-8") as f: topics = json.load(f)
|
| 54 |
+
labels = np.load("data/processed/cluster_labels.npy")
|
| 55 |
+
coords = np.load("data/processed/coords_2d.npy")
|
| 56 |
+
return {"cases": cases, "labels": labels, "coords": coords, "topics": topics}
|
| 57 |
+
|
| 58 |
+
@st.cache_data
|
| 59 |
+
def get_gaps():
|
| 60 |
+
with open("data/processed/gaps.json", encoding="utf-8") as f:
|
| 61 |
+
return json.load(f)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ── Legal Gap Explanation Engine (v3.3) ───────────────────────────────
|
| 65 |
+
def generate_gap_explanation(gap: dict, cases: list, labels) -> dict:
|
| 66 |
+
"""
|
| 67 |
+
Generate a deterministic explanation for why a cluster
|
| 68 |
+
has verdict inconsistency. No LLM. Pure rule-based logic.
|
| 69 |
+
"""
|
| 70 |
+
from collections import Counter
|
| 71 |
+
|
| 72 |
+
cluster_id = gap["cluster_id"]
|
| 73 |
+
|
| 74 |
+
# Get all cases in this cluster
|
| 75 |
+
cluster_cases = [
|
| 76 |
+
c for c, lbl in zip(cases, labels)
|
| 77 |
+
if int(lbl) == int(cluster_id)
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
convicted = [c for c in cluster_cases if c.get("verdict") == "convicted"]
|
| 81 |
+
acquitted = [c for c in cluster_cases if c.get("verdict") == "acquitted"]
|
| 82 |
+
bail_granted = [c for c in cluster_cases if c.get("verdict") == "bail_granted"]
|
| 83 |
+
bail_rejected = [c for c in cluster_cases if c.get("verdict") == "bail_rejected"]
|
| 84 |
+
|
| 85 |
+
if not cluster_cases:
|
| 86 |
+
return {
|
| 87 |
+
"summary": "Insufficient data for this cluster.",
|
| 88 |
+
"key_differences": [],
|
| 89 |
+
"legal_insight": "No cases available for analysis."
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
# Evidence comparison
|
| 93 |
+
def get_evidence_set(case_list):
|
| 94 |
+
evidence = []
|
| 95 |
+
for c in case_list:
|
| 96 |
+
evidence.extend(c.get("evidence_types", []))
|
| 97 |
+
return Counter(evidence)
|
| 98 |
+
|
| 99 |
+
conv_evidence = get_evidence_set(convicted)
|
| 100 |
+
acqu_evidence = get_evidence_set(acquitted)
|
| 101 |
+
|
| 102 |
+
# IPC section comparison
|
| 103 |
+
def get_ipc_set(case_list):
|
| 104 |
+
sections = []
|
| 105 |
+
for c in case_list:
|
| 106 |
+
sections.extend(c.get("ipc_sections", []))
|
| 107 |
+
return Counter(sections)
|
| 108 |
+
|
| 109 |
+
conv_ipc = get_ipc_set(convicted)
|
| 110 |
+
acqu_ipc = get_ipc_set(acquitted)
|
| 111 |
+
|
| 112 |
+
# Court comparison
|
| 113 |
+
conv_courts = Counter(c.get("court", "unknown") for c in convicted)
|
| 114 |
+
acqu_courts = Counter(c.get("court", "unknown") for c in acquitted)
|
| 115 |
+
|
| 116 |
+
# Build key differences
|
| 117 |
+
key_differences = []
|
| 118 |
+
|
| 119 |
+
# Evidence differences
|
| 120 |
+
evidence_in_convicted_not_acquitted = [
|
| 121 |
+
e for e in conv_evidence
|
| 122 |
+
if conv_evidence[e] > 0 and acqu_evidence.get(e, 0) == 0
|
| 123 |
+
]
|
| 124 |
+
evidence_in_acquitted_not_convicted = [
|
| 125 |
+
e for e in acqu_evidence
|
| 126 |
+
if acqu_evidence[e] > 0 and conv_evidence.get(e, 0) == 0
|
| 127 |
+
]
|
| 128 |
+
|
| 129 |
+
if evidence_in_convicted_not_acquitted:
|
| 130 |
+
key_differences.append(
|
| 131 |
+
f"Convicted cases more often had: "
|
| 132 |
+
f"{', '.join(evidence_in_convicted_not_acquitted)}"
|
| 133 |
+
)
|
| 134 |
+
if evidence_in_acquitted_not_convicted:
|
| 135 |
+
key_differences.append(
|
| 136 |
+
f"Acquitted cases more often had: "
|
| 137 |
+
f"{', '.join(evidence_in_acquitted_not_convicted)}"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
# Court level differences
|
| 141 |
+
top_conv_court = conv_courts.most_common(1)[0][0] if conv_courts else "unknown"
|
| 142 |
+
top_acqu_court = acqu_courts.most_common(1)[0][0] if acqu_courts else "unknown"
|
| 143 |
+
if top_conv_court != top_acqu_court and top_conv_court != "unknown":
|
| 144 |
+
key_differences.append(
|
| 145 |
+
f"Most convictions from {top_conv_court}, "
|
| 146 |
+
f"most acquittals from {top_acqu_court}"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
# IPC differences
|
| 150 |
+
common_ipc = gap.get("common_ipc_sections", [])
|
| 151 |
+
if common_ipc:
|
| 152 |
+
key_differences.append(
|
| 153 |
+
f"Shared IPC sections: {', '.join(common_ipc[:3])}"
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# If no differences found, add generic insight
|
| 157 |
+
if not key_differences:
|
| 158 |
+
key_differences.append(
|
| 159 |
+
"Cases share similar charges but differ in "
|
| 160 |
+
"factual circumstances or evidence quality"
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# Generate summary
|
| 164 |
+
total = gap["total_cases"]
|
| 165 |
+
conv_cnt = gap["convicted_count"]
|
| 166 |
+
acqu_cnt = gap["acquitted_count"]
|
| 167 |
+
score = gap["inconsistency_score"]
|
| 168 |
+
dom_type = gap.get("dominant_case_type", "unknown")
|
| 169 |
+
|
| 170 |
+
summary = (
|
| 171 |
+
f"In this {dom_type} law cluster, {total} similar cases "
|
| 172 |
+
f"resulted in {conv_cnt} convictions and {acqu_cnt} acquittals "
|
| 173 |
+
f"({score:.0%} inconsistency). "
|
| 174 |
+
f"Cases share the same charges but produced opposite outcomes."
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
# Legal insight
|
| 178 |
+
if score >= 0.45:
|
| 179 |
+
legal_insight = (
|
| 180 |
+
"This cluster shows high verdict inconsistency \u2014 "
|
| 181 |
+
"nearly equal split between conviction and acquittal "
|
| 182 |
+
"on similar charges. This may indicate judicial discretion "
|
| 183 |
+
"based on evidence quality, witness credibility, or "
|
| 184 |
+
"differing interpretations of the same IPC sections."
|
| 185 |
+
)
|
| 186 |
+
elif score >= 0.30:
|
| 187 |
+
legal_insight = (
|
| 188 |
+
"Moderate inconsistency detected. Similar cases are "
|
| 189 |
+
"leaning towards one outcome but a significant minority "
|
| 190 |
+
"received the opposite verdict. Evidence strength and "
|
| 191 |
+
"court level may be contributing factors."
|
| 192 |
+
)
|
| 193 |
+
else:
|
| 194 |
+
legal_insight = (
|
| 195 |
+
"Low-moderate inconsistency. The majority of similar "
|
| 196 |
+
"cases share a verdict pattern, but exceptions exist "
|
| 197 |
+
"suggesting fact-specific reasoning by courts."
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
return {
|
| 201 |
+
"summary": summary,
|
| 202 |
+
"key_differences": key_differences,
|
| 203 |
+
"legal_insight": legal_insight
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def get_cluster_summary(cluster_id: int, cases: list, labels) -> dict:
|
| 208 |
+
"""
|
| 209 |
+
Compute a readable summary for a single cluster.
|
| 210 |
+
Used in cluster map legend and Legal Gaps section.
|
| 211 |
+
"""
|
| 212 |
+
from collections import Counter
|
| 213 |
+
|
| 214 |
+
cluster_cases = [
|
| 215 |
+
c for c, lbl in zip(cases, labels)
|
| 216 |
+
if int(lbl) == int(cluster_id)
|
| 217 |
+
]
|
| 218 |
+
|
| 219 |
+
if not cluster_cases:
|
| 220 |
+
return {
|
| 221 |
+
"total": 0,
|
| 222 |
+
"dominant_ipc": [],
|
| 223 |
+
"case_type": "unknown",
|
| 224 |
+
"verdict_split": {},
|
| 225 |
+
"label": f"Cluster {cluster_id}"
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
# IPC sections
|
| 229 |
+
all_ipc = []
|
| 230 |
+
for c in cluster_cases:
|
| 231 |
+
all_ipc.extend(c.get("ipc_sections", []))
|
| 232 |
+
ipc_counter = Counter(all_ipc)
|
| 233 |
+
dominant_ipc = [ipc for ipc, _ in ipc_counter.most_common(3)]
|
| 234 |
+
|
| 235 |
+
# Case type
|
| 236 |
+
types = Counter(c.get("case_type", "unknown") for c in cluster_cases)
|
| 237 |
+
dominant_type = types.most_common(1)[0][0]
|
| 238 |
+
|
| 239 |
+
# Verdict split
|
| 240 |
+
verdicts = Counter(c.get("verdict", "unknown") for c in cluster_cases)
|
| 241 |
+
total = len(cluster_cases)
|
| 242 |
+
verdict_split = {
|
| 243 |
+
v: round(count / total * 100)
|
| 244 |
+
for v, count in verdicts.most_common(4)
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
# Build a human-readable label
|
| 248 |
+
if dominant_ipc:
|
| 249 |
+
label = f"IPC {', '.join(dominant_ipc[:2])} \u2014 {dominant_type.title()}"
|
| 250 |
+
else:
|
| 251 |
+
label = f"{dominant_type.title()} cases"
|
| 252 |
+
|
| 253 |
+
return {
|
| 254 |
+
"total": total,
|
| 255 |
+
"dominant_ipc": dominant_ipc,
|
| 256 |
+
"case_type": dominant_type,
|
| 257 |
+
"verdict_split": verdict_split,
|
| 258 |
+
"label": label
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
@st.cache_data
|
| 263 |
+
def compute_all_gap_explanations(_cases, _labels, _gaps_json):
|
| 264 |
+
"""
|
| 265 |
+
Pre-compute explanations for ALL gaps at once.
|
| 266 |
+
Cached — only recomputes when data changes.
|
| 267 |
+
"""
|
| 268 |
+
import json as _json
|
| 269 |
+
gaps_list = _json.loads(_gaps_json)
|
| 270 |
+
return {
|
| 271 |
+
gap["cluster_id"]: generate_gap_explanation(gap, _cases, _labels)
|
| 272 |
+
for gap in gaps_list
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
@st.cache_data
|
| 277 |
+
def compute_all_cluster_summaries(_cases, _labels):
|
| 278 |
+
"""
|
| 279 |
+
Pre-compute summaries for ALL clusters at once.
|
| 280 |
+
Cached — only recomputes when data changes.
|
| 281 |
+
"""
|
| 282 |
+
if _labels is None or len(_cases) == 0:
|
| 283 |
+
return {}
|
| 284 |
+
summaries = {}
|
| 285 |
+
for cid in set(int(l) for l in _labels if int(l) != -1):
|
| 286 |
+
summaries[cid] = get_cluster_summary(cid, _cases, _labels)
|
| 287 |
+
return summaries
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# ── Persistent Feedback Storage (v3.3) ────────────────────���───────────
|
| 291 |
+
def save_feedback_to_disk(feedback_dict: dict):
|
| 292 |
+
"""
|
| 293 |
+
Write feedback to JSON with file locking.
|
| 294 |
+
Safe for Streamlit concurrent reruns.
|
| 295 |
+
"""
|
| 296 |
+
feedback_path = "data/feedback.json"
|
| 297 |
+
lock_path = "data/feedback.lock"
|
| 298 |
+
|
| 299 |
+
# Simple file lock — wait up to 2 seconds
|
| 300 |
+
waited = 0
|
| 301 |
+
while os.path.exists(lock_path) and waited < 2:
|
| 302 |
+
time.sleep(0.1)
|
| 303 |
+
waited += 0.1
|
| 304 |
+
|
| 305 |
+
try:
|
| 306 |
+
# Acquire lock
|
| 307 |
+
with open(lock_path, "w", encoding="utf-8") as lf:
|
| 308 |
+
lf.write("locked")
|
| 309 |
+
|
| 310 |
+
# Load existing, merge, save
|
| 311 |
+
existing = {}
|
| 312 |
+
if os.path.exists(feedback_path):
|
| 313 |
+
try:
|
| 314 |
+
with open(feedback_path, encoding="utf-8") as f:
|
| 315 |
+
existing = json.load(f)
|
| 316 |
+
except Exception:
|
| 317 |
+
existing = {}
|
| 318 |
+
|
| 319 |
+
existing.update(feedback_dict)
|
| 320 |
+
|
| 321 |
+
os.makedirs("data", exist_ok=True)
|
| 322 |
+
with open(feedback_path, "w", encoding="utf-8") as f:
|
| 323 |
+
json.dump(existing, f, indent=2)
|
| 324 |
+
except Exception:
|
| 325 |
+
pass # feedback persistence must never crash the app
|
| 326 |
+
finally:
|
| 327 |
+
# Always release lock
|
| 328 |
+
if os.path.exists(lock_path):
|
| 329 |
+
try:
|
| 330 |
+
os.remove(lock_path)
|
| 331 |
+
except Exception:
|
| 332 |
+
pass
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
# ── Query Logger (v3.3) ───────────────────────────────────────────────
|
| 336 |
+
_query_logger = None
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def get_query_logger():
|
| 340 |
+
global _query_logger
|
| 341 |
+
if _query_logger is None:
|
| 342 |
+
os.makedirs("logs", exist_ok=True)
|
| 343 |
+
handler = logging.FileHandler("logs/queries.log", encoding="utf-8")
|
| 344 |
+
handler.setLevel(logging.INFO)
|
| 345 |
+
handler.setFormatter(
|
| 346 |
+
logging.Formatter(
|
| 347 |
+
"%(asctime)s | %(message)s",
|
| 348 |
+
datefmt="%Y-%m-%d %H:%M:%S"
|
| 349 |
+
)
|
| 350 |
+
)
|
| 351 |
+
_query_logger = logging.getLogger("lexai_queries")
|
| 352 |
+
_query_logger.setLevel(logging.INFO)
|
| 353 |
+
_query_logger.addHandler(handler)
|
| 354 |
+
return _query_logger
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def render_header(cases):
|
| 358 |
+
st.markdown(f"""
|
| 359 |
+
<div class="lexai-header">
|
| 360 |
+
<h1>⚖️ LexAI v3.4</h1>
|
| 361 |
+
<p>
|
| 362 |
+
<span class="tag">Open Source</span>
|
| 363 |
+
<span class="tag">LegalBERT</span>
|
| 364 |
+
<span class="tag">FAISS</span>
|
| 365 |
+
Indian Court Judgment Similarity Engine & Legal Gap Finder
|
| 366 |
+
· {len(cases):,} cases indexed
|
| 367 |
+
</p>
|
| 368 |
+
</div>
|
| 369 |
+
""", unsafe_allow_html=True)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def render_metrics(metrics, cases, labels):
|
| 373 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 374 |
+
with c1:
|
| 375 |
+
st.markdown(f"""
|
| 376 |
+
<div class="metric-card">
|
| 377 |
+
<div class="value">{len(cases):,}</div>
|
| 378 |
+
<div class="label">Cases Indexed</div>
|
| 379 |
+
</div>""", unsafe_allow_html=True)
|
| 380 |
+
|
| 381 |
+
with c2:
|
| 382 |
+
n_cl = int(len(set(labels))-1) if labels is not None else 0
|
| 383 |
+
st.markdown(f"""
|
| 384 |
+
<div class="metric-card">
|
| 385 |
+
<div class="value">{n_cl}</div>
|
| 386 |
+
<div class="label">Clusters</div>
|
| 387 |
+
</div>""", unsafe_allow_html=True)
|
| 388 |
+
|
| 389 |
+
with c3:
|
| 390 |
+
sil = metrics.get("silhouette_score", 0)
|
| 391 |
+
sil_col = "#86efac" if sil >= 0.5 else "#fde68a" if sil >= 0.2 else "#fca5a5"
|
| 392 |
+
st.markdown(f"""
|
| 393 |
+
<div class="metric-card">
|
| 394 |
+
<div class="value" style="color:{sil_col}">{sil:.3f}</div>
|
| 395 |
+
<div class="label">Silhouette</div>
|
| 396 |
+
</div>""", unsafe_allow_html=True)
|
| 397 |
+
|
| 398 |
+
with c4:
|
| 399 |
+
algo = metrics.get("winner_algorithm", "HDBSCAN").upper()
|
| 400 |
+
st.markdown(f"""
|
| 401 |
+
<div class="metric-card">
|
| 402 |
+
<div class="value" style="font-size:20px;padding-top:8px">{algo}</div>
|
| 403 |
+
<div class="label">Algorithm</div>
|
| 404 |
+
</div>""", unsafe_allow_html=True)
|
| 405 |
+
|
| 406 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
@st.cache_resource
|
| 410 |
+
def get_pipeline():
|
| 411 |
+
from src.search_pipeline import SearchPipeline
|
| 412 |
+
return SearchPipeline()
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def render_search():
|
| 416 |
+
# Section header
|
| 417 |
+
st.markdown("""
|
| 418 |
+
<p class="section-header">Search Similar Judgments</p>
|
| 419 |
+
<p class="section-sub">Describe your case — LexAI finds the most similar
|
| 420 |
+
past judgments and explains why they match.</p>
|
| 421 |
+
""", unsafe_allow_html=True)
|
| 422 |
+
|
| 423 |
+
# Query examples as clickable pills (informational)
|
| 424 |
+
st.markdown("""
|
| 425 |
+
<div style="margin-bottom:12px">
|
| 426 |
+
<span style="font-size:12px;color:#64748b">Try: </span>
|
| 427 |
+
<span class="query-pill">IPC 302 murder with eyewitness</span>
|
| 428 |
+
<span class="query-pill">Bail application IPC 420 fraud</span>
|
| 429 |
+
<span class="query-pill">Appeal against acquittal</span>
|
| 430 |
+
</div>
|
| 431 |
+
""", unsafe_allow_html=True)
|
| 432 |
+
|
| 433 |
+
query = st.text_area(
|
| 434 |
+
"Enter legal query or case description:",
|
| 435 |
+
placeholder="e.g., bail application under IPC 302 murder where accused has no prior record",
|
| 436 |
+
height=100,
|
| 437 |
+
key="search_query"
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
# FAISS direct retrieval — reranker disabled (evaluated, no improvement)
|
| 441 |
+
use_reranker = False
|
| 442 |
+
st.caption("🔍 Using FAISS direct retrieval · MRR@5: 0.5269 · ~270ms")
|
| 443 |
+
|
| 444 |
+
if st.button("Search", type="primary", width="stretch"):
|
| 445 |
+
pipeline = get_pipeline()
|
| 446 |
+
ok, msg = pipeline.health_check()
|
| 447 |
+
if not ok:
|
| 448 |
+
st.warning(msg)
|
| 449 |
+
return
|
| 450 |
+
|
| 451 |
+
# Loading states
|
| 452 |
+
progress_bar = st.progress(0, text="Validating query...")
|
| 453 |
+
progress_bar.progress(25, text="Embedding query with LegalBERT...")
|
| 454 |
+
response = pipeline.search(query, top_k=5)
|
| 455 |
+
progress_bar.progress(75, text="Ranking results...")
|
| 456 |
+
progress_bar.progress(100, text="Building explanations...")
|
| 457 |
+
time.sleep(0.3)
|
| 458 |
+
progress_bar.empty()
|
| 459 |
+
|
| 460 |
+
if not response.success:
|
| 461 |
+
st.warning(response.error)
|
| 462 |
+
return
|
| 463 |
+
|
| 464 |
+
st.success(
|
| 465 |
+
f"Found {len(response.results)} candidates. "
|
| 466 |
+
f"Latency: {response.latency_ms}ms"
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
# Query logging
|
| 470 |
+
try:
|
| 471 |
+
get_query_logger().info(
|
| 472 |
+
f"query={query[:100]!r} | "
|
| 473 |
+
f"results={len(response.results)} | "
|
| 474 |
+
f"latency={response.latency_ms}ms | "
|
| 475 |
+
f"ipc={response.query_case.get('ipc_sections', [])} | "
|
| 476 |
+
f"case_type={response.query_case.get('case_type', 'unknown')}"
|
| 477 |
+
)
|
| 478 |
+
except Exception:
|
| 479 |
+
pass # logging must never crash the app
|
| 480 |
+
|
| 481 |
+
# Query understanding display
|
| 482 |
+
if response.query_case:
|
| 483 |
+
qc = response.query_case
|
| 484 |
+
ipc_text = ", ".join(qc["ipc_sections"]) if qc["ipc_sections"] else "none detected"
|
| 485 |
+
ev_text = ", ".join(qc["evidence_types"]) if qc["evidence_types"] else "none detected"
|
| 486 |
+
type_text = qc["case_type"].title()
|
| 487 |
+
|
| 488 |
+
st.markdown(
|
| 489 |
+
f"**Query understood:** "
|
| 490 |
+
f"IPC {ipc_text} | "
|
| 491 |
+
f"{type_text} case | "
|
| 492 |
+
f"Evidence: {ev_text}",
|
| 493 |
+
)
|
| 494 |
+
st.divider()
|
| 495 |
+
|
| 496 |
+
def get_case_display_info(case: dict) -> dict:
|
| 497 |
+
"""
|
| 498 |
+
Extract display-ready information from a case dict.
|
| 499 |
+
Handles both HuggingFace and Indian Kanoon cases.
|
| 500 |
+
Returns: title, court, date, tid, ik_url
|
| 501 |
+
"""
|
| 502 |
+
import json as _json
|
| 503 |
+
|
| 504 |
+
# Try to get metadata
|
| 505 |
+
meta = {}
|
| 506 |
+
try:
|
| 507 |
+
meta_raw = case.get("meta", "{}")
|
| 508 |
+
if isinstance(meta_raw, str):
|
| 509 |
+
meta = _json.loads(meta_raw)
|
| 510 |
+
elif isinstance(meta_raw, dict):
|
| 511 |
+
meta = meta_raw
|
| 512 |
+
except Exception:
|
| 513 |
+
meta = {}
|
| 514 |
+
|
| 515 |
+
# Case title — from meta title field
|
| 516 |
+
title = (
|
| 517 |
+
meta.get("title", "")
|
| 518 |
+
or meta.get("case_name", "")
|
| 519 |
+
or ""
|
| 520 |
+
).strip()
|
| 521 |
+
|
| 522 |
+
# Fallback title from text
|
| 523 |
+
if not title or len(title) < 5:
|
| 524 |
+
text = case.get("text", "")[:200]
|
| 525 |
+
# Try to find "vs" pattern in first 200 chars
|
| 526 |
+
if " vs " in text.lower() or " v. " in text.lower():
|
| 527 |
+
first_line = text.split("\n")[0].strip()
|
| 528 |
+
if len(first_line) < 120:
|
| 529 |
+
title = first_line
|
| 530 |
+
if not title:
|
| 531 |
+
title = f"Case {case.get('id', 'Unknown')[:12]}"
|
| 532 |
+
|
| 533 |
+
# Court
|
| 534 |
+
court = (
|
| 535 |
+
meta.get("court", "")
|
| 536 |
+
or case.get("court", "")
|
| 537 |
+
or "Unknown Court"
|
| 538 |
+
).strip()
|
| 539 |
+
|
| 540 |
+
# Date
|
| 541 |
+
date = (
|
| 542 |
+
meta.get("publishdate", "")
|
| 543 |
+
or meta.get("date", "")
|
| 544 |
+
or case.get("date", "")
|
| 545 |
+
or ""
|
| 546 |
+
).strip()
|
| 547 |
+
|
| 548 |
+
# Indian Kanoon link
|
| 549 |
+
tid = str(meta.get("tid", "")).strip()
|
| 550 |
+
ik_url = f"https://indiankanoon.org/doc/{tid}/" if tid else ""
|
| 551 |
+
|
| 552 |
+
return {
|
| 553 |
+
"title": title[:100],
|
| 554 |
+
"court": court,
|
| 555 |
+
"date": date,
|
| 556 |
+
"tid": tid,
|
| 557 |
+
"ik_url": ik_url,
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
VERDICT_CSS = {
|
| 561 |
+
"convicted": "badge-convicted",
|
| 562 |
+
"acquitted": "badge-acquitted",
|
| 563 |
+
"bail_granted": "badge-bail_granted",
|
| 564 |
+
"bail_rejected": "badge-bail_rejected",
|
| 565 |
+
"appeal_allowed": "badge-appeal_allowed",
|
| 566 |
+
"appeal_dismissed": "badge-appeal_dismissed",
|
| 567 |
+
"sentence_modified": "badge-sentence_modified",
|
| 568 |
+
"unknown": "badge-unknown",
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
VERDICT_LABELS = {
|
| 572 |
+
"convicted": "Convicted",
|
| 573 |
+
"acquitted": "Acquitted",
|
| 574 |
+
"bail_granted": "Bail Granted",
|
| 575 |
+
"bail_rejected": "Bail Rejected",
|
| 576 |
+
"appeal_allowed": "Appeal Allowed",
|
| 577 |
+
"appeal_dismissed": "Appeal Dismissed",
|
| 578 |
+
"sentence_modified": "Sentence Modified",
|
| 579 |
+
"unknown": "Unknown",
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
for result in response.results:
|
| 583 |
+
case = result.case
|
| 584 |
+
exp = result.explanation
|
| 585 |
+
verdict = case.get("verdict", "unknown")
|
| 586 |
+
css_cls = VERDICT_CSS.get(verdict, "badge-unknown")
|
| 587 |
+
v_label = VERDICT_LABELS.get(verdict, "Unknown")
|
| 588 |
+
info = get_case_display_info(case)
|
| 589 |
+
|
| 590 |
+
# Build IK link HTML
|
| 591 |
+
ik_link_html = ""
|
| 592 |
+
if info["ik_url"]:
|
| 593 |
+
ik_link_html = f'<a href="{info["ik_url"]}" target="_blank" class="ik-link">🔗 View on Indian Kanoon</a>'
|
| 594 |
+
|
| 595 |
+
# Card
|
| 596 |
+
st.markdown(f"""
|
| 597 |
+
<div class="result-card">
|
| 598 |
+
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px">
|
| 599 |
+
<div style="flex:1">
|
| 600 |
+
<span class="badge {css_cls}">{v_label}</span>
|
| 601 |
+
<div class="result-title">#{result.rank} — {info['title']}</div>
|
| 602 |
+
<div class="result-meta">
|
| 603 |
+
{info['court']}
|
| 604 |
+
{f" · {info['date']}" if info['date'] else ""}
|
| 605 |
+
</div>{ik_link_html}
|
| 606 |
+
</div>
|
| 607 |
+
<div style="text-align:right;flex-shrink:0">
|
| 608 |
+
<span class="score-pill">Score: {result.score:.3f}</span>
|
| 609 |
+
</div>
|
| 610 |
+
</div>
|
| 611 |
+
</div>
|
| 612 |
+
""", unsafe_allow_html=True)
|
| 613 |
+
|
| 614 |
+
# Matching factors
|
| 615 |
+
factors = []
|
| 616 |
+
if exp.get("shared_ipc"):
|
| 617 |
+
factors.append(f"**IPC:** {', '.join(exp['shared_ipc'])}")
|
| 618 |
+
if exp.get("shared_evidence"):
|
| 619 |
+
factors.append(f"**Evidence:** {', '.join(exp['shared_evidence'])}")
|
| 620 |
+
if exp.get("shared_case_type"):
|
| 621 |
+
factors.append(f"**Type:** {case.get('case_type','').title()}")
|
| 622 |
+
if factors:
|
| 623 |
+
st.markdown(" · ".join(factors))
|
| 624 |
+
|
| 625 |
+
# Explanation expander
|
| 626 |
+
with st.expander("⚖️ Why this result?", expanded=False):
|
| 627 |
+
col_a, col_b = st.columns(2)
|
| 628 |
+
with col_a:
|
| 629 |
+
st.markdown("**Similarity**")
|
| 630 |
+
st.info(exp.get("similarity_reason", "—"))
|
| 631 |
+
st.markdown("**Differences**")
|
| 632 |
+
st.warning(exp.get("key_differences", "—"))
|
| 633 |
+
with col_b:
|
| 634 |
+
st.markdown("**Verdict Analysis**")
|
| 635 |
+
va = exp.get("verdict_analysis", "—")
|
| 636 |
+
if "divergence" in va.lower():
|
| 637 |
+
st.error(va)
|
| 638 |
+
elif "alignment" in va.lower():
|
| 639 |
+
st.success(va)
|
| 640 |
+
else:
|
| 641 |
+
st.info(va)
|
| 642 |
+
excerpt = case.get("text", "")[:350]
|
| 643 |
+
if excerpt:
|
| 644 |
+
st.markdown("**Excerpt**")
|
| 645 |
+
st.caption(f'"{excerpt}..."')
|
| 646 |
+
|
| 647 |
+
# Feedback buttons
|
| 648 |
+
import hashlib
|
| 649 |
+
fb_key = f"{hashlib.md5(query.encode()).hexdigest()[:6]}|{case.get('id','')}"
|
| 650 |
+
current_fb = st.session_state.get("feedback", {}).get(fb_key)
|
| 651 |
+
fc1, fc2, fc3 = st.columns([1, 1, 6])
|
| 652 |
+
with fc1:
|
| 653 |
+
if st.button("👍", key=f"up_{fb_key}", help="Relevant"):
|
| 654 |
+
if "feedback" not in st.session_state:
|
| 655 |
+
st.session_state["feedback"] = {}
|
| 656 |
+
st.session_state["feedback"][fb_key] = "relevant"
|
| 657 |
+
save_feedback_to_disk(st.session_state["feedback"])
|
| 658 |
+
st.rerun()
|
| 659 |
+
with fc2:
|
| 660 |
+
if st.button("👎", key=f"dn_{fb_key}", help="Not relevant"):
|
| 661 |
+
if "feedback" not in st.session_state:
|
| 662 |
+
st.session_state["feedback"] = {}
|
| 663 |
+
st.session_state["feedback"][fb_key] = "not_relevant"
|
| 664 |
+
save_feedback_to_disk(st.session_state["feedback"])
|
| 665 |
+
st.rerun()
|
| 666 |
+
with fc3:
|
| 667 |
+
if current_fb == "relevant":
|
| 668 |
+
st.caption("✓ Marked relevant")
|
| 669 |
+
elif current_fb == "not_relevant":
|
| 670 |
+
st.caption("✗ Marked not relevant")
|
| 671 |
+
|
| 672 |
+
st.markdown("---")
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
def render_cluster_map():
|
| 676 |
+
st.markdown("### Judgment Cluster Map")
|
| 677 |
+
|
| 678 |
+
with st.spinner("Loading cluster data..."):
|
| 679 |
+
try:
|
| 680 |
+
data = get_cluster_data()
|
| 681 |
+
except Exception as e:
|
| 682 |
+
st.error(f"Could not load cluster data: {e}")
|
| 683 |
+
return
|
| 684 |
+
|
| 685 |
+
cases = data["cases"]
|
| 686 |
+
labels = data["labels"]
|
| 687 |
+
coords = data["coords"]
|
| 688 |
+
topics = data["topics"]
|
| 689 |
+
|
| 690 |
+
# Build cluster summaries for tooltips (cached)
|
| 691 |
+
cluster_summaries = compute_all_cluster_summaries(cases, labels)
|
| 692 |
+
|
| 693 |
+
# Build DataFrame with rich hover columns
|
| 694 |
+
df = pd.DataFrame({
|
| 695 |
+
"x": [c[0] for c in coords],
|
| 696 |
+
"y": [c[1] for c in coords],
|
| 697 |
+
"cluster": [str(l) for l in labels],
|
| 698 |
+
"cluster_label": [
|
| 699 |
+
cluster_summaries.get(int(l), {}).get("label", f"Cluster {l}")
|
| 700 |
+
if int(l) != -1 else "Noise"
|
| 701 |
+
for l in labels
|
| 702 |
+
],
|
| 703 |
+
"case_type": [c.get("case_type", "unknown") for c in cases],
|
| 704 |
+
"verdict": [c.get("verdict", "unknown") for c in cases],
|
| 705 |
+
"court": [c.get("court", "unknown") for c in cases],
|
| 706 |
+
"date": [c.get("date", "unknown") for c in cases],
|
| 707 |
+
})
|
| 708 |
+
|
| 709 |
+
fig = px.scatter(
|
| 710 |
+
df, x="x", y="y",
|
| 711 |
+
color="cluster",
|
| 712 |
+
hover_data={
|
| 713 |
+
"x": False,
|
| 714 |
+
"y": False,
|
| 715 |
+
"cluster": False,
|
| 716 |
+
"cluster_label": True,
|
| 717 |
+
"verdict": True,
|
| 718 |
+
"case_type": True,
|
| 719 |
+
"court": True,
|
| 720 |
+
"date": True,
|
| 721 |
+
},
|
| 722 |
+
labels={"cluster_label": "Cluster"},
|
| 723 |
+
height=500,
|
| 724 |
+
title="UMAP Projection of Judgment Embeddings",
|
| 725 |
+
color_discrete_sequence=px.colors.qualitative.Set2,
|
| 726 |
+
template="plotly_dark",
|
| 727 |
+
)
|
| 728 |
+
fig.update_traces(marker=dict(size=6, opacity=0.7))
|
| 729 |
+
fig.update_layout(
|
| 730 |
+
plot_bgcolor="rgba(0,0,0,0)",
|
| 731 |
+
paper_bgcolor="rgba(0,0,0,0)",
|
| 732 |
+
font=dict(family="Inter"),
|
| 733 |
+
)
|
| 734 |
+
st.plotly_chart(fig, width="stretch")
|
| 735 |
+
|
| 736 |
+
# Cluster summary table
|
| 737 |
+
st.markdown("**Cluster Summary**")
|
| 738 |
+
summary_rows = []
|
| 739 |
+
for cid in sorted(cluster_summaries.keys()):
|
| 740 |
+
s = cluster_summaries[cid]
|
| 741 |
+
top_verdict = max(s["verdict_split"], key=s["verdict_split"].get) \
|
| 742 |
+
if s["verdict_split"] else "unknown"
|
| 743 |
+
summary_rows.append({
|
| 744 |
+
"Cluster": cid,
|
| 745 |
+
"Cases": s["total"],
|
| 746 |
+
"Legal Category": s["label"],
|
| 747 |
+
"Dominant Verdict": top_verdict,
|
| 748 |
+
"Verdict Split": " | ".join(
|
| 749 |
+
f"{v}: {p}%" for v, p in
|
| 750 |
+
list(s["verdict_split"].items())[:3]
|
| 751 |
+
),
|
| 752 |
+
})
|
| 753 |
+
|
| 754 |
+
if summary_rows:
|
| 755 |
+
st.dataframe(
|
| 756 |
+
pd.DataFrame(summary_rows),
|
| 757 |
+
width="stretch",
|
| 758 |
+
hide_index=True
|
| 759 |
+
)
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
def render_verdict_distribution():
|
| 763 |
+
st.markdown("### Verdict Distribution")
|
| 764 |
+
try:
|
| 765 |
+
data = get_cluster_data()
|
| 766 |
+
except Exception as e:
|
| 767 |
+
st.error(f"Data not found: {e}")
|
| 768 |
+
return
|
| 769 |
+
|
| 770 |
+
cases = data["cases"]
|
| 771 |
+
|
| 772 |
+
verdicts = {}
|
| 773 |
+
for c in cases:
|
| 774 |
+
v = c.get("verdict", "unknown")
|
| 775 |
+
verdicts[v] = verdicts.get(v, 0) + 1
|
| 776 |
+
|
| 777 |
+
df = pd.DataFrame({
|
| 778 |
+
"Verdict": list(verdicts.keys()),
|
| 779 |
+
"Count": list(verdicts.values()),
|
| 780 |
+
})
|
| 781 |
+
df["Verdict"] = df["Verdict"].str.replace("_", " ").str.title()
|
| 782 |
+
|
| 783 |
+
fig = px.bar(
|
| 784 |
+
df, x="Verdict", y="Count", color="Verdict",
|
| 785 |
+
color_discrete_sequence=["#e94560", "#0f3460", "#00c875", "#533483"],
|
| 786 |
+
template="plotly_dark",
|
| 787 |
+
)
|
| 788 |
+
fig.update_layout(
|
| 789 |
+
plot_bgcolor="rgba(0,0,0,0)",
|
| 790 |
+
paper_bgcolor="rgba(0,0,0,0)",
|
| 791 |
+
font=dict(family="Inter"),
|
| 792 |
+
showlegend=False, height=350,
|
| 793 |
+
)
|
| 794 |
+
st.plotly_chart(fig, width="stretch")
|
| 795 |
+
st.caption(
|
| 796 |
+
"Dataset note: Bail applications dominate because Indian Kanoon "
|
| 797 |
+
"returns a high volume of bail matter judgments. "
|
| 798 |
+
"Bail Rejected + Bail Granted cases reflect real Indian court "
|
| 799 |
+
"docket composition where bail matters vastly outnumber trials."
|
| 800 |
+
)
|
| 801 |
+
|
| 802 |
+
|
| 803 |
+
def render_gaps():
|
| 804 |
+
st.markdown("## Legal Gaps & Inconsistencies")
|
| 805 |
+
|
| 806 |
+
try:
|
| 807 |
+
gaps = get_gaps()
|
| 808 |
+
except Exception as e:
|
| 809 |
+
st.error(f"Gaps data not found: {e}")
|
| 810 |
+
return
|
| 811 |
+
|
| 812 |
+
if not gaps:
|
| 813 |
+
st.info(
|
| 814 |
+
"No inconsistent clusters detected. "
|
| 815 |
+
"This improves as more cases are added and verdict "
|
| 816 |
+
"coverage increases above 40%."
|
| 817 |
+
)
|
| 818 |
+
return
|
| 819 |
+
|
| 820 |
+
st.caption(
|
| 821 |
+
f"{len(gaps)} clusters show opposing verdicts for similar cases. "
|
| 822 |
+
"Cases in the same cluster share IPC sections and case type "
|
| 823 |
+
"but received opposite verdicts."
|
| 824 |
+
)
|
| 825 |
+
|
| 826 |
+
# Load cases and labels for explanation engine
|
| 827 |
+
try:
|
| 828 |
+
data = get_cluster_data()
|
| 829 |
+
cases = data["cases"]
|
| 830 |
+
labels_for_gaps = data["labels"]
|
| 831 |
+
except Exception:
|
| 832 |
+
cases = []
|
| 833 |
+
labels_for_gaps = None
|
| 834 |
+
|
| 835 |
+
# Pre-compute ALL explanations once (cached) — not inside the render loop
|
| 836 |
+
if labels_for_gaps is not None and cases:
|
| 837 |
+
all_explanations = compute_all_gap_explanations(
|
| 838 |
+
cases, labels_for_gaps, json.dumps(gaps)
|
| 839 |
+
)
|
| 840 |
+
else:
|
| 841 |
+
all_explanations = {}
|
| 842 |
+
|
| 843 |
+
for gap in gaps:
|
| 844 |
+
score = gap["inconsistency_score"]
|
| 845 |
+
card_cls = "gap-card" + (" moderate" if score < 0.45 else "") + \
|
| 846 |
+
(" low" if score < 0.30 else "")
|
| 847 |
+
conv_cnt = gap.get("convicted_count", 0)
|
| 848 |
+
acqu_cnt = gap.get("acquitted_count", 0)
|
| 849 |
+
dom_type = gap.get("dominant_case_type", "general").title()
|
| 850 |
+
ipc_list = ", ".join(gap.get("common_ipc_sections", [])[:4]) or "—"
|
| 851 |
+
|
| 852 |
+
# Use actual verdict labels from gap data
|
| 853 |
+
convicted_count = gap.get("convicted_count", 0)
|
| 854 |
+
acquitted_count = gap.get("acquitted_count", 0)
|
| 855 |
+
bail_granted = gap.get("bail_granted_count", 0)
|
| 856 |
+
bail_rejected = gap.get("bail_rejected_count", 0)
|
| 857 |
+
|
| 858 |
+
# Show whichever pair has data
|
| 859 |
+
if convicted_count + acquitted_count > 0:
|
| 860 |
+
label_a = f"Convicted: {convicted_count}"
|
| 861 |
+
label_b = f"Acquitted: {acquitted_count}"
|
| 862 |
+
else:
|
| 863 |
+
label_a = f"Granted: {bail_granted}"
|
| 864 |
+
label_b = f"Rejected: {bail_rejected}"
|
| 865 |
+
|
| 866 |
+
# Get pre-computed explanation
|
| 867 |
+
explanation = all_explanations.get(gap["cluster_id"], {})
|
| 868 |
+
summary = explanation.get("summary", "")
|
| 869 |
+
diffs = explanation.get("key_differences", [])
|
| 870 |
+
insight = explanation.get("legal_insight", "")
|
| 871 |
+
|
| 872 |
+
st.markdown(f"""
|
| 873 |
+
<div class="{card_cls}">
|
| 874 |
+
<div style="display:flex;justify-content:space-between;align-items:center">
|
| 875 |
+
<div class="gap-card-title">
|
| 876 |
+
Cluster {gap['cluster_id']}
|
| 877 |
+
·
|
| 878 |
+
{gap['total_cases']} cases
|
| 879 |
+
·
|
| 880 |
+
<span style="color:#f87171">{score:.0%} inconsistency</span>
|
| 881 |
+
</div>
|
| 882 |
+
<div>
|
| 883 |
+
<span class="verdict-badge verdict-convicted"
|
| 884 |
+
style="margin-right:6px">{label_a}</span>
|
| 885 |
+
<span class="verdict-badge verdict-acquitted">
|
| 886 |
+
{label_b}</span>
|
| 887 |
+
</div>
|
| 888 |
+
</div>
|
| 889 |
+
<div style="color:#94a3b8;font-size:13px;margin-top:8px">
|
| 890 |
+
{dom_type} · IPC {ipc_list}
|
| 891 |
+
</div>
|
| 892 |
+
</div>
|
| 893 |
+
""", unsafe_allow_html=True)
|
| 894 |
+
|
| 895 |
+
if summary:
|
| 896 |
+
st.write(summary)
|
| 897 |
+
|
| 898 |
+
for diff in diffs:
|
| 899 |
+
st.markdown(f"• {diff}")
|
| 900 |
+
|
| 901 |
+
if insight:
|
| 902 |
+
st.markdown(
|
| 903 |
+
f'<div class="gap-insight">⚖️ <strong>Legal Insight:</strong> {insight}</div>',
|
| 904 |
+
unsafe_allow_html=True
|
| 905 |
+
)
|
| 906 |
+
|
| 907 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 908 |
+
|
| 909 |
+
|
| 910 |
+
def render_eval_metrics():
|
| 911 |
+
st.markdown("### Retrieval Evaluation Metrics")
|
| 912 |
+
metrics_path = "data/processed/eval_metrics_retrieval.json"
|
| 913 |
+
if not os.path.exists(metrics_path):
|
| 914 |
+
st.info("Run `python -m src.eval_pipeline` to generate retrieval metrics.")
|
| 915 |
+
return
|
| 916 |
+
|
| 917 |
+
with open(metrics_path, encoding="utf-8") as f:
|
| 918 |
+
rmetrics = json.load(f)
|
| 919 |
+
|
| 920 |
+
# Interpretation helpers
|
| 921 |
+
def interpret_mrr(v):
|
| 922 |
+
if v >= 0.7: return "Strong \u2014 relevant case usually in top 2"
|
| 923 |
+
if v >= 0.5: return "Moderate \u2014 relevant case usually in top 3"
|
| 924 |
+
if v >= 0.3: return "Fair \u2014 relevant case usually in top 5"
|
| 925 |
+
return "Low \u2014 relevant case may not appear in top results"
|
| 926 |
+
|
| 927 |
+
def interpret_ndcg(v):
|
| 928 |
+
if v >= 0.7: return "Strong ranking quality"
|
| 929 |
+
if v >= 0.5: return "Fair ranking relevance"
|
| 930 |
+
if v >= 0.3: return "Moderate \u2014 some ranking noise"
|
| 931 |
+
return "Low \u2014 ranking needs improvement"
|
| 932 |
+
|
| 933 |
+
def interpret_p5(v):
|
| 934 |
+
if v >= 0.6: return "High \u2014 most results are relevant"
|
| 935 |
+
if v >= 0.4: return "Moderate \u2014 roughly half are relevant"
|
| 936 |
+
if v >= 0.2: return "Low \u2014 minority of results are relevant"
|
| 937 |
+
return "Very low \u2014 precision needs improvement"
|
| 938 |
+
|
| 939 |
+
fr = rmetrics.get("faiss_plus_reranker", rmetrics.get("faiss_only", {}))
|
| 940 |
+
mrr = fr.get("MRR@5", 0)
|
| 941 |
+
p5 = fr.get("P@5", 0)
|
| 942 |
+
ndcg = fr.get("NDCG@5", 0)
|
| 943 |
+
|
| 944 |
+
m1, m2, m3 = st.columns(3)
|
| 945 |
+
m1.metric("MRR@5", f"{mrr:.4f}", help=interpret_mrr(mrr))
|
| 946 |
+
m1.caption(interpret_mrr(mrr))
|
| 947 |
+
m2.metric("P@5", f"{p5:.4f}", help=interpret_p5(p5))
|
| 948 |
+
m2.caption(interpret_p5(p5))
|
| 949 |
+
m3.metric("NDCG@5", f"{ndcg:.4f}", help=interpret_ndcg(ndcg))
|
| 950 |
+
m3.caption(interpret_ndcg(ndcg))
|
| 951 |
+
|
| 952 |
+
# Reranker experiment explanation
|
| 953 |
+
st.markdown("**Reranker Experiment**")
|
| 954 |
+
st.info(
|
| 955 |
+
"Two cross-encoder models were evaluated on 96 queries: \n\n"
|
| 956 |
+
"\u2022 **nli-deberta-v3-small** \u2192 NDCG delta: \u22120.10 (degraded quality) \n\n"
|
| 957 |
+
"\u2022 **ms-marco-MiniLM** \u2192 NDCG delta: 0.00 (no improvement) \n\n"
|
| 958 |
+
"**Conclusion:** LegalBERT bi-encoder already captures Indian legal "
|
| 959 |
+
"domain similarity well without a cross-encoder. Neither model improved "
|
| 960 |
+
"results without fine-tuning on labeled Indian law data. "
|
| 961 |
+
"The reranker is kept in the codebase for v4 fine-tuning. "
|
| 962 |
+
"FAISS direct retrieval is used in production at ~270ms latency."
|
| 963 |
+
)
|
| 964 |
+
|
| 965 |
+
|
| 966 |
+
# ── Main App ──────────────────────────────────────────────────────────────
|
| 967 |
+
def main():
|
| 968 |
+
try:
|
| 969 |
+
data = get_cluster_data()
|
| 970 |
+
cases = data.get("cases", [])
|
| 971 |
+
labels = data.get("labels", [])
|
| 972 |
+
except Exception:
|
| 973 |
+
cases = []
|
| 974 |
+
labels = None
|
| 975 |
+
|
| 976 |
+
render_header(cases)
|
| 977 |
+
|
| 978 |
+
# Sidebar
|
| 979 |
+
with st.sidebar:
|
| 980 |
+
st.markdown("## Navigation")
|
| 981 |
+
page = st.radio(
|
| 982 |
+
"Go to:",
|
| 983 |
+
["Search", "Cluster Map", "Legal Gaps", "Analytics"],
|
| 984 |
+
label_visibility="collapsed"
|
| 985 |
+
)
|
| 986 |
+
st.markdown("---")
|
| 987 |
+
st.markdown("**LexAI v3.4**")
|
| 988 |
+
st.markdown("Built with LegalBERT + FAISS + DeBERTa")
|
| 989 |
+
|
| 990 |
+
# Load metrics for header
|
| 991 |
+
try:
|
| 992 |
+
metrics = get_metrics()
|
| 993 |
+
render_metrics(metrics, cases, labels)
|
| 994 |
+
except Exception as e:
|
| 995 |
+
metrics = {}
|
| 996 |
+
st.warning(
|
| 997 |
+
"Clustering metrics not available. "
|
| 998 |
+
"Run the Colab notebook and copy eval_metrics.json "
|
| 999 |
+
"to data/processed/."
|
| 1000 |
+
)
|
| 1001 |
+
|
| 1002 |
+
st.markdown("---")
|
| 1003 |
+
|
| 1004 |
+
if page == "Search":
|
| 1005 |
+
render_search()
|
| 1006 |
+
elif page == "Cluster Map":
|
| 1007 |
+
render_cluster_map()
|
| 1008 |
+
elif page == "Legal Gaps":
|
| 1009 |
+
render_gaps()
|
| 1010 |
+
elif page == "Analytics":
|
| 1011 |
+
if not metrics:
|
| 1012 |
+
st.info(
|
| 1013 |
+
"Clustering metrics not available. "
|
| 1014 |
+
"Run the Colab notebook and copy eval_metrics.json "
|
| 1015 |
+
"to data/processed/."
|
| 1016 |
+
)
|
| 1017 |
+
# Cluster quality label
|
| 1018 |
+
sil_score = metrics.get("silhouette_score", 0)
|
| 1019 |
+
if sil_score >= 0.5:
|
| 1020 |
+
quality_label = "GOOD"
|
| 1021 |
+
quality_color = "#22c55e"
|
| 1022 |
+
quality_note = "Strong cluster separation. Cases group meaningfully."
|
| 1023 |
+
elif sil_score >= 0.2:
|
| 1024 |
+
quality_label = "MODERATE"
|
| 1025 |
+
quality_color = "#eab308"
|
| 1026 |
+
quality_note = "Some overlap between clusters. Acceptable for this dataset size."
|
| 1027 |
+
else:
|
| 1028 |
+
quality_label = "LOW"
|
| 1029 |
+
quality_color = "#ef4444"
|
| 1030 |
+
quality_note = (
|
| 1031 |
+
"Overlapping clusters \u2014 cases are semantically similar across groups. "
|
| 1032 |
+
"This is expected with 500 cases. Improves significantly with 1,000+ cases."
|
| 1033 |
+
)
|
| 1034 |
+
|
| 1035 |
+
col_sil, col_q = st.columns([1, 2])
|
| 1036 |
+
with col_sil:
|
| 1037 |
+
st.metric("Silhouette Score", f"{sil_score:.3f}")
|
| 1038 |
+
with col_q:
|
| 1039 |
+
st.markdown(
|
| 1040 |
+
f"<span style='background:{quality_color};color:white;"
|
| 1041 |
+
f"padding:3px 10px;border-radius:4px;font-weight:bold'>"
|
| 1042 |
+
f"Cluster Quality: {quality_label}</span>",
|
| 1043 |
+
unsafe_allow_html=True
|
| 1044 |
+
)
|
| 1045 |
+
st.caption(quality_note)
|
| 1046 |
+
|
| 1047 |
+
st.divider()
|
| 1048 |
+
|
| 1049 |
+
col1, col2 = st.columns(2)
|
| 1050 |
+
with col1:
|
| 1051 |
+
render_verdict_distribution()
|
| 1052 |
+
with col2:
|
| 1053 |
+
render_eval_metrics()
|
| 1054 |
+
|
| 1055 |
+
# User feedback summary
|
| 1056 |
+
st.divider()
|
| 1057 |
+
st.markdown("### User Feedback Summary")
|
| 1058 |
+
fb = st.session_state.get("feedback", {})
|
| 1059 |
+
if not fb:
|
| 1060 |
+
st.caption("No feedback collected yet. Search and mark results to see feedback stats.")
|
| 1061 |
+
else:
|
| 1062 |
+
relevant = sum(1 for v in fb.values() if v == "relevant")
|
| 1063 |
+
not_relevant = sum(1 for v in fb.values() if v == "not_relevant")
|
| 1064 |
+
total_fb = len(fb)
|
| 1065 |
+
user_precision = relevant / total_fb if total_fb > 0 else 0
|
| 1066 |
+
|
| 1067 |
+
fb1, fb2, fb3 = st.columns(3)
|
| 1068 |
+
fb1.metric("Total rated", total_fb)
|
| 1069 |
+
fb2.metric("Marked relevant", relevant)
|
| 1070 |
+
fb3.metric("User Precision", f"{user_precision:.0%}",
|
| 1071 |
+
help="% of rated results marked relevant by user")
|
| 1072 |
+
|
| 1073 |
+
# Evaluation loop — compare user feedback to offline metrics
|
| 1074 |
+
try:
|
| 1075 |
+
ret_metrics_path = "data/processed/eval_metrics_retrieval.json"
|
| 1076 |
+
if os.path.exists(ret_metrics_path):
|
| 1077 |
+
with open(ret_metrics_path, encoding="utf-8") as f:
|
| 1078 |
+
ret_metrics_fb = json.load(f)
|
| 1079 |
+
offline_mrr = ret_metrics_fb.get(
|
| 1080 |
+
"faiss_plus_reranker",
|
| 1081 |
+
ret_metrics_fb.get("faiss_only", {})
|
| 1082 |
+
).get("MRR@5", 0)
|
| 1083 |
+
if total_fb >= 5 and offline_mrr > 0:
|
| 1084 |
+
st.markdown("**Feedback vs Offline Metrics**")
|
| 1085 |
+
comp1, comp2 = st.columns(2)
|
| 1086 |
+
comp1.metric("User Precision (live)", f"{user_precision:.2f}")
|
| 1087 |
+
comp2.metric("MRR@5 (offline eval)", f"{offline_mrr:.4f}")
|
| 1088 |
+
if user_precision >= offline_mrr - 0.1:
|
| 1089 |
+
st.success(
|
| 1090 |
+
"User feedback aligns with offline evaluation. "
|
| 1091 |
+
"Retrieval quality is consistent in practice."
|
| 1092 |
+
)
|
| 1093 |
+
else:
|
| 1094 |
+
st.warning(
|
| 1095 |
+
"User precision is lower than offline MRR. "
|
| 1096 |
+
"This may indicate the eval oracle (shared IPC sections) "
|
| 1097 |
+
"overestimates real-world relevance. "
|
| 1098 |
+
"Collecting more labeled feedback would improve accuracy."
|
| 1099 |
+
)
|
| 1100 |
+
except Exception:
|
| 1101 |
+
pass
|
| 1102 |
+
|
| 1103 |
+
st.caption(
|
| 1104 |
+
"Session feedback resets on page refresh. "
|
| 1105 |
+
"Persistent feedback is saved to data/feedback.json."
|
| 1106 |
+
)
|
| 1107 |
+
|
| 1108 |
+
|
| 1109 |
+
if __name__ == "__main__":
|
| 1110 |
+
main()
|
assets/styles.css
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 2 |
+
|
| 3 |
+
html, body, [class*="css"] {
|
| 4 |
+
font-family: 'Inter', sans-serif !important;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
.main .block-container {
|
| 8 |
+
padding-top: 1.5rem;
|
| 9 |
+
padding-bottom: 2rem;
|
| 10 |
+
max-width: 1200px;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
/* Sidebar */
|
| 14 |
+
[data-testid="stSidebar"] {
|
| 15 |
+
background: #0a0f1e !important;
|
| 16 |
+
border-right: 1px solid #1e293b !important;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/* Header */
|
| 20 |
+
.lexai-header {
|
| 21 |
+
background: linear-gradient(135deg, #0a0f1e 0%, #0f2444 60%, #0a1628 100%);
|
| 22 |
+
border: 1px solid #1e3a5f;
|
| 23 |
+
border-radius: 16px;
|
| 24 |
+
padding: 28px 36px;
|
| 25 |
+
margin-bottom: 24px;
|
| 26 |
+
position: relative;
|
| 27 |
+
overflow: hidden;
|
| 28 |
+
}
|
| 29 |
+
.lexai-header::before {
|
| 30 |
+
content: '';
|
| 31 |
+
position: absolute;
|
| 32 |
+
top: -50%;
|
| 33 |
+
right: -10%;
|
| 34 |
+
width: 300px;
|
| 35 |
+
height: 300px;
|
| 36 |
+
background: radial-gradient(circle, rgba(59,130,246,0.08) 0%, transparent 70%);
|
| 37 |
+
pointer-events: none;
|
| 38 |
+
}
|
| 39 |
+
.lexai-header h1 {
|
| 40 |
+
color: #f1f5f9;
|
| 41 |
+
font-size: 30px;
|
| 42 |
+
font-weight: 700;
|
| 43 |
+
margin: 0 0 6px 0;
|
| 44 |
+
letter-spacing: -0.5px;
|
| 45 |
+
}
|
| 46 |
+
.lexai-header p {
|
| 47 |
+
color: #64748b;
|
| 48 |
+
font-size: 14px;
|
| 49 |
+
margin: 0;
|
| 50 |
+
}
|
| 51 |
+
.lexai-header .tag {
|
| 52 |
+
display: inline-block;
|
| 53 |
+
background: rgba(59,130,246,0.15);
|
| 54 |
+
border: 1px solid rgba(59,130,246,0.3);
|
| 55 |
+
color: #60a5fa;
|
| 56 |
+
padding: 2px 10px;
|
| 57 |
+
border-radius: 20px;
|
| 58 |
+
font-size: 11px;
|
| 59 |
+
font-weight: 600;
|
| 60 |
+
margin-right: 6px;
|
| 61 |
+
text-transform: uppercase;
|
| 62 |
+
letter-spacing: 0.5px;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/* Metric cards */
|
| 66 |
+
.metric-card {
|
| 67 |
+
background: #0f172a;
|
| 68 |
+
border: 1px solid #1e293b;
|
| 69 |
+
border-radius: 12px;
|
| 70 |
+
padding: 20px 24px;
|
| 71 |
+
text-align: center;
|
| 72 |
+
transition: all 0.2s ease;
|
| 73 |
+
height: 100%;
|
| 74 |
+
}
|
| 75 |
+
.metric-card:hover {
|
| 76 |
+
border-color: #3b82f6;
|
| 77 |
+
transform: translateY(-2px);
|
| 78 |
+
box-shadow: 0 8px 25px rgba(59,130,246,0.1);
|
| 79 |
+
}
|
| 80 |
+
.metric-card .value {
|
| 81 |
+
font-size: 34px;
|
| 82 |
+
font-weight: 700;
|
| 83 |
+
color: #f1f5f9;
|
| 84 |
+
line-height: 1;
|
| 85 |
+
margin-bottom: 8px;
|
| 86 |
+
}
|
| 87 |
+
.metric-card .label {
|
| 88 |
+
font-size: 11px;
|
| 89 |
+
color: #475569;
|
| 90 |
+
text-transform: uppercase;
|
| 91 |
+
letter-spacing: 1px;
|
| 92 |
+
font-weight: 500;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/* Search section */
|
| 96 |
+
.section-title {
|
| 97 |
+
font-size: 22px;
|
| 98 |
+
font-weight: 700;
|
| 99 |
+
color: #f1f5f9;
|
| 100 |
+
margin: 0 0 6px 0;
|
| 101 |
+
}
|
| 102 |
+
.section-sub {
|
| 103 |
+
font-size: 14px;
|
| 104 |
+
color: #64748b;
|
| 105 |
+
margin: 0 0 20px 0;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/* Example pills */
|
| 109 |
+
.pill {
|
| 110 |
+
display: inline-block;
|
| 111 |
+
background: #0f172a;
|
| 112 |
+
border: 1px solid #1e293b;
|
| 113 |
+
border-radius: 20px;
|
| 114 |
+
padding: 5px 14px;
|
| 115 |
+
font-size: 12px;
|
| 116 |
+
color: #94a3b8;
|
| 117 |
+
margin: 0 6px 6px 0;
|
| 118 |
+
cursor: pointer;
|
| 119 |
+
transition: all 0.2s;
|
| 120 |
+
}
|
| 121 |
+
.pill:hover {
|
| 122 |
+
border-color: #3b82f6;
|
| 123 |
+
color: #60a5fa;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/* Result cards */
|
| 127 |
+
.result-card {
|
| 128 |
+
background: #0f172a;
|
| 129 |
+
border: 1px solid #1e293b;
|
| 130 |
+
border-radius: 12px;
|
| 131 |
+
padding: 20px 24px;
|
| 132 |
+
margin-bottom: 16px;
|
| 133 |
+
transition: all 0.2s;
|
| 134 |
+
}
|
| 135 |
+
.result-card:hover {
|
| 136 |
+
border-color: #334155;
|
| 137 |
+
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
| 138 |
+
}
|
| 139 |
+
.result-title {
|
| 140 |
+
font-size: 16px;
|
| 141 |
+
font-weight: 600;
|
| 142 |
+
color: #f1f5f9;
|
| 143 |
+
margin-bottom: 4px;
|
| 144 |
+
line-height: 1.4;
|
| 145 |
+
}
|
| 146 |
+
.result-meta {
|
| 147 |
+
font-size: 13px;
|
| 148 |
+
color: #475569;
|
| 149 |
+
margin-bottom: 12px;
|
| 150 |
+
}
|
| 151 |
+
.score-pill {
|
| 152 |
+
background: #0f172a;
|
| 153 |
+
border: 1px solid #334155;
|
| 154 |
+
border-radius: 6px;
|
| 155 |
+
padding: 3px 10px;
|
| 156 |
+
font-size: 12px;
|
| 157 |
+
color: #64748b;
|
| 158 |
+
font-family: 'Courier New', monospace;
|
| 159 |
+
}
|
| 160 |
+
.ik-link {
|
| 161 |
+
display: inline-block;
|
| 162 |
+
background: rgba(59,130,246,0.1);
|
| 163 |
+
border: 1px solid rgba(59,130,246,0.2);
|
| 164 |
+
border-radius: 6px;
|
| 165 |
+
padding: 4px 12px;
|
| 166 |
+
font-size: 12px;
|
| 167 |
+
color: #60a5fa;
|
| 168 |
+
text-decoration: none;
|
| 169 |
+
margin-top: 8px;
|
| 170 |
+
transition: all 0.2s;
|
| 171 |
+
}
|
| 172 |
+
.ik-link:hover {
|
| 173 |
+
background: rgba(59,130,246,0.2);
|
| 174 |
+
color: #93c5fd;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/* Verdict badges */
|
| 178 |
+
.badge {
|
| 179 |
+
display: inline-block;
|
| 180 |
+
padding: 4px 12px;
|
| 181 |
+
border-radius: 20px;
|
| 182 |
+
font-size: 11px;
|
| 183 |
+
font-weight: 600;
|
| 184 |
+
text-transform: uppercase;
|
| 185 |
+
letter-spacing: 0.5px;
|
| 186 |
+
margin-bottom: 8px;
|
| 187 |
+
}
|
| 188 |
+
.badge-convicted { background:#7f1d1d; color:#fca5a5; border:1px solid #991b1b; }
|
| 189 |
+
.badge-acquitted { background:#14532d; color:#86efac; border:1px solid #166534; }
|
| 190 |
+
.badge-bail_granted { background:#713f12; color:#fde68a; border:1px solid #92400e; }
|
| 191 |
+
.badge-bail_rejected{ background:#7c2d12; color:#fdba74; border:1px solid #9a3412; }
|
| 192 |
+
.badge-appeal_allowed { background:#1e3a5f; color:#93c5fd; border:1px solid #1e40af; }
|
| 193 |
+
.badge-appeal_dismissed { background:#3b0764; color:#d8b4fe; border:1px solid #4c1d95; }
|
| 194 |
+
.badge-sentence_modified { background:#1e1b4b; color:#a5b4fc; border:1px solid #312e81; }
|
| 195 |
+
.badge-unknown { background:#1e293b; color:#64748b; border:1px solid #334155; }
|
| 196 |
+
|
| 197 |
+
/* Gap cards */
|
| 198 |
+
.gap-card {
|
| 199 |
+
background: #0f172a;
|
| 200 |
+
border-left: 4px solid #ef4444;
|
| 201 |
+
border-top: 1px solid #1e293b;
|
| 202 |
+
border-right: 1px solid #1e293b;
|
| 203 |
+
border-bottom: 1px solid #1e293b;
|
| 204 |
+
border-radius: 0 12px 12px 0;
|
| 205 |
+
padding: 20px 24px;
|
| 206 |
+
margin-bottom: 24px;
|
| 207 |
+
}
|
| 208 |
+
.gap-card.moderate { border-left-color: #f97316; }
|
| 209 |
+
.gap-card.low { border-left-color: #eab308; }
|
| 210 |
+
.gap-title {
|
| 211 |
+
font-size: 15px;
|
| 212 |
+
font-weight: 600;
|
| 213 |
+
color: #f1f5f9;
|
| 214 |
+
}
|
| 215 |
+
.gap-insight {
|
| 216 |
+
background: rgba(30,58,95,0.5);
|
| 217 |
+
border: 1px solid #1e3a5f;
|
| 218 |
+
border-radius: 8px;
|
| 219 |
+
padding: 12px 16px;
|
| 220 |
+
font-size: 13px;
|
| 221 |
+
color: #93c5fd;
|
| 222 |
+
margin-top: 12px;
|
| 223 |
+
line-height: 1.7;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
/* Quality badge */
|
| 227 |
+
.quality-badge {
|
| 228 |
+
display: inline-block;
|
| 229 |
+
padding: 4px 14px;
|
| 230 |
+
border-radius: 20px;
|
| 231 |
+
font-size: 12px;
|
| 232 |
+
font-weight: 700;
|
| 233 |
+
text-transform: uppercase;
|
| 234 |
+
letter-spacing: 1px;
|
| 235 |
+
}
|
| 236 |
+
.quality-good { background:#14532d; color:#86efac; border:1px solid #166534; }
|
| 237 |
+
.quality-medium { background:#713f12; color:#fde68a; border:1px solid #92400e; }
|
| 238 |
+
.quality-low { background:#7f1d1d; color:#fca5a5; border:1px solid #991b1b; }
|
| 239 |
+
|
| 240 |
+
/* Dividers */
|
| 241 |
+
hr { border-color: #1e293b !important; margin: 28px 0 !important; }
|
| 242 |
+
|
| 243 |
+
/* Hide Streamlit chrome */
|
| 244 |
+
#MainMenu { visibility: hidden; }
|
| 245 |
+
footer { visibility: hidden; }
|
| 246 |
+
|
| 247 |
+
/* Expander */
|
| 248 |
+
.streamlit-expanderHeader {
|
| 249 |
+
background: #0f172a !important;
|
| 250 |
+
border-radius: 8px !important;
|
| 251 |
+
font-size: 13px !important;
|
| 252 |
+
font-weight: 500 !important;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/* Input */
|
| 256 |
+
.stTextArea textarea {
|
| 257 |
+
background: #0f172a !important;
|
| 258 |
+
border: 1px solid #1e293b !important;
|
| 259 |
+
border-radius: 10px !important;
|
| 260 |
+
color: #f1f5f9 !important;
|
| 261 |
+
font-size: 15px !important;
|
| 262 |
+
line-height: 1.6 !important;
|
| 263 |
+
transition: border-color 0.2s !important;
|
| 264 |
+
}
|
| 265 |
+
.stTextArea textarea:focus {
|
| 266 |
+
border-color: #3b82f6 !important;
|
| 267 |
+
box-shadow: 0 0 0 3px rgba(59,130,246,0.1) !important;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
/* Button */
|
| 271 |
+
.stButton > button[kind="primary"] {
|
| 272 |
+
background: linear-gradient(135deg, #2563eb, #1d4ed8) !important;
|
| 273 |
+
color: white !important;
|
| 274 |
+
border: none !important;
|
| 275 |
+
border-radius: 10px !important;
|
| 276 |
+
font-weight: 600 !important;
|
| 277 |
+
font-size: 15px !important;
|
| 278 |
+
padding: 14px 24px !important;
|
| 279 |
+
width: 100% !important;
|
| 280 |
+
transition: all 0.2s !important;
|
| 281 |
+
box-shadow: 0 4px 15px rgba(37,99,235,0.3) !important;
|
| 282 |
+
}
|
| 283 |
+
.stButton > button[kind="primary"]:hover {
|
| 284 |
+
background: linear-gradient(135deg, #1d4ed8, #1e40af) !important;
|
| 285 |
+
box-shadow: 0 6px 20px rgba(37,99,235,0.4) !important;
|
| 286 |
+
transform: translateY(-1px) !important;
|
| 287 |
+
}
|
config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
load_dotenv()
|
| 4 |
+
|
| 5 |
+
INDIAN_KANOON_API_KEY = os.getenv("INDIAN_KANOON_API_KEY", "")
|
| 6 |
+
INDIAN_KANOON_BASE_URL = "https://api.indiankanoon.org"
|
| 7 |
+
|
| 8 |
+
RAW_DIR = "data/raw"
|
| 9 |
+
PROCESSED_DIR = "data/processed"
|
| 10 |
+
DB_PATH = "data/judgments.db"
|
| 11 |
+
CASES_JSON_PATH = "data/processed/cases.json"
|
| 12 |
+
EMBEDDINGS_PATH = "data/processed/embeddings.npy"
|
| 13 |
+
FAISS_INDEX_PATH = "data/processed/faiss.index"
|
| 14 |
+
LABELS_PATH = "data/processed/cluster_labels.npy"
|
| 15 |
+
TOPICS_PATH = "data/processed/cluster_topics.json"
|
| 16 |
+
COORDS_PATH = "data/processed/coords_2d.npy"
|
| 17 |
+
GAPS_PATH = "data/processed/gaps.json"
|
| 18 |
+
METRICS_PATH = "data/processed/eval_metrics.json"
|
| 19 |
+
RETRIEVAL_METRICS_PATH = "data/processed/eval_metrics_retrieval.json" # NEW v3.2
|
| 20 |
+
|
| 21 |
+
EMBEDDING_MODEL = "nlpaueb/legal-bert-base-uncased"
|
| 22 |
+
SPACY_MODEL = "en_core_web_lg"
|
| 23 |
+
|
| 24 |
+
# v3.1: nli-deberta instead of ms-marco
|
| 25 |
+
# ms-marco trained on Bing web search clicks — wrong domain for law
|
| 26 |
+
# nli-deberta trained on NLI entailment — maps to legal reasoning
|
| 27 |
+
RERANKER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
| 28 |
+
|
| 29 |
+
# v3.2 Production config: Disable reranker for latency improvements
|
| 30 |
+
USE_RERANKER = False
|
| 31 |
+
START_WITH_N_CASES = 5000
|
| 32 |
+
UMAP_SUBSAMPLE_LIMIT = 2000
|
| 33 |
+
MAX_TEXT_LENGTH = 512
|
| 34 |
+
BATCH_SIZE = 32
|
| 35 |
+
HDBSCAN_MIN_CLUSTER_SIZE = 10
|
| 36 |
+
MIN_K = 5
|
| 37 |
+
MAX_K = 20
|
| 38 |
+
RANDOM_STATE = 42
|
| 39 |
+
TOP_K_RETRIEVAL = 50
|
| 40 |
+
TOP_K_RESULTS = 5
|
| 41 |
+
|
| 42 |
+
# v3.1: query validation thresholds
|
| 43 |
+
QUERY_MIN_CHARS = 20
|
| 44 |
+
QUERY_MAX_CHARS = 5000
|
| 45 |
+
QUERY_MIN_WORDS = 4
|
| 46 |
+
|
| 47 |
+
# v3.2: retrieval evaluation settings
|
| 48 |
+
EVAL_SAMPLE_SIZE = 100 # how many cases to use as eval queries
|
| 49 |
+
EVAL_TOP_K = 5 # k for MRR@k, P@k, NDCG@k
|
| 50 |
+
|
| 51 |
+
APP_TITLE = "LexAI — Court Judgment Analyzer"
|
| 52 |
+
GDRIVE_OUTPUT_PATH = os.getenv(
|
| 53 |
+
"GDRIVE_OUTPUT_PATH",
|
| 54 |
+
"/content/drive/MyDrive/lexai_outputs"
|
| 55 |
+
)
|
data/processed/cluster_topics.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"0": "Cluster 0",
|
| 3 |
+
"1": "Cluster 1",
|
| 4 |
+
"2": "Cluster 2"
|
| 5 |
+
}
|
data/processed/eval_metrics.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"winner_algorithm": "hdbscan",
|
| 3 |
+
"n_clusters": 3,
|
| 4 |
+
"silhouette_score": 0.8344,
|
| 5 |
+
"davies_bouldin_score": 0.7362,
|
| 6 |
+
"kmeans_best_k": 5,
|
| 7 |
+
"kmeans_best_silhouette": 0.6138,
|
| 8 |
+
"hdbscan_n_clusters": 3,
|
| 9 |
+
"hdbscan_silhouette": 0.8344,
|
| 10 |
+
"total_cases": 5007
|
| 11 |
+
}
|
data/processed/eval_metrics_retrieval.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"evaluation_summary": {
|
| 3 |
+
"total_cases": 500,
|
| 4 |
+
"evaluable_cases": 476,
|
| 5 |
+
"queries_evaluated": 96,
|
| 6 |
+
"queries_skipped": 4,
|
| 7 |
+
"eval_k": 5,
|
| 8 |
+
"relevance_oracle": "shared_ipc_section_and_same_case_type"
|
| 9 |
+
},
|
| 10 |
+
"faiss_only": {
|
| 11 |
+
"MRR@5": 0.5269,
|
| 12 |
+
"P@5": 0.325,
|
| 13 |
+
"NDCG@5": 0.5746
|
| 14 |
+
},
|
| 15 |
+
"faiss_plus_reranker": {
|
| 16 |
+
"MRR@5": 0.5269,
|
| 17 |
+
"P@5": 0.325,
|
| 18 |
+
"NDCG@5": 0.5746
|
| 19 |
+
},
|
| 20 |
+
"reranker_improvement": {
|
| 21 |
+
"MRR_delta": 0.0,
|
| 22 |
+
"P5_delta": 0.0,
|
| 23 |
+
"NDCG_delta": 0.0
|
| 24 |
+
},
|
| 25 |
+
"latency": {
|
| 26 |
+
"avg_embed_ms": 126.2,
|
| 27 |
+
"avg_faiss_ms": 0.1,
|
| 28 |
+
"avg_rerank_ms": 680.5,
|
| 29 |
+
"avg_query_ms": 806.7,
|
| 30 |
+
"p95_query_ms": 908.7
|
| 31 |
+
},
|
| 32 |
+
"model_info": {
|
| 33 |
+
"embedding_model": "nlpaueb/legal-bert-base-uncased",
|
| 34 |
+
"reranker_model": "cross-encoder/ms-marco-MiniLM-L-6-v2",
|
| 35 |
+
"score_column": "entailment (index 2 of 3 NLI labels)"
|
| 36 |
+
}
|
| 37 |
+
}
|
data/processed/gaps.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"cluster_id": 0,
|
| 4 |
+
"total_cases": 2681,
|
| 5 |
+
"positive_outcome_count": 2049,
|
| 6 |
+
"negative_outcome_count": 574,
|
| 7 |
+
"convicted_count": 193,
|
| 8 |
+
"acquitted_count": 1268,
|
| 9 |
+
"bail_granted_count": 781,
|
| 10 |
+
"bail_rejected_count": 381,
|
| 11 |
+
"inconsistency_score": 0.214,
|
| 12 |
+
"common_ipc_sections": [
|
| 13 |
+
"302",
|
| 14 |
+
"34",
|
| 15 |
+
"313",
|
| 16 |
+
"120B",
|
| 17 |
+
"420"
|
| 18 |
+
],
|
| 19 |
+
"dominant_case_type": "criminal",
|
| 20 |
+
"courts_involved": [
|
| 21 |
+
"Chhattisgarh High Court",
|
| 22 |
+
"High Court of Jammu & Kashmir and Ladakh",
|
| 23 |
+
"Karnataka High Court, Bangalore",
|
| 24 |
+
"Madras High Court",
|
| 25 |
+
"Jammu & Kashmir and Ladakh High Court, Srinagar Bench"
|
| 26 |
+
]
|
| 27 |
+
}
|
| 28 |
+
]
|
download_data.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LexAI — HuggingFace data downloader.
|
| 3 |
+
|
| 4 |
+
Downloads large ML artefacts from HuggingFace Hub at startup.
|
| 5 |
+
Files are cached locally — subsequent runs skip the download.
|
| 6 |
+
|
| 7 |
+
Dataset repo: https://huggingface.co/datasets/Satyam810/lexai-data
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
import hashlib
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ── Configuration ────────────────────────────────────────────────────────────
|
| 17 |
+
|
| 18 |
+
HF_DATASET_REPO = "Satyam810/lexai-data" # ← your HF dataset repo name
|
| 19 |
+
|
| 20 |
+
# Files to download: { local_path: filename_in_hf_repo }
|
| 21 |
+
DATA_FILES = {
|
| 22 |
+
"data/processed/cases.json": "cases.json",
|
| 23 |
+
"data/processed/faiss.index": "faiss.index",
|
| 24 |
+
"data/processed/embeddings.npy": "embeddings.npy",
|
| 25 |
+
"data/processed/cluster_labels.npy": "cluster_labels.npy",
|
| 26 |
+
"data/processed/coords_2d.npy": "coords_2d.npy",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ── Downloader ────────────────────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
def download_if_missing(verbose: bool = True) -> bool:
|
| 33 |
+
"""
|
| 34 |
+
Download missing data files from HuggingFace Hub.
|
| 35 |
+
|
| 36 |
+
Returns True if all files are present after download, False on error.
|
| 37 |
+
Call this at the TOP of app.py before any other imports that need the data.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
missing = [
|
| 41 |
+
(local, hf_name)
|
| 42 |
+
for local, hf_name in DATA_FILES.items()
|
| 43 |
+
if not Path(local).exists()
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
if not missing:
|
| 47 |
+
if verbose:
|
| 48 |
+
print("✅ All data files present — skipping download.")
|
| 49 |
+
return True
|
| 50 |
+
|
| 51 |
+
if verbose:
|
| 52 |
+
print(f"📥 Downloading {len(missing)} missing file(s) from HuggingFace...")
|
| 53 |
+
for local, _ in missing:
|
| 54 |
+
print(f" • {local}")
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
from huggingface_hub import hf_hub_download
|
| 58 |
+
except ImportError:
|
| 59 |
+
print(
|
| 60 |
+
"❌ huggingface_hub not installed. "
|
| 61 |
+
"Add it to requirements.txt and re-deploy."
|
| 62 |
+
)
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
# Ensure directories exist
|
| 66 |
+
for local, _ in missing:
|
| 67 |
+
Path(local).parent.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
|
| 69 |
+
all_ok = True
|
| 70 |
+
for local, hf_name in missing:
|
| 71 |
+
try:
|
| 72 |
+
if verbose:
|
| 73 |
+
print(f" ⬇️ Downloading {hf_name}...")
|
| 74 |
+
|
| 75 |
+
downloaded = hf_hub_download(
|
| 76 |
+
repo_id=HF_DATASET_REPO,
|
| 77 |
+
filename=hf_name,
|
| 78 |
+
repo_type="dataset",
|
| 79 |
+
local_dir="data/processed",
|
| 80 |
+
local_dir_use_symlinks=False,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# Verify file landed in the right place
|
| 84 |
+
if Path(downloaded).exists():
|
| 85 |
+
size_mb = Path(downloaded).stat().st_size / (1024 * 1024)
|
| 86 |
+
if verbose:
|
| 87 |
+
print(f" ✅ {hf_name} ({size_mb:.1f} MB)")
|
| 88 |
+
else:
|
| 89 |
+
print(f" ❌ {hf_name} — download succeeded but file not found at {downloaded}")
|
| 90 |
+
all_ok = False
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
print(f" ❌ Failed to download {hf_name}: {e}")
|
| 94 |
+
all_ok = False
|
| 95 |
+
|
| 96 |
+
if all_ok and verbose:
|
| 97 |
+
print("✅ All data files ready.")
|
| 98 |
+
elif not all_ok and verbose:
|
| 99 |
+
print(
|
| 100 |
+
"⚠️ Some files failed to download. "
|
| 101 |
+
"Check your HF_DATASET_REPO setting and that the dataset is public."
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
return all_ok
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
if __name__ == "__main__":
|
| 108 |
+
# Can be run standalone: python download_data.py
|
| 109 |
+
success = download_if_missing(verbose=True)
|
| 110 |
+
sys.exit(0 if success else 1)
|
packages.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# System packages required by Streamlit Community Cloud
|
| 2 |
+
# These are installed via apt-get before pip install
|
| 3 |
+
|
| 4 |
+
# Required for spaCy's en_core_web_lg model
|
| 5 |
+
build-essential
|
| 6 |
+
|
| 7 |
+
# Required for FAISS CPU compilation (if building from source)
|
| 8 |
+
libopenblas-dev
|
| 9 |
+
|
| 10 |
+
# Required for numpy/scipy
|
| 11 |
+
libgfortran5
|
requirements.txt
CHANGED
|
@@ -1,3 +1,31 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LexAI v3.4 — pinned dependency versions for reproducible deployment
|
| 2 |
+
# Python 3.11 required
|
| 3 |
+
|
| 4 |
+
# ── Core App ────────────────────────────────────────────────
|
| 5 |
+
streamlit>=1.32.0,<2.0.0
|
| 6 |
+
python-dotenv>=1.0.0
|
| 7 |
+
huggingface_hub>=0.22.0
|
| 8 |
+
|
| 9 |
+
# ── ML / Embeddings ─────────────────────────────────────────
|
| 10 |
+
torch>=2.0.0
|
| 11 |
+
sentence-transformers>=2.6.0
|
| 12 |
+
faiss-cpu>=1.8.0
|
| 13 |
+
|
| 14 |
+
# ── NLP ─────────────────────────────────────────────────────
|
| 15 |
+
spacy>=3.7.0
|
| 16 |
+
# Run after install: python -m spacy download en_core_web_lg
|
| 17 |
+
|
| 18 |
+
# ── Data Processing ─────────────────────────────────────────
|
| 19 |
+
pandas>=2.2.0
|
| 20 |
+
numpy>=1.26.0
|
| 21 |
+
scikit-learn>=1.4.0
|
| 22 |
+
datasets>=2.18.0
|
| 23 |
+
|
| 24 |
+
# ── Visualisation ───────────────────────────────────────────
|
| 25 |
+
plotly>=5.20.0
|
| 26 |
+
|
| 27 |
+
# ── Network ─────────────────────────────────────────────────
|
| 28 |
+
requests>=2.31.0
|
| 29 |
+
|
| 30 |
+
# ── Testing ─────────────────────────────────────────────────
|
| 31 |
+
pytest>=8.0.0
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# LexAI source package
|
src/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (141 Bytes). View file
|
|
|
src/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (131 Bytes). View file
|
|
|
src/__pycache__/eval_pipeline.cpython-314.pyc
ADDED
|
Binary file (15.6 kB). View file
|
|
|
src/__pycache__/explanation_engine.cpython-311.pyc
ADDED
|
Binary file (6.94 kB). View file
|
|
|
src/__pycache__/explanation_engine.cpython-314.pyc
ADDED
|
Binary file (6.49 kB). View file
|
|
|
src/__pycache__/fetcher.cpython-311.pyc
ADDED
|
Binary file (15.9 kB). View file
|
|
|
src/__pycache__/fetcher.cpython-314.pyc
ADDED
|
Binary file (9.91 kB). View file
|
|
|
src/__pycache__/nlp_pipeline.cpython-311.pyc
ADDED
|
Binary file (15.7 kB). View file
|
|
|
src/__pycache__/nlp_pipeline.cpython-314.pyc
ADDED
|
Binary file (14.1 kB). View file
|
|
|
src/__pycache__/query_validator.cpython-311.pyc
ADDED
|
Binary file (5.96 kB). View file
|
|
|
src/__pycache__/query_validator.cpython-314.pyc
ADDED
|
Binary file (6.64 kB). View file
|
|
|
src/__pycache__/reranker.cpython-311.pyc
ADDED
|
Binary file (3.86 kB). View file
|
|
|
src/__pycache__/reranker.cpython-314.pyc
ADDED
|
Binary file (3.48 kB). View file
|
|
|
src/__pycache__/search_pipeline.cpython-311.pyc
ADDED
|
Binary file (11.3 kB). View file
|
|
|
src/__pycache__/search_pipeline.cpython-314.pyc
ADDED
|
Binary file (11 kB). View file
|
|
|
src/embedder.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Embedding engine — wraps SentenceTransformer for local inference."""
|
| 2 |
+
import numpy as np
|
| 3 |
+
from sentence_transformers import SentenceTransformer
|
| 4 |
+
from config import EMBEDDING_MODEL, MAX_TEXT_LENGTH
|
| 5 |
+
|
| 6 |
+
_model = None
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def get_model():
|
| 10 |
+
global _model
|
| 11 |
+
if _model is None:
|
| 12 |
+
_model = SentenceTransformer(EMBEDDING_MODEL)
|
| 13 |
+
return _model
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def embed_texts(texts: list, batch_size: int = 32) -> np.ndarray:
|
| 17 |
+
"""Encode a list of texts into embeddings."""
|
| 18 |
+
model = get_model()
|
| 19 |
+
truncated = [" ".join(t.split()[:MAX_TEXT_LENGTH]) for t in texts]
|
| 20 |
+
return model.encode(
|
| 21 |
+
truncated, batch_size=batch_size,
|
| 22 |
+
show_progress_bar=len(texts) > 50,
|
| 23 |
+
convert_to_numpy=True
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def embed_query(query: str) -> np.ndarray:
|
| 28 |
+
"""Encode a single query string."""
|
| 29 |
+
model = get_model()
|
| 30 |
+
truncated = " ".join(query.split()[:MAX_TEXT_LENGTH])
|
| 31 |
+
vec = model.encode([truncated], convert_to_numpy=True).astype("float32")
|
| 32 |
+
import faiss
|
| 33 |
+
faiss.normalize_L2(vec)
|
| 34 |
+
return vec
|
src/eval_pipeline.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Retrieval evaluation — self-supervised, no labeled data required.
|
| 3 |
+
|
| 4 |
+
Relevance oracle: two cases are "relevant" if they share
|
| 5 |
+
at least one IPC section AND the same case_type.
|
| 6 |
+
|
| 7 |
+
Metrics:
|
| 8 |
+
MRR@5 Mean Reciprocal Rank at 5
|
| 9 |
+
P@5 Precision at 5
|
| 10 |
+
NDCG@5 Normalized Discounted Cumulative Gain at 5
|
| 11 |
+
|
| 12 |
+
Compares: FAISS-only vs FAISS + cross-encoder reranker.
|
| 13 |
+
|
| 14 |
+
Output: data/processed/eval_metrics_retrieval.json
|
| 15 |
+
Run: python src/eval_pipeline.py
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import json, numpy as np, faiss, math, time, random
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from sentence_transformers import SentenceTransformer, CrossEncoder
|
| 21 |
+
from config import (
|
| 22 |
+
CASES_JSON_PATH, EMBEDDINGS_PATH, FAISS_INDEX_PATH,
|
| 23 |
+
RETRIEVAL_METRICS_PATH, EMBEDDING_MODEL, RERANKER_MODEL,
|
| 24 |
+
TOP_K_RETRIEVAL, TOP_K_RESULTS, EVAL_SAMPLE_SIZE, EVAL_TOP_K,
|
| 25 |
+
MAX_TEXT_LENGTH
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ── Oracle ────────────────────────────────────────────────────────────────────
|
| 30 |
+
|
| 31 |
+
def is_relevant(query_case: dict, candidate_case: dict) -> bool:
|
| 32 |
+
"""
|
| 33 |
+
Self-supervised relevance: shared IPC section + same case type.
|
| 34 |
+
Cases with no IPC sections are unevaluable by this oracle.
|
| 35 |
+
"""
|
| 36 |
+
q_ipc = set(query_case.get("ipc_sections", []))
|
| 37 |
+
c_ipc = set(candidate_case.get("ipc_sections", []))
|
| 38 |
+
q_type = query_case.get("case_type", "")
|
| 39 |
+
c_type = candidate_case.get("case_type", "")
|
| 40 |
+
if not q_ipc or not c_ipc:
|
| 41 |
+
return False
|
| 42 |
+
return bool(q_ipc & c_ipc) and (q_type == c_type)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ── Standard IR metrics ───────────────────────────────────────────────────────
|
| 46 |
+
|
| 47 |
+
def reciprocal_rank(relevant_flags: list) -> float:
|
| 48 |
+
for i, flag in enumerate(relevant_flags):
|
| 49 |
+
if flag:
|
| 50 |
+
return 1.0 / (i + 1)
|
| 51 |
+
return 0.0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def precision_at_k(relevant_flags: list, k: int) -> float:
|
| 55 |
+
return sum(relevant_flags[:k]) / k if k > 0 else 0.0
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def ndcg_at_k(relevant_flags: list, k: int) -> float:
|
| 59 |
+
if k == 0:
|
| 60 |
+
return 0.0
|
| 61 |
+
dcg = sum(flag / math.log2(i + 2) for i, flag in enumerate(relevant_flags[:k]))
|
| 62 |
+
idcg = sum(1.0 / math.log2(i + 2) for i in range(sum(relevant_flags[:k])))
|
| 63 |
+
return dcg / idcg if idcg > 0 else 0.0
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ── Main eval ─────────────────────────────────────────────────────────────────
|
| 67 |
+
|
| 68 |
+
def run_retrieval_eval():
|
| 69 |
+
print("=" * 60)
|
| 70 |
+
print("LEXAI RETRIEVAL EVALUATION")
|
| 71 |
+
print("=" * 60)
|
| 72 |
+
|
| 73 |
+
# Load assets
|
| 74 |
+
print("\nLoading cases and index...")
|
| 75 |
+
with open(CASES_JSON_PATH, encoding="utf-8") as f:
|
| 76 |
+
cases = json.load(f)
|
| 77 |
+
|
| 78 |
+
embeddings = np.load(EMBEDDINGS_PATH).astype("float32")
|
| 79 |
+
index = faiss.read_index(FAISS_INDEX_PATH)
|
| 80 |
+
embed_model = SentenceTransformer(EMBEDDING_MODEL)
|
| 81 |
+
rerank_model = CrossEncoder(RERANKER_MODEL, max_length=512)
|
| 82 |
+
|
| 83 |
+
print(f" Cases: {len(cases)} | Embeddings: {embeddings.shape} | FAISS: {index.ntotal}")
|
| 84 |
+
|
| 85 |
+
# Select eval queries — only cases with IPC sections
|
| 86 |
+
evaluable = [
|
| 87 |
+
(i, c) for i, c in enumerate(cases)
|
| 88 |
+
if c.get("ipc_sections")
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
if len(evaluable) < 10:
|
| 92 |
+
print(f"\n⚠️ Only {len(evaluable)} evaluable cases (need >= 10 with IPC sections).")
|
| 93 |
+
print(" Re-run nlp_pipeline.py to improve IPC extraction, then retry.")
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
random.seed(42)
|
| 97 |
+
sample_size = min(EVAL_SAMPLE_SIZE, len(evaluable))
|
| 98 |
+
eval_sample = random.sample(evaluable, sample_size)
|
| 99 |
+
print(f"\nEval queries: {sample_size} (from {len(evaluable)} evaluable cases)")
|
| 100 |
+
|
| 101 |
+
# Evaluation loop
|
| 102 |
+
faiss_mrr, faiss_p5, faiss_ndcg = [], [], []
|
| 103 |
+
rerank_mrr, rerank_p5, rerank_ndcg = [], [], []
|
| 104 |
+
embed_latencies = []
|
| 105 |
+
faiss_latencies = []
|
| 106 |
+
rerank_latencies = []
|
| 107 |
+
skipped = 0
|
| 108 |
+
|
| 109 |
+
import numpy as _np
|
| 110 |
+
|
| 111 |
+
for eval_idx, (case_idx, query_case) in enumerate(eval_sample):
|
| 112 |
+
if eval_idx % 20 == 0:
|
| 113 |
+
print(f" [{eval_idx}/{sample_size}] evaluating...")
|
| 114 |
+
|
| 115 |
+
# Embed query
|
| 116 |
+
q_words = query_case["text"].split()[:MAX_TEXT_LENGTH]
|
| 117 |
+
t_embed = time.perf_counter()
|
| 118 |
+
q_emb = embed_model.encode(
|
| 119 |
+
[" ".join(q_words)], convert_to_numpy=True
|
| 120 |
+
).astype("float32")
|
| 121 |
+
embed_latencies.append((time.perf_counter() - t_embed) * 1000)
|
| 122 |
+
|
| 123 |
+
faiss.normalize_L2(q_emb)
|
| 124 |
+
|
| 125 |
+
# FAISS search
|
| 126 |
+
t_faiss = time.perf_counter()
|
| 127 |
+
scores_arr, idxs = index.search(q_emb, TOP_K_RETRIEVAL + 1)
|
| 128 |
+
faiss_latencies.append((time.perf_counter() - t_faiss) * 1000)
|
| 129 |
+
|
| 130 |
+
# Exclude query case itself
|
| 131 |
+
faiss_candidates = [
|
| 132 |
+
(cases[idx], float(sc))
|
| 133 |
+
for idx, sc in zip(idxs[0], scores_arr[0])
|
| 134 |
+
if idx != case_idx and 0 <= idx < len(cases)
|
| 135 |
+
][:TOP_K_RETRIEVAL]
|
| 136 |
+
|
| 137 |
+
if not faiss_candidates:
|
| 138 |
+
skipped += 1
|
| 139 |
+
continue
|
| 140 |
+
|
| 141 |
+
# Check oracle: any relevant in full candidate set?
|
| 142 |
+
all_flags = [is_relevant(query_case, c) for c, _ in faiss_candidates]
|
| 143 |
+
if not any(all_flags):
|
| 144 |
+
skipped += 1
|
| 145 |
+
continue
|
| 146 |
+
|
| 147 |
+
# FAISS-only metrics
|
| 148 |
+
faiss_flags = [is_relevant(query_case, c) for c, _ in faiss_candidates[:EVAL_TOP_K]]
|
| 149 |
+
faiss_mrr.append(reciprocal_rank(faiss_flags))
|
| 150 |
+
faiss_p5.append(precision_at_k(faiss_flags, EVAL_TOP_K))
|
| 151 |
+
faiss_ndcg.append(ndcg_at_k(faiss_flags, EVAL_TOP_K))
|
| 152 |
+
|
| 153 |
+
# Reranking
|
| 154 |
+
pairs = [
|
| 155 |
+
[query_case["text"][:256], cand["text"][:400]]
|
| 156 |
+
for cand, _ in faiss_candidates
|
| 157 |
+
]
|
| 158 |
+
t_rerank = time.perf_counter()
|
| 159 |
+
raw_scores = rerank_model.predict(pairs, apply_softmax=True, show_progress_bar=False)
|
| 160 |
+
rerank_latencies.append((time.perf_counter() - t_rerank) * 1000)
|
| 161 |
+
|
| 162 |
+
raw_scores = _np.array(raw_scores)
|
| 163 |
+
if raw_scores.ndim == 2 and raw_scores.shape[1] == 3:
|
| 164 |
+
scores = raw_scores[:, 2] # entailment column
|
| 165 |
+
else:
|
| 166 |
+
scores = raw_scores.flatten()
|
| 167 |
+
|
| 168 |
+
reranked = sorted(
|
| 169 |
+
zip(faiss_candidates, scores.tolist()),
|
| 170 |
+
key=lambda x: x[1], reverse=True
|
| 171 |
+
)
|
| 172 |
+
rerank_top5 = [cand for (cand, _), _ in reranked[:EVAL_TOP_K]]
|
| 173 |
+
rerank_flags = [is_relevant(query_case, c) for c in rerank_top5]
|
| 174 |
+
|
| 175 |
+
rerank_mrr.append(reciprocal_rank(rerank_flags))
|
| 176 |
+
rerank_p5.append(precision_at_k(rerank_flags, EVAL_TOP_K))
|
| 177 |
+
rerank_ndcg.append(ndcg_at_k(rerank_flags, EVAL_TOP_K))
|
| 178 |
+
|
| 179 |
+
# Aggregate
|
| 180 |
+
evaluated = len(faiss_mrr)
|
| 181 |
+
if evaluated == 0:
|
| 182 |
+
print("\n⚠️ No queries evaluated. Check IPC section extraction.")
|
| 183 |
+
return None
|
| 184 |
+
|
| 185 |
+
def avg(lst): return round(sum(lst) / len(lst), 4) if lst else 0.0
|
| 186 |
+
|
| 187 |
+
all_latencies = [e + f + r for e, f, r in zip(embed_latencies, faiss_latencies, rerank_latencies)]
|
| 188 |
+
|
| 189 |
+
results = {
|
| 190 |
+
"evaluation_summary": {
|
| 191 |
+
"total_cases": len(cases),
|
| 192 |
+
"evaluable_cases": len(evaluable),
|
| 193 |
+
"queries_evaluated": evaluated,
|
| 194 |
+
"queries_skipped": skipped,
|
| 195 |
+
"eval_k": EVAL_TOP_K,
|
| 196 |
+
"relevance_oracle": "shared_ipc_section_and_same_case_type",
|
| 197 |
+
},
|
| 198 |
+
"faiss_only": {
|
| 199 |
+
"MRR@5": avg(faiss_mrr),
|
| 200 |
+
"P@5": avg(faiss_p5),
|
| 201 |
+
"NDCG@5": avg(faiss_ndcg),
|
| 202 |
+
},
|
| 203 |
+
"faiss_plus_reranker": {
|
| 204 |
+
"MRR@5": avg(rerank_mrr),
|
| 205 |
+
"P@5": avg(rerank_p5),
|
| 206 |
+
"NDCG@5": avg(rerank_ndcg),
|
| 207 |
+
},
|
| 208 |
+
"reranker_improvement": {
|
| 209 |
+
"MRR_delta": round(avg(rerank_mrr) - avg(faiss_mrr), 4),
|
| 210 |
+
"P5_delta": round(avg(rerank_p5) - avg(faiss_p5), 4),
|
| 211 |
+
"NDCG_delta": round(avg(rerank_ndcg) - avg(faiss_ndcg), 4),
|
| 212 |
+
},
|
| 213 |
+
"latency": {
|
| 214 |
+
"avg_embed_ms": round(avg(embed_latencies), 1),
|
| 215 |
+
"avg_faiss_ms": round(avg(faiss_latencies), 1),
|
| 216 |
+
"avg_rerank_ms": round(avg(rerank_latencies), 1),
|
| 217 |
+
"avg_query_ms": round(avg(all_latencies), 1),
|
| 218 |
+
"p95_query_ms": round(
|
| 219 |
+
sorted(all_latencies)[int(len(all_latencies) * 0.95)]
|
| 220 |
+
if all_latencies else 0, 1
|
| 221 |
+
),
|
| 222 |
+
},
|
| 223 |
+
"model_info": {
|
| 224 |
+
"embedding_model": EMBEDDING_MODEL,
|
| 225 |
+
"reranker_model": RERANKER_MODEL,
|
| 226 |
+
"score_column": "entailment (index 2 of 3 NLI labels)",
|
| 227 |
+
},
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
# Save
|
| 231 |
+
Path(RETRIEVAL_METRICS_PATH).parent.mkdir(parents=True, exist_ok=True)
|
| 232 |
+
with open(RETRIEVAL_METRICS_PATH, "w", encoding="utf-8") as f:
|
| 233 |
+
json.dump(results, f, indent=2)
|
| 234 |
+
|
| 235 |
+
# Print report
|
| 236 |
+
fi = results["faiss_only"]
|
| 237 |
+
re = results["faiss_plus_reranker"]
|
| 238 |
+
dlt = results["reranker_improvement"]
|
| 239 |
+
lat = results["latency"]
|
| 240 |
+
|
| 241 |
+
print(f"\n{'='*60}")
|
| 242 |
+
print("RESULTS")
|
| 243 |
+
print(f" Evaluated: {evaluated} queries | Skipped: {skipped}")
|
| 244 |
+
print(f" {'Metric':<12} {'FAISS only':>12} {'+ Reranker':>12} {'Delta':>10}")
|
| 245 |
+
print(f" {'-'*52}")
|
| 246 |
+
for metric, f_key, r_key, d_key in [
|
| 247 |
+
("MRR@5", "MRR@5", "MRR@5", "MRR_delta"),
|
| 248 |
+
("P@5", "P@5", "P@5", "P5_delta"),
|
| 249 |
+
("NDCG@5", "NDCG@5", "NDCG@5", "NDCG_delta"),
|
| 250 |
+
]:
|
| 251 |
+
f_val = fi[f_key]; r_val = re[r_key]; d = dlt[d_key]
|
| 252 |
+
sign = "+" if d >= 0 else ""
|
| 253 |
+
print(f" {metric:<12} {f_val:>12.4f} {r_val:>12.4f} {sign}{d:>9.4f}")
|
| 254 |
+
|
| 255 |
+
print(f"\n Latency breakdown:")
|
| 256 |
+
print(f" Embed: {lat['avg_embed_ms']}ms")
|
| 257 |
+
print(f" FAISS: {lat['avg_faiss_ms']}ms")
|
| 258 |
+
print(f" Rerank: {lat['avg_rerank_ms']}ms")
|
| 259 |
+
print(f" Total: {lat['avg_query_ms']}ms avg / {lat['p95_query_ms']}ms P95")
|
| 260 |
+
|
| 261 |
+
ndcg_delta = dlt["NDCG_delta"]
|
| 262 |
+
print(f"\n{'='*60}")
|
| 263 |
+
if ndcg_delta >= 0:
|
| 264 |
+
print(f"✅ Reranker improved NDCG by +{ndcg_delta:.4f}")
|
| 265 |
+
print(" Two-stage pipeline adds measurable value.")
|
| 266 |
+
else:
|
| 267 |
+
print(f"⚠️ Reranker NDCG delta = {ndcg_delta:.4f} (reranker degraded vs FAISS)")
|
| 268 |
+
print(" Documented as known limitation — NLI model not fine-tuned on Indian law.")
|
| 269 |
+
print(" FAISS bi-encoder alone performs well on this dataset.")
|
| 270 |
+
print(" Fine-tuning on labeled pairs would resolve this.")
|
| 271 |
+
|
| 272 |
+
print(f"\n*** RESUME / INTERVIEW NUMBERS ***")
|
| 273 |
+
print(f" MRR@5 (reranker): {re['MRR@5']:.4f}")
|
| 274 |
+
print(f" P@5 (reranker): {re['P@5']:.4f}")
|
| 275 |
+
print(f" NDCG@5 (reranker): {re['NDCG@5']:.4f}")
|
| 276 |
+
print(f" NDCG delta: {ndcg_delta:+.4f}")
|
| 277 |
+
print(f" Avg query latency: {lat['avg_query_ms']}ms")
|
| 278 |
+
print(f"{'='*60}")
|
| 279 |
+
|
| 280 |
+
return results
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
if __name__ == "__main__":
|
| 284 |
+
run_retrieval_eval()
|
src/explanation_engine.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Explanation engine — deterministic, template-based structured diff.
|
| 3 |
+
|
| 4 |
+
NO LLM dependency. No API calls. Always fast. Always consistent.
|
| 5 |
+
|
| 6 |
+
Exported functions (both required by search_pipeline.py):
|
| 7 |
+
explain_similarity(query_case, retrieved_case, similarity_score) -> dict
|
| 8 |
+
explain_results(query_case, results) -> list[dict]
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def explain_similarity(
|
| 13 |
+
query_case: dict,
|
| 14 |
+
retrieved_case: dict,
|
| 15 |
+
similarity_score: float
|
| 16 |
+
) -> dict:
|
| 17 |
+
"""
|
| 18 |
+
Generate structured explanation comparing query to one retrieved case.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
query_case: NLP-processed dict for the user's query text
|
| 22 |
+
retrieved_case: NLP-processed dict for a retrieved result
|
| 23 |
+
similarity_score: reranker relevance score (float)
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
dict with 10 keys
|
| 27 |
+
"""
|
| 28 |
+
# IPC comparison
|
| 29 |
+
q_ipc = set(query_case.get("ipc_sections", []))
|
| 30 |
+
r_ipc = set(retrieved_case.get("ipc_sections", []))
|
| 31 |
+
shared_ipc = sorted(q_ipc & r_ipc)
|
| 32 |
+
q_only_ipc = sorted(q_ipc - r_ipc)
|
| 33 |
+
r_only_ipc = sorted(r_ipc - q_ipc)
|
| 34 |
+
|
| 35 |
+
# Evidence comparison
|
| 36 |
+
q_evidence = set(query_case.get("evidence_types", []))
|
| 37 |
+
r_evidence = set(retrieved_case.get("evidence_types", []))
|
| 38 |
+
shared_evidence = sorted(q_evidence & r_evidence)
|
| 39 |
+
q_only_evidence = sorted(q_evidence - r_evidence)
|
| 40 |
+
r_only_evidence = sorted(r_evidence - q_evidence)
|
| 41 |
+
|
| 42 |
+
# Case type
|
| 43 |
+
q_type = query_case.get("case_type", "unknown")
|
| 44 |
+
r_type = retrieved_case.get("case_type", "unknown")
|
| 45 |
+
shared_case_type = (q_type == r_type)
|
| 46 |
+
|
| 47 |
+
# Court and verdict
|
| 48 |
+
q_court = query_case.get("court", "unknown")
|
| 49 |
+
r_court = retrieved_case.get("court", "unknown")
|
| 50 |
+
q_verdict = query_case.get("verdict", "unknown")
|
| 51 |
+
r_verdict = retrieved_case.get("verdict", "unknown")
|
| 52 |
+
|
| 53 |
+
# ── Similarity reason ──────────────────────────────────────────────────
|
| 54 |
+
reasons = []
|
| 55 |
+
if shared_ipc:
|
| 56 |
+
reasons.append(f"both cite IPC {', '.join(shared_ipc)}")
|
| 57 |
+
if shared_case_type:
|
| 58 |
+
reasons.append(f"both are {q_type} cases")
|
| 59 |
+
if shared_evidence:
|
| 60 |
+
reasons.append(f"both involve {', '.join(shared_evidence)} evidence")
|
| 61 |
+
if not reasons:
|
| 62 |
+
reasons.append(
|
| 63 |
+
"high semantic similarity in legal language and factual context"
|
| 64 |
+
)
|
| 65 |
+
similarity_reason = "Similarity: " + "; ".join(reasons).capitalize() + "."
|
| 66 |
+
|
| 67 |
+
# ── Key differences ────────────────────────────────────────────────────
|
| 68 |
+
diffs = []
|
| 69 |
+
if q_only_ipc:
|
| 70 |
+
diffs.append(f"your case cites IPC {', '.join(q_only_ipc)} (absent here)")
|
| 71 |
+
if r_only_ipc:
|
| 72 |
+
diffs.append(f"this case additionally cites IPC {', '.join(r_only_ipc)}")
|
| 73 |
+
if q_only_evidence:
|
| 74 |
+
diffs.append(
|
| 75 |
+
f"your case has {', '.join(q_only_evidence)} evidence (absent here)"
|
| 76 |
+
)
|
| 77 |
+
if r_only_evidence:
|
| 78 |
+
diffs.append(
|
| 79 |
+
f"this case has {', '.join(r_only_evidence)} evidence (absent in yours)"
|
| 80 |
+
)
|
| 81 |
+
if not shared_case_type:
|
| 82 |
+
diffs.append(
|
| 83 |
+
f"case type differs: yours is {q_type}, this is {r_type}"
|
| 84 |
+
)
|
| 85 |
+
if q_court != r_court and r_court != "unknown":
|
| 86 |
+
diffs.append(f"decided by {r_court}")
|
| 87 |
+
if not diffs:
|
| 88 |
+
diffs.append("no major structural differences detected")
|
| 89 |
+
key_differences = "Differences: " + "; ".join(diffs).capitalize() + "."
|
| 90 |
+
|
| 91 |
+
# ── Verdict analysis ───────────────────────────────────────────────────
|
| 92 |
+
if q_verdict == "unknown" or r_verdict == "unknown":
|
| 93 |
+
verdict_analysis = (
|
| 94 |
+
"Verdict comparison: unable to extract verdicts reliably "
|
| 95 |
+
"from one or both cases."
|
| 96 |
+
)
|
| 97 |
+
elif q_verdict == r_verdict:
|
| 98 |
+
verdict_analysis = (
|
| 99 |
+
f"Verdict alignment: both cases resulted in {r_verdict}."
|
| 100 |
+
)
|
| 101 |
+
else:
|
| 102 |
+
verdict_factors = []
|
| 103 |
+
if "forensic" in r_evidence and "forensic" not in q_evidence:
|
| 104 |
+
verdict_factors.append("this case had forensic evidence")
|
| 105 |
+
if "eyewitness" in r_evidence and "eyewitness" not in q_evidence:
|
| 106 |
+
verdict_factors.append("this case had eyewitness testimony")
|
| 107 |
+
if "confession" in r_evidence and "confession" not in q_evidence:
|
| 108 |
+
verdict_factors.append("this case included a confession")
|
| 109 |
+
if "forensic" in q_evidence and "forensic" not in r_evidence:
|
| 110 |
+
verdict_factors.append(
|
| 111 |
+
"your case has forensic evidence this one lacked"
|
| 112 |
+
)
|
| 113 |
+
if verdict_factors:
|
| 114 |
+
verdict_analysis = (
|
| 115 |
+
f"Verdict divergence: your case trends {q_verdict}, "
|
| 116 |
+
f"this case was {r_verdict}. "
|
| 117 |
+
f"Possible factor: {'; '.join(verdict_factors)}."
|
| 118 |
+
)
|
| 119 |
+
else:
|
| 120 |
+
verdict_analysis = (
|
| 121 |
+
f"Verdict divergence: your case trends {q_verdict}, "
|
| 122 |
+
f"this case was {r_verdict}. "
|
| 123 |
+
f"Similar charges led to opposite outcomes — review carefully."
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
return {
|
| 127 |
+
"similarity_score": round(similarity_score, 3),
|
| 128 |
+
"similarity_reason": similarity_reason,
|
| 129 |
+
"key_differences": key_differences,
|
| 130 |
+
"verdict_analysis": verdict_analysis,
|
| 131 |
+
"shared_ipc": shared_ipc,
|
| 132 |
+
"shared_evidence": shared_evidence,
|
| 133 |
+
"shared_case_type": shared_case_type,
|
| 134 |
+
"retrieved_verdict": r_verdict,
|
| 135 |
+
"retrieved_court": r_court,
|
| 136 |
+
"retrieved_date": retrieved_case.get("date", ""),
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def explain_results(query_case: dict, results: list) -> list:
|
| 141 |
+
"""
|
| 142 |
+
Run explanation for all reranked results.
|
| 143 |
+
|
| 144 |
+
Args:
|
| 145 |
+
query_case: NLP-processed dict for the query
|
| 146 |
+
results: list of (case_dict, score) tuples from reranker
|
| 147 |
+
|
| 148 |
+
Returns:
|
| 149 |
+
list of explanation dicts, one per result
|
| 150 |
+
"""
|
| 151 |
+
return [
|
| 152 |
+
explain_similarity(query_case, case, score)
|
| 153 |
+
for case, score in results
|
| 154 |
+
]
|
src/fetcher.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3, json, requests, time, logging
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from config import (
|
| 4 |
+
INDIAN_KANOON_API_KEY, INDIAN_KANOON_BASE_URL,
|
| 5 |
+
DB_PATH, START_WITH_N_CASES
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
logging.basicConfig(
|
| 9 |
+
level=logging.INFO,
|
| 10 |
+
format="%(asctime)s %(levelname)s %(message)s"
|
| 11 |
+
)
|
| 12 |
+
log = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
# ── SCHEMA VERIFIED: SnehaDeshmukh/IndianBailJudgments-1200 ───────────────
|
| 15 |
+
# Fields: facts, judgment_reason, summary, ipc_sections, court, date,
|
| 16 |
+
# bail_outcome, crime_type, case_title, judge, accused_name, etc.
|
| 17 |
+
# Text = combined facts + judgment_reason + summary (~870 chars avg)
|
| 18 |
+
DATASET_NAME = "SnehaDeshmukh/IndianBailJudgments-1200"
|
| 19 |
+
TEXT_FIELDS = ["facts", "judgment_reason", "summary"] # combined into raw_text
|
| 20 |
+
COURT_FIELD = "court"
|
| 21 |
+
DATE_FIELD = "date"
|
| 22 |
+
VERDICT_FIELD = "bail_outcome"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def validate_schema(row: dict) -> tuple:
|
| 26 |
+
"""
|
| 27 |
+
v3.1: Validate first row before processing all cases.
|
| 28 |
+
Returns (is_valid: bool, reason: str)
|
| 29 |
+
"""
|
| 30 |
+
for field in TEXT_FIELDS:
|
| 31 |
+
if field not in row:
|
| 32 |
+
return False, (
|
| 33 |
+
f"Expected field '{field}' not found in row. "
|
| 34 |
+
f"Available fields: {list(row.keys())}. "
|
| 35 |
+
f"Dataset schema may have changed."
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Combine text fields and check length
|
| 39 |
+
combined = " ".join(str(row.get(f, "")) for f in TEXT_FIELDS)
|
| 40 |
+
if len(combined.strip()) < 50:
|
| 41 |
+
return False, (
|
| 42 |
+
f"Combined text fields too short ({len(combined)} chars). "
|
| 43 |
+
f"Fields: {TEXT_FIELDS}"
|
| 44 |
+
)
|
| 45 |
+
return True, "ok"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def init_database():
|
| 49 |
+
conn = sqlite3.connect(DB_PATH)
|
| 50 |
+
conn.execute("""
|
| 51 |
+
CREATE TABLE IF NOT EXISTS cases (
|
| 52 |
+
id TEXT PRIMARY KEY,
|
| 53 |
+
court TEXT,
|
| 54 |
+
date TEXT,
|
| 55 |
+
raw_text TEXT,
|
| 56 |
+
source TEXT,
|
| 57 |
+
meta TEXT
|
| 58 |
+
)
|
| 59 |
+
""")
|
| 60 |
+
conn.commit()
|
| 61 |
+
return conn
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def fetch_from_huggingface(max_cases=500):
|
| 65 |
+
from datasets import load_dataset
|
| 66 |
+
|
| 67 |
+
log.info(f"Loading {max_cases} diverse cases from {DATASET_NAME}...")
|
| 68 |
+
ds = load_dataset(
|
| 69 |
+
DATASET_NAME,
|
| 70 |
+
split="train",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# Stratified sampling: shuffle to ensure diversity instead of just top N
|
| 74 |
+
if len(ds) > max_cases:
|
| 75 |
+
ds = ds.shuffle(seed=42).select(range(max_cases))
|
| 76 |
+
else:
|
| 77 |
+
log.warning(f"Dataset has {len(ds)} cases, requesting {max_cases}. Taking all.")
|
| 78 |
+
ds = ds.shuffle(seed=42)
|
| 79 |
+
|
| 80 |
+
if len(ds) == 0:
|
| 81 |
+
raise ValueError("Dataset returned 0 rows.")
|
| 82 |
+
|
| 83 |
+
first_row = dict(ds[0])
|
| 84 |
+
valid, reason = validate_schema(first_row)
|
| 85 |
+
if not valid:
|
| 86 |
+
raise ValueError(
|
| 87 |
+
f"SCHEMA VALIDATION FAILED: {reason}\n"
|
| 88 |
+
f"Run the Phase 2.1 schema verification snippet first."
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
log.info(
|
| 92 |
+
f"Schema valid. TEXT_FIELDS={TEXT_FIELDS}. "
|
| 93 |
+
f"Processing {len(ds)} rows..."
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
empty_text_count = 0
|
| 97 |
+
cases = []
|
| 98 |
+
|
| 99 |
+
for i, row in enumerate(ds):
|
| 100 |
+
# Combine text fields: facts + judgment_reason + summary
|
| 101 |
+
text_parts = []
|
| 102 |
+
for field in TEXT_FIELDS:
|
| 103 |
+
val = str(row.get(field, "")).strip()
|
| 104 |
+
if val and val.lower() != "none":
|
| 105 |
+
text_parts.append(val)
|
| 106 |
+
combined_text = " ".join(text_parts)
|
| 107 |
+
|
| 108 |
+
if len(combined_text.strip()) < 50:
|
| 109 |
+
empty_text_count += 1
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
+
# Extract IPC sections from the dataset (already parsed!)
|
| 113 |
+
ipc_raw = row.get("ipc_sections", "[]")
|
| 114 |
+
try:
|
| 115 |
+
if isinstance(ipc_raw, str):
|
| 116 |
+
ipc_sections = json.loads(ipc_raw.replace("'", '"'))
|
| 117 |
+
elif isinstance(ipc_raw, list):
|
| 118 |
+
ipc_sections = ipc_raw
|
| 119 |
+
else:
|
| 120 |
+
ipc_sections = []
|
| 121 |
+
except (json.JSONDecodeError, Exception):
|
| 122 |
+
ipc_sections = []
|
| 123 |
+
|
| 124 |
+
case_id = str(row.get("case_id", f"bail_{i}"))
|
| 125 |
+
|
| 126 |
+
cases.append({
|
| 127 |
+
"id": f"hf_{case_id}",
|
| 128 |
+
"court": str(row.get(COURT_FIELD, "unknown")),
|
| 129 |
+
"date": str(row.get(DATE_FIELD, "")),
|
| 130 |
+
"raw_text": combined_text,
|
| 131 |
+
"source": "huggingface",
|
| 132 |
+
"meta": json.dumps({
|
| 133 |
+
"length": len(combined_text),
|
| 134 |
+
"row_index": i,
|
| 135 |
+
"dataset": DATASET_NAME,
|
| 136 |
+
"case_title": str(row.get("case_title", "")),
|
| 137 |
+
"bail_outcome": str(row.get(VERDICT_FIELD, "")),
|
| 138 |
+
"crime_type": str(row.get("crime_type", "")),
|
| 139 |
+
"ipc_sections": ipc_sections,
|
| 140 |
+
"judge": str(row.get("judge", "")),
|
| 141 |
+
"accused_name": str(row.get("accused_name", "")),
|
| 142 |
+
"bail_type": str(row.get("bail_type", "")),
|
| 143 |
+
})
|
| 144 |
+
})
|
| 145 |
+
|
| 146 |
+
log.info(
|
| 147 |
+
f"Loaded {len(cases)} valid cases. "
|
| 148 |
+
f"{empty_text_count} skipped (empty text)."
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
if empty_text_count > max_cases * 0.3:
|
| 152 |
+
log.warning(
|
| 153 |
+
f"WARNING: {empty_text_count}/{max_cases} rows had empty text. "
|
| 154 |
+
f"TEXT_FIELDS={TEXT_FIELDS} may be wrong."
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
return cases
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def fetch_from_indian_kanoon(
|
| 161 |
+
query: str,
|
| 162 |
+
pages: int = 10,
|
| 163 |
+
method: str = "GET"
|
| 164 |
+
) -> list:
|
| 165 |
+
"""
|
| 166 |
+
Fetch cases from Indian Kanoon API.
|
| 167 |
+
- Uses GET or POST based on what the API accepts
|
| 168 |
+
- 1.5 second delay after every request
|
| 169 |
+
- Retries up to 3 times on failure
|
| 170 |
+
- Deduplicates by doc id or hash(title+court)
|
| 171 |
+
"""
|
| 172 |
+
import time, hashlib
|
| 173 |
+
|
| 174 |
+
if not INDIAN_KANOON_API_KEY:
|
| 175 |
+
log.info("No Indian Kanoon API key. Skipping.")
|
| 176 |
+
return []
|
| 177 |
+
|
| 178 |
+
headers = {"Authorization": f"Token {INDIAN_KANOON_API_KEY}"}
|
| 179 |
+
cases = []
|
| 180 |
+
|
| 181 |
+
for page in range(1, pages + 1):
|
| 182 |
+
# Retry logic — up to 3 attempts per page
|
| 183 |
+
success = False
|
| 184 |
+
for attempt in range(1, 4):
|
| 185 |
+
try:
|
| 186 |
+
if method == "GET":
|
| 187 |
+
r = requests.get(
|
| 188 |
+
f"{INDIAN_KANOON_BASE_URL}/search/",
|
| 189 |
+
params={"formInput": query, "pagenum": page},
|
| 190 |
+
headers=headers,
|
| 191 |
+
timeout=30
|
| 192 |
+
)
|
| 193 |
+
else:
|
| 194 |
+
r = requests.post(
|
| 195 |
+
f"{INDIAN_KANOON_BASE_URL}/search/",
|
| 196 |
+
data={"formInput": query, "pagenum": page},
|
| 197 |
+
headers=headers,
|
| 198 |
+
timeout=30
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
if r.status_code == 200:
|
| 202 |
+
docs = r.json().get("docs", [])
|
| 203 |
+
for doc in docs:
|
| 204 |
+
cases.append({
|
| 205 |
+
"id": str(doc.get("tid", "")),
|
| 206 |
+
"court": doc.get("court", "unknown"),
|
| 207 |
+
"date": doc.get("publishdate", ""),
|
| 208 |
+
"raw_text": doc.get("doc", ""),
|
| 209 |
+
"source": "indiankanoon",
|
| 210 |
+
"meta": json.dumps(doc)
|
| 211 |
+
})
|
| 212 |
+
log.info(
|
| 213 |
+
f"Query '{query}' page {page}: "
|
| 214 |
+
f"{len(docs)} docs fetched"
|
| 215 |
+
)
|
| 216 |
+
success = True
|
| 217 |
+
break
|
| 218 |
+
else:
|
| 219 |
+
log.warning(
|
| 220 |
+
f"Attempt {attempt}/3 — "
|
| 221 |
+
f"Status {r.status_code} for '{query}' page {page}"
|
| 222 |
+
)
|
| 223 |
+
except Exception as e:
|
| 224 |
+
log.warning(
|
| 225 |
+
f"Attempt {attempt}/3 — "
|
| 226 |
+
f"Error for '{query}' page {page}: {e}"
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# Wait before retry
|
| 230 |
+
time.sleep(2)
|
| 231 |
+
|
| 232 |
+
if not success:
|
| 233 |
+
log.error(
|
| 234 |
+
f"Skipping '{query}' page {page} "
|
| 235 |
+
f"after 3 failed attempts."
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
# Mandatory delay after every request (success or skip)
|
| 239 |
+
time.sleep(1.5)
|
| 240 |
+
|
| 241 |
+
log.info(f"Fetched {len(cases)} raw cases for '{query}'.")
|
| 242 |
+
return cases
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def deduplicate_cases(cases: list) -> list:
|
| 246 |
+
"""
|
| 247 |
+
Remove duplicate cases using:
|
| 248 |
+
1. doc id (tid) if available and non-empty
|
| 249 |
+
2. hash(title + court) as fallback
|
| 250 |
+
Returns unique cases only.
|
| 251 |
+
"""
|
| 252 |
+
import hashlib
|
| 253 |
+
seen = set()
|
| 254 |
+
unique = []
|
| 255 |
+
|
| 256 |
+
for c in cases:
|
| 257 |
+
# Try id first
|
| 258 |
+
case_id = c.get("id", "").strip()
|
| 259 |
+
|
| 260 |
+
# Fallback: hash of raw_text first 200 chars + court
|
| 261 |
+
if not case_id or case_id == "":
|
| 262 |
+
raw = c.get("raw_text", "")[:200]
|
| 263 |
+
court = c.get("court", "")
|
| 264 |
+
case_id = hashlib.md5(
|
| 265 |
+
f"{raw}{court}".encode()
|
| 266 |
+
).hexdigest()
|
| 267 |
+
|
| 268 |
+
if case_id not in seen:
|
| 269 |
+
seen.add(case_id)
|
| 270 |
+
# Make sure the id field is set
|
| 271 |
+
c["id"] = case_id
|
| 272 |
+
unique.append(c)
|
| 273 |
+
|
| 274 |
+
removed = len(cases) - len(unique)
|
| 275 |
+
if removed > 0:
|
| 276 |
+
log.info(f"Deduplication: removed {removed} duplicates.")
|
| 277 |
+
return unique
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def save_cases_to_db(cases, conn):
|
| 281 |
+
inserted = 0
|
| 282 |
+
for c in cases:
|
| 283 |
+
try:
|
| 284 |
+
conn.execute(
|
| 285 |
+
"INSERT OR IGNORE INTO cases VALUES (?,?,?,?,?,?)",
|
| 286 |
+
(c["id"], c["court"], c["date"],
|
| 287 |
+
c["raw_text"], c["source"], c["meta"])
|
| 288 |
+
)
|
| 289 |
+
inserted += 1
|
| 290 |
+
except Exception as e:
|
| 291 |
+
log.error(f"Insert error {c['id']}: {e}")
|
| 292 |
+
conn.commit()
|
| 293 |
+
log.info(f"Saved {inserted} new cases to DB.")
|
| 294 |
+
return inserted
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def get_case_count(conn):
|
| 298 |
+
return conn.execute("SELECT COUNT(*) FROM cases").fetchone()[0]
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
if __name__ == "__main__":
|
| 302 |
+
import time
|
| 303 |
+
|
| 304 |
+
conn = init_database()
|
| 305 |
+
|
| 306 |
+
# Use whichever method worked in Step 1
|
| 307 |
+
# Change "GET" to "POST" if Step 1 showed POST works
|
| 308 |
+
API_METHOD = "POST"
|
| 309 |
+
|
| 310 |
+
QUERIES = [
|
| 311 |
+
("IPC 302 murder conviction sessions court", 10),
|
| 312 |
+
("IPC 302 murder acquittal benefit of doubt", 10),
|
| 313 |
+
("IPC 376 rape conviction High Court", 10),
|
| 314 |
+
("IPC 420 fraud cheating conviction", 10),
|
| 315 |
+
("IPC 498A domestic violence matrimonial", 10),
|
| 316 |
+
("IPC 307 attempt murder conviction", 10),
|
| 317 |
+
("bail application murder rejected", 10),
|
| 318 |
+
("bail application granted Supreme Court", 10),
|
| 319 |
+
("appeal against conviction allowed High Court", 10),
|
| 320 |
+
("acquittal evidence insufficient witness", 10),
|
| 321 |
+
]
|
| 322 |
+
|
| 323 |
+
TARGET_CASES = 5000
|
| 324 |
+
all_new_cases = []
|
| 325 |
+
|
| 326 |
+
for query, pages in QUERIES:
|
| 327 |
+
current_count = get_case_count(conn)
|
| 328 |
+
if current_count >= TARGET_CASES:
|
| 329 |
+
print(f"Target {TARGET_CASES} reached. Stopping.")
|
| 330 |
+
break
|
| 331 |
+
|
| 332 |
+
print(f"\nFetching: '{query}' ({pages} pages)...")
|
| 333 |
+
raw_cases = fetch_from_indian_kanoon(
|
| 334 |
+
query, pages=pages, method=API_METHOD
|
| 335 |
+
)
|
| 336 |
+
all_new_cases.extend(raw_cases)
|
| 337 |
+
print(f" Got {len(raw_cases)} raw cases")
|
| 338 |
+
|
| 339 |
+
# Deduplicate everything before saving
|
| 340 |
+
print(f"\nTotal raw cases fetched: {len(all_new_cases)}")
|
| 341 |
+
unique_cases = deduplicate_cases(all_new_cases)
|
| 342 |
+
print(f"After deduplication: {len(unique_cases)}")
|
| 343 |
+
|
| 344 |
+
saved = save_cases_to_db(unique_cases, conn)
|
| 345 |
+
final_count = get_case_count(conn)
|
| 346 |
+
|
| 347 |
+
print(f"\nNew cases saved: {saved}")
|
| 348 |
+
print(f"Total cases in DB: {final_count}")
|
| 349 |
+
conn.close()
|
| 350 |
+
|
| 351 |
+
if final_count < TARGET_CASES:
|
| 352 |
+
print(
|
| 353 |
+
f"\nNote: Got {final_count} cases (target {TARGET_CASES})."
|
| 354 |
+
f"\nRun fetcher again with different queries to add more."
|
| 355 |
+
)
|
| 356 |
+
else:
|
| 357 |
+
print(f"\nTarget reached: {final_count} cases in DB.")
|
src/inconsistency.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gap detection — identifies verdict inconsistencies within clusters."""
|
| 2 |
+
import json, numpy as np
|
| 3 |
+
from collections import defaultdict
|
| 4 |
+
from config import CASES_JSON_PATH, LABELS_PATH, GAPS_PATH
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def detect_gaps(cases=None, labels=None):
|
| 8 |
+
"""
|
| 9 |
+
Find clusters where similar cases have opposing verdicts.
|
| 10 |
+
|
| 11 |
+
Returns list of gap dicts sorted by inconsistency_score (desc).
|
| 12 |
+
"""
|
| 13 |
+
if cases is None:
|
| 14 |
+
with open(CASES_JSON_PATH, encoding="utf-8") as f:
|
| 15 |
+
cases = json.load(f)
|
| 16 |
+
if labels is None:
|
| 17 |
+
labels = np.load(LABELS_PATH)
|
| 18 |
+
|
| 19 |
+
groups = defaultdict(list)
|
| 20 |
+
for case, label in zip(cases, labels):
|
| 21 |
+
lid = int(label)
|
| 22 |
+
if lid != -1:
|
| 23 |
+
groups[lid].append(case)
|
| 24 |
+
|
| 25 |
+
gaps = []
|
| 26 |
+
for cid, members in groups.items():
|
| 27 |
+
verdict_counts = defaultdict(int)
|
| 28 |
+
for m in members:
|
| 29 |
+
verdict_counts[m["verdict"]] += 1
|
| 30 |
+
|
| 31 |
+
granted = verdict_counts.get("bail_granted", 0) + verdict_counts.get("acquitted", 0)
|
| 32 |
+
rejected = verdict_counts.get("bail_rejected", 0) + verdict_counts.get("convicted", 0)
|
| 33 |
+
|
| 34 |
+
if not granted or not rejected:
|
| 35 |
+
continue
|
| 36 |
+
|
| 37 |
+
total = len(members)
|
| 38 |
+
score = round(min(granted, rejected) / total, 3)
|
| 39 |
+
|
| 40 |
+
all_sections = []
|
| 41 |
+
for m in members:
|
| 42 |
+
all_sections.extend(m["ipc_sections"])
|
| 43 |
+
common_sections = sorted(
|
| 44 |
+
set(all_sections), key=lambda s: -all_sections.count(s)
|
| 45 |
+
)[:5]
|
| 46 |
+
|
| 47 |
+
gaps.append({
|
| 48 |
+
"cluster_id": cid,
|
| 49 |
+
"total_cases": total,
|
| 50 |
+
"positive_outcome_count": granted,
|
| 51 |
+
"negative_outcome_count": rejected,
|
| 52 |
+
"convicted_count": verdict_counts.get("convicted", 0),
|
| 53 |
+
"acquitted_count": verdict_counts.get("acquitted", 0),
|
| 54 |
+
"bail_granted_count": verdict_counts.get("bail_granted", 0),
|
| 55 |
+
"bail_rejected_count": verdict_counts.get("bail_rejected", 0),
|
| 56 |
+
"inconsistency_score": score,
|
| 57 |
+
"common_ipc_sections": common_sections,
|
| 58 |
+
"dominant_case_type": max(
|
| 59 |
+
set(m["case_type"] for m in members),
|
| 60 |
+
key=lambda t: sum(1 for m in members if m["case_type"] == t)
|
| 61 |
+
),
|
| 62 |
+
"courts_involved": list(set(m["court"] for m in members))[:5],
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
return sorted(gaps, key=lambda x: -x["inconsistency_score"])
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
if __name__ == "__main__":
|
| 69 |
+
gaps = detect_gaps()
|
| 70 |
+
with open(GAPS_PATH, "w", encoding="utf-8") as f:
|
| 71 |
+
json.dump(gaps, f, indent=2)
|
| 72 |
+
print(f"Detected {len(gaps)} inconsistent clusters.")
|
| 73 |
+
for g in gaps:
|
| 74 |
+
print(f" Cluster {g['cluster_id']}: {g['inconsistency_score']:.0%} inconsistency "
|
| 75 |
+
f"({g['total_cases']} cases)")
|
src/nlp_pipeline.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re, json, sqlite3, spacy
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from config import DB_PATH, CASES_JSON_PATH, SPACY_MODEL
|
| 4 |
+
|
| 5 |
+
nlp = spacy.load(SPACY_MODEL)
|
| 6 |
+
|
| 7 |
+
VERDICT_PATTERNS = {
|
| 8 |
+
"acquitted": [
|
| 9 |
+
r"\b(acquitt|not\s+guilty|discharg)\w*\b",
|
| 10 |
+
r"\bset\s+aside.*convict\w*\b",
|
| 11 |
+
r"\bconviction\b.*\bset\s+aside\b",
|
| 12 |
+
r"\border\s+of\s+acquittal\b.*\bconfirm\w*",
|
| 13 |
+
r"\bdirected\s+to\s+be\s+released\b",
|
| 14 |
+
r"\breleased\s+on\s+probation\b",
|
| 15 |
+
r"\bwarrant\s+of\s+conviction\b.*\bquash\w*",
|
| 16 |
+
r"\bbenefit\s+of\s+(the\s+)?doubt\b",
|
| 17 |
+
],
|
| 18 |
+
"convicted": [
|
| 19 |
+
r"\b(convict|found\s+guilty|sentenced\s+to|imprisonment\s+for)\w*\b",
|
| 20 |
+
r"\bplea\s+of\s+guilty\b.*\baccepted?\b",
|
| 21 |
+
r"\bwarrant\s+of\s+commitment\b",
|
| 22 |
+
r"\bsentence\s+(is\s+)?maintained\b",
|
| 23 |
+
],
|
| 24 |
+
"appeal_allowed": [
|
| 25 |
+
r"\bappeal\s+(is\s+)?(hereby\s+)?allow\w*\b",
|
| 26 |
+
r"\bappeal\s+(is\s+)?(hereby\s+)?succeed\w*\b",
|
| 27 |
+
],
|
| 28 |
+
"appeal_dismissed": [
|
| 29 |
+
r"\bappeal\s+(is\s+)?(hereby\s+)?dismiss\w*\b",
|
| 30 |
+
r"\bappeal\s+(is\s+)?(hereby\s+)?reject\w*\b",
|
| 31 |
+
r"\bappeal\s+(is\s+)?(hereby\s+)?fail\w*\b",
|
| 32 |
+
],
|
| 33 |
+
"bail_granted": [
|
| 34 |
+
r"\bbail\s+(is\s+)?grant\w*\b",
|
| 35 |
+
r"\banticipatory\s+bail\b.*\ballow\w*\b",
|
| 36 |
+
r"\bbail\s+application\b.*\ballow\w*\b",
|
| 37 |
+
r"\benlarged\s+on\s+bail\b",
|
| 38 |
+
r"\breleased\s+on\s+bail\b",
|
| 39 |
+
r"\bbail\s+pray\w*\b.*\ballow\w*\b",
|
| 40 |
+
],
|
| 41 |
+
"bail_rejected": [
|
| 42 |
+
r"\b(bail\s+(is\s+)?reject\w*|refused\s+bail)\b",
|
| 43 |
+
r"\bbail\s+application\b.*\bdismiss\w*\b",
|
| 44 |
+
r"\bbail\s+application\b.*\breject\w*\b",
|
| 45 |
+
r"\bremanded\s+(to\s+)?(judicial\s+)?custody\b",
|
| 46 |
+
r"\bbail\s+application\b.*\brefus\w*\b",
|
| 47 |
+
r"\bbail\s+pray\w*\b.*\b(dismiss|reject|refus)\w*\b",
|
| 48 |
+
r"\bbail\s+(is\s+)?denied\b",
|
| 49 |
+
],
|
| 50 |
+
"sentence_modified": [
|
| 51 |
+
r"\bsentence\s+(is\s+)?reduc\w*\b",
|
| 52 |
+
r"\bsentence\s+(is\s+)?enhanc\w*\b",
|
| 53 |
+
r"\bperiod\s+already\s+undergone\b",
|
| 54 |
+
r"\bsentence\s+commut\w*\b",
|
| 55 |
+
],
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
IPC_PATTERN = r"""
|
| 59 |
+
(?:section|sec\.?|u/?s\.?)\s*
|
| 60 |
+
(\d{1,3}[A-Za-z]?)
|
| 61 |
+
(?:\s*/\s*(\d{1,3}[A-Za-z]?))*
|
| 62 |
+
\s*(?:of\s+the\s+)?
|
| 63 |
+
(?:Indian\s+Penal\s+Code|IPC|CrPC|Cr\.P\.C\.)?
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
CASE_TYPE_KEYWORDS = {
|
| 67 |
+
"criminal": ["ipc", "crpc", "murder", "rape", "theft",
|
| 68 |
+
"robbery", "fraud", "cheating", "assault", "dacoity"],
|
| 69 |
+
"civil": ["contract", "property", "partition", "injunction",
|
| 70 |
+
"damages", "specific performance", "tort"],
|
| 71 |
+
"constitutional": ["article 14", "article 19", "article 21",
|
| 72 |
+
"fundamental right", "writ", "habeas corpus"],
|
| 73 |
+
"family": ["divorce", "maintenance", "custody", "adoption",
|
| 74 |
+
"matrimonial", "hindu marriage"],
|
| 75 |
+
"labour": ["workman", "dismissal", "retrenchment", "labour court",
|
| 76 |
+
"industrial dispute", "provident fund"],
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
EVIDENCE_KEYWORDS = [
|
| 80 |
+
"forensic", "dna", "fingerprint", "eyewitness",
|
| 81 |
+
"confession", "cctv", "post mortem", "ballistic",
|
| 82 |
+
"circumstantial", "documentary"
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def clean_text(text):
|
| 87 |
+
if not text: return ""
|
| 88 |
+
text = re.sub(r'Page\s+\d+\s+of\s+\d+', '', text, flags=re.IGNORECASE)
|
| 89 |
+
text = re.sub(r'\n{2,}', '\n', text)
|
| 90 |
+
text = re.sub(r'\s{2,}', ' ', text)
|
| 91 |
+
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]', '', text)
|
| 92 |
+
return text.strip()
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def extract_verdict(text):
|
| 96 |
+
text_lower = text.lower()
|
| 97 |
+
for label, patterns in VERDICT_PATTERNS.items():
|
| 98 |
+
for pattern in patterns:
|
| 99 |
+
if re.search(pattern, text_lower, re.IGNORECASE | re.VERBOSE):
|
| 100 |
+
return label
|
| 101 |
+
return "unknown"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def extract_ipc_sections(text):
|
| 105 |
+
matches = re.findall(IPC_PATTERN, text, re.IGNORECASE | re.VERBOSE)
|
| 106 |
+
sections = []
|
| 107 |
+
for m in matches:
|
| 108 |
+
if isinstance(m, tuple):
|
| 109 |
+
sections.extend([x for x in m if x])
|
| 110 |
+
else:
|
| 111 |
+
sections.append(m)
|
| 112 |
+
return sorted(set(s.upper() for s in sections if s))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def extract_case_type(text):
|
| 116 |
+
text_lower = text.lower()
|
| 117 |
+
scores = {
|
| 118 |
+
k: sum(text_lower.count(kw) for kw in v)
|
| 119 |
+
for k, v in CASE_TYPE_KEYWORDS.items()
|
| 120 |
+
}
|
| 121 |
+
best = max(scores, key=scores.get)
|
| 122 |
+
return best if scores[best] > 0 else "general"
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def extract_entities(text):
|
| 126 |
+
doc = nlp(text[:5000])
|
| 127 |
+
ents = {"persons": [], "organizations": [], "locations": [], "dates": []}
|
| 128 |
+
for e in doc.ents:
|
| 129 |
+
if e.label_ == "PERSON": ents["persons"].append(e.text)
|
| 130 |
+
elif e.label_ == "ORG": ents["organizations"].append(e.text)
|
| 131 |
+
elif e.label_ in ("GPE","LOC"): ents["locations"].append(e.text)
|
| 132 |
+
elif e.label_ == "DATE": ents["dates"].append(e.text)
|
| 133 |
+
return {k: list(set(v)) for k, v in ents.items()}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def extract_evidence_types(text):
|
| 137 |
+
text_lower = text.lower()
|
| 138 |
+
return [kw for kw in EVIDENCE_KEYWORDS if kw in text_lower]
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def process_case(row):
|
| 142 |
+
case_id, court, date, raw_text, source, meta_str = row
|
| 143 |
+
clean = clean_text(raw_text)
|
| 144 |
+
|
| 145 |
+
# Parse stored metadata from fetcher (contains pre-extracted fields)
|
| 146 |
+
try:
|
| 147 |
+
meta = json.loads(meta_str) if meta_str else {}
|
| 148 |
+
except (json.JSONDecodeError, TypeError):
|
| 149 |
+
meta = {}
|
| 150 |
+
|
| 151 |
+
# Extract verdict from text via regex
|
| 152 |
+
verdict_from_text = extract_verdict(clean)
|
| 153 |
+
|
| 154 |
+
# Also use pre-extracted bail_outcome from dataset metadata as fallback
|
| 155 |
+
bail_outcome = meta.get("bail_outcome", "").strip().lower()
|
| 156 |
+
if verdict_from_text == "unknown" and bail_outcome:
|
| 157 |
+
if bail_outcome in ("granted", "grant"):
|
| 158 |
+
verdict_from_text = "bail_granted"
|
| 159 |
+
elif bail_outcome in ("rejected", "reject", "denied"):
|
| 160 |
+
verdict_from_text = "bail_rejected"
|
| 161 |
+
elif bail_outcome in ("partly granted", "partial"):
|
| 162 |
+
verdict_from_text = "bail_granted"
|
| 163 |
+
|
| 164 |
+
# Extract IPC sections from text
|
| 165 |
+
ipc_from_text = extract_ipc_sections(clean)
|
| 166 |
+
|
| 167 |
+
# Merge with pre-extracted IPC from dataset metadata
|
| 168 |
+
ipc_from_meta = meta.get("ipc_sections", [])
|
| 169 |
+
if isinstance(ipc_from_meta, str):
|
| 170 |
+
try:
|
| 171 |
+
ipc_from_meta = json.loads(ipc_from_meta.replace("'", '"'))
|
| 172 |
+
except Exception:
|
| 173 |
+
ipc_from_meta = []
|
| 174 |
+
# Normalize: ensure all are uppercase strings
|
| 175 |
+
ipc_from_meta = [str(s).upper() for s in ipc_from_meta if s]
|
| 176 |
+
merged_ipc = sorted(set(ipc_from_text + ipc_from_meta))
|
| 177 |
+
|
| 178 |
+
return {
|
| 179 |
+
"id": case_id,
|
| 180 |
+
"court": court or "unknown",
|
| 181 |
+
"date": date or "",
|
| 182 |
+
"source": source or "unknown",
|
| 183 |
+
"text": clean,
|
| 184 |
+
"verdict": verdict_from_text,
|
| 185 |
+
"ipc_sections": merged_ipc,
|
| 186 |
+
"case_type": extract_case_type(clean),
|
| 187 |
+
"entities": extract_entities(clean),
|
| 188 |
+
"evidence_types": extract_evidence_types(clean),
|
| 189 |
+
"text_length": len(clean),
|
| 190 |
+
"crime_type": meta.get("crime_type", ""),
|
| 191 |
+
"case_title": meta.get("case_title", ""),
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def run_pipeline(limit=None):
|
| 196 |
+
conn = sqlite3.connect(DB_PATH)
|
| 197 |
+
q = "SELECT id, court, date, raw_text, source, meta FROM cases"
|
| 198 |
+
if limit:
|
| 199 |
+
q += f" LIMIT {limit}"
|
| 200 |
+
rows = conn.execute(q).fetchall()
|
| 201 |
+
conn.close()
|
| 202 |
+
|
| 203 |
+
print(f"Processing {len(rows)} cases...")
|
| 204 |
+
processed = []
|
| 205 |
+
for i, row in enumerate(rows):
|
| 206 |
+
if i % 100 == 0:
|
| 207 |
+
print(f" [{i}/{len(rows)}]")
|
| 208 |
+
try:
|
| 209 |
+
processed.append(process_case(row))
|
| 210 |
+
except Exception as e:
|
| 211 |
+
print(f" Error row {i}: {e}")
|
| 212 |
+
|
| 213 |
+
Path(CASES_JSON_PATH).parent.mkdir(parents=True, exist_ok=True)
|
| 214 |
+
with open(CASES_JSON_PATH, "w", encoding="utf-8") as f:
|
| 215 |
+
json.dump(processed, f, indent=2, ensure_ascii=False)
|
| 216 |
+
|
| 217 |
+
verdicts = {}
|
| 218 |
+
for c in processed:
|
| 219 |
+
verdicts[c["verdict"]] = verdicts.get(c["verdict"], 0) + 1
|
| 220 |
+
|
| 221 |
+
total = len(processed)
|
| 222 |
+
unknown_pct = verdicts.get("unknown", 0) / total * 100
|
| 223 |
+
|
| 224 |
+
print(f"\n{'='*55}")
|
| 225 |
+
print(f"VERDICT DISTRIBUTION ({total} cases):")
|
| 226 |
+
for v, count in sorted(verdicts.items(), key=lambda x: -x[1]):
|
| 227 |
+
pct = count / total * 100
|
| 228 |
+
bar = "#" * int(pct / 2)
|
| 229 |
+
print(f" {v:<22} {count:4d} ({pct:5.1f}%) {bar}")
|
| 230 |
+
print(f"{'='*55}")
|
| 231 |
+
|
| 232 |
+
# IPC coverage stats
|
| 233 |
+
has_ipc = sum(1 for c in processed if c["ipc_sections"])
|
| 234 |
+
print(f"\nIPC section coverage: {has_ipc}/{total} ({has_ipc/total*100:.0f}%)")
|
| 235 |
+
|
| 236 |
+
# Case type distribution
|
| 237 |
+
case_types = {}
|
| 238 |
+
for c in processed:
|
| 239 |
+
case_types[c["case_type"]] = case_types.get(c["case_type"], 0) + 1
|
| 240 |
+
print(f"\nCASE TYPE DISTRIBUTION:")
|
| 241 |
+
for ct, count in sorted(case_types.items(), key=lambda x: -x[1]):
|
| 242 |
+
print(f" {ct:<22} {count:4d}")
|
| 243 |
+
|
| 244 |
+
# Evidence coverage
|
| 245 |
+
has_evidence = sum(1 for c in processed if c["evidence_types"])
|
| 246 |
+
print(f"\nEvidence coverage: {has_evidence}/{total} ({has_evidence/total*100:.0f}%)")
|
| 247 |
+
|
| 248 |
+
if unknown_pct > 60:
|
| 249 |
+
print(f"\nHARD STOP: {unknown_pct:.0f}% unknown verdicts.")
|
| 250 |
+
print("DEBUG STEPS:")
|
| 251 |
+
print("1. Check raw text samples from DB")
|
| 252 |
+
print("2. Add missing regex patterns to VERDICT_PATTERNS")
|
| 253 |
+
print("3. Re-run nlp_pipeline.py until unknown% < 40%")
|
| 254 |
+
elif unknown_pct > 40:
|
| 255 |
+
print(f"\nWARNING: {unknown_pct:.0f}% unknown. Proceed to Phase 4 with caution.")
|
| 256 |
+
else:
|
| 257 |
+
print(f"\nVerdict coverage good ({100-unknown_pct:.0f}% classified). Proceed.")
|
| 258 |
+
|
| 259 |
+
return processed
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
if __name__ == "__main__":
|
| 263 |
+
run_pipeline()
|
src/query_validator.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Input validation guard — runs before any ML code.
|
| 3 |
+
|
| 4 |
+
Returns (True, "") if valid.
|
| 5 |
+
Returns (False, error_msg) if invalid.
|
| 6 |
+
|
| 7 |
+
Catches:
|
| 8 |
+
1. Empty / whitespace only
|
| 9 |
+
2. Too long (> QUERY_MAX_CHARS) — checked BEFORE word count
|
| 10 |
+
3. Too short (< QUERY_MIN_CHARS)
|
| 11 |
+
4. Too few words (< QUERY_MIN_WORDS)
|
| 12 |
+
5. Non-Latin / Indic script
|
| 13 |
+
6. No legal signal words
|
| 14 |
+
|
| 15 |
+
FIXES from v3.2.1 audit:
|
| 16 |
+
- Too-long check now runs BEFORE short check (was shadowed)
|
| 17 |
+
- Indic script detection uses ord() ranges instead of regex
|
| 18 |
+
(regex pattern had encoding issues on some systems)
|
| 19 |
+
- Legal signal check uses whole-word matching (word boundary)
|
| 20 |
+
- Single-word error message now includes "too brief"
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import re
|
| 24 |
+
from config import QUERY_MIN_CHARS, QUERY_MAX_CHARS, QUERY_MIN_WORDS
|
| 25 |
+
|
| 26 |
+
# Legal signal words — at least one must appear as a standalone word
|
| 27 |
+
LEGAL_SIGNALS = [
|
| 28 |
+
"ipc", "section", "accused", "court", "bail", "murder", "rape",
|
| 29 |
+
"fraud", "appeal", "conviction", "acquittal", "sentence", "judge",
|
| 30 |
+
"petitioner", "respondent", "plaintiff", "defendant", "fir", "charge",
|
| 31 |
+
"arrest", "custody", "evidence", "witness", "verdict", "judgment",
|
| 32 |
+
"crpc", "article", "writ", "habeas", "injunction", "decree",
|
| 33 |
+
"theft", "robbery", "assault", "cheating", "dacoity", "offence",
|
| 34 |
+
"offense", "criminal", "civil", "sessions", "magistrate", "high court",
|
| 35 |
+
"supreme court", "tribunal", "acquit", "convict", "imprison",
|
| 36 |
+
"sentenced", "charged", "alleged",
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _has_indic_script(text: str) -> bool:
|
| 41 |
+
"""
|
| 42 |
+
Detect Indic script characters using Unicode code point ranges.
|
| 43 |
+
Uses ord() checks — avoids regex encoding issues on all platforms.
|
| 44 |
+
|
| 45 |
+
Ranges covered:
|
| 46 |
+
0x0900–0x097F Devanagari (Hindi, Marathi, Sanskrit)
|
| 47 |
+
0x0980–0x09FF Bengali
|
| 48 |
+
0x0A00–0x0A7F Gurmukhi (Punjabi)
|
| 49 |
+
0x0A80–0x0AFF Gujarati
|
| 50 |
+
0x0B00–0x0B7F Odia
|
| 51 |
+
0x0B80–0x0BFF Tamil
|
| 52 |
+
0x0C00–0x0C7F Telugu
|
| 53 |
+
0x0C80–0x0CFF Kannada
|
| 54 |
+
0x0D00–0x0D7F Malayalam
|
| 55 |
+
"""
|
| 56 |
+
indic_count = 0
|
| 57 |
+
for ch in text:
|
| 58 |
+
cp = ord(ch)
|
| 59 |
+
if (0x0900 <= cp <= 0x097F or # Devanagari
|
| 60 |
+
0x0980 <= cp <= 0x09FF or # Bengali
|
| 61 |
+
0x0A00 <= cp <= 0x0A7F or # Gurmukhi
|
| 62 |
+
0x0A80 <= cp <= 0x0AFF or # Gujarati
|
| 63 |
+
0x0B00 <= cp <= 0x0B7F or # Odia
|
| 64 |
+
0x0B80 <= cp <= 0x0BFF or # Tamil
|
| 65 |
+
0x0C00 <= cp <= 0x0C7F or # Telugu
|
| 66 |
+
0x0C80 <= cp <= 0x0CFF or # Kannada
|
| 67 |
+
0x0D00 <= cp <= 0x0D7F): # Malayalam
|
| 68 |
+
indic_count += 1
|
| 69 |
+
return indic_count > len(text) * 0.25
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _has_legal_signal(text_lower: str) -> bool:
|
| 73 |
+
"""
|
| 74 |
+
Check for at least one legal signal word.
|
| 75 |
+
Uses word-boundary matching to avoid false positives from
|
| 76 |
+
substrings (e.g. "like" inside "Unlike", "in" inside "injunction").
|
| 77 |
+
"""
|
| 78 |
+
for signal in LEGAL_SIGNALS:
|
| 79 |
+
# Use \b word boundary for single-word signals
|
| 80 |
+
# Use plain 'in' check for multi-word signals like "high court"
|
| 81 |
+
if " " in signal:
|
| 82 |
+
if signal in text_lower:
|
| 83 |
+
return True
|
| 84 |
+
else:
|
| 85 |
+
if re.search(r'\b' + re.escape(signal) + r'\b', text_lower):
|
| 86 |
+
return True
|
| 87 |
+
return False
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def validate_query(text: str) -> tuple:
|
| 91 |
+
"""
|
| 92 |
+
Validate query before sending to NLP/ML pipeline.
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
(True, "") — valid query
|
| 96 |
+
(False, human-readable error msg) — invalid query
|
| 97 |
+
"""
|
| 98 |
+
# 1. Empty
|
| 99 |
+
if not text or not text.strip():
|
| 100 |
+
return False, (
|
| 101 |
+
"Please describe your case. The search field is empty."
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
text = text.strip()
|
| 105 |
+
|
| 106 |
+
# 2. Too long — check BEFORE word count to catch "word " * 1000
|
| 107 |
+
if len(text) > QUERY_MAX_CHARS:
|
| 108 |
+
return False, (
|
| 109 |
+
f"Query too long ({len(text):,} characters, limit {QUERY_MAX_CHARS:,}). "
|
| 110 |
+
f"Summarize the key charges, facts, and evidence in a few sentences. "
|
| 111 |
+
f"For a full judgment text, use the PDF upload feature."
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
# 3. Too short (character count)
|
| 115 |
+
if len(text) < QUERY_MIN_CHARS:
|
| 116 |
+
return False, (
|
| 117 |
+
f"Query too short ({len(text)} characters, minimum {QUERY_MIN_CHARS}). "
|
| 118 |
+
f"Example: 'Accused charged under IPC Section 302 for murder "
|
| 119 |
+
f"with eyewitness and forensic evidence.'"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# 4. Too few words
|
| 123 |
+
word_count = len(text.split())
|
| 124 |
+
if word_count < QUERY_MIN_WORDS:
|
| 125 |
+
return False, (
|
| 126 |
+
f"Query too brief ({word_count} word{'s' if word_count != 1 else ''}). "
|
| 127 |
+
f"Please describe the charges, facts, and evidence in at least "
|
| 128 |
+
f"{QUERY_MIN_WORDS} words."
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
# 5. Non-Latin / Indic script
|
| 132 |
+
if _has_indic_script(text):
|
| 133 |
+
return False, (
|
| 134 |
+
"Query appears to be in a non-English script. "
|
| 135 |
+
"LexAI's embedding model (LegalBERT) was trained on English legal text. "
|
| 136 |
+
"Please enter your query in English for accurate results."
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
# 6. No legal signal
|
| 140 |
+
text_lower = text.lower()
|
| 141 |
+
if not _has_legal_signal(text_lower):
|
| 142 |
+
return False, (
|
| 143 |
+
"Query doesn't appear to describe a legal case. "
|
| 144 |
+
"Please include legal context such as charges (IPC section), "
|
| 145 |
+
"case type (murder, bail, fraud), court, or parties. "
|
| 146 |
+
"Example: 'Accused charged under IPC 420 for cheating. Victim filed FIR.'"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
return True, ""
|
src/reranker.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cross-encoder re-ranker — sits between FAISS and explanation engine.
|
| 3 |
+
|
| 4 |
+
Pipeline: FAISS top 50 → CrossEncoder rerank → top 5 → Explanation.
|
| 5 |
+
|
| 6 |
+
MODEL: cross-encoder/nli-deberta-v3-small
|
| 7 |
+
- 3 output labels: [contradiction=0, neutral=1, entailment=2]
|
| 8 |
+
- We extract entailment score (index 2) as the relevance signal
|
| 9 |
+
- Legal relevance = entailment logic, not webpage click relevance
|
| 10 |
+
|
| 11 |
+
CRITICAL BUG FIXED IN v3.2.1:
|
| 12 |
+
Old: scores = model.predict(pairs)
|
| 13 |
+
→ returns shape (n, 3) for NLI models
|
| 14 |
+
→ using raw array as score accidentally sorted by contradiction
|
| 15 |
+
|
| 16 |
+
New: scores = model.predict(pairs, apply_softmax=True)[:, 2]
|
| 17 |
+
→ softmax normalizes the 3 logits to probabilities
|
| 18 |
+
→ we take column 2 (entailment probability) as the score
|
| 19 |
+
→ higher entailment = more legally relevant result
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
from sentence_transformers import CrossEncoder
|
| 24 |
+
from config import RERANKER_MODEL
|
| 25 |
+
|
| 26 |
+
_model = None # lazy-loaded singleton
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def get_reranker():
|
| 30 |
+
"""Load cross-encoder once and reuse. Thread-safe for Streamlit."""
|
| 31 |
+
global _model
|
| 32 |
+
if _model is None:
|
| 33 |
+
print(f"Loading cross-encoder: {RERANKER_MODEL}")
|
| 34 |
+
print("(First run only — ~110MB download, cached after)")
|
| 35 |
+
_model = CrossEncoder(RERANKER_MODEL, max_length=512)
|
| 36 |
+
print("Cross-encoder loaded.")
|
| 37 |
+
return _model
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def rerank(query_text: str, candidates: list, top_k: int = 5) -> list:
|
| 41 |
+
"""
|
| 42 |
+
Re-rank FAISS candidates using cross-encoder entailment scores.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
query_text: raw query string
|
| 46 |
+
candidates: list of (case_dict, faiss_score) tuples
|
| 47 |
+
top_k: number of results to return
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
list of (case_dict, entailment_score) sorted descending
|
| 51 |
+
"""
|
| 52 |
+
if not candidates:
|
| 53 |
+
return []
|
| 54 |
+
|
| 55 |
+
model = get_reranker()
|
| 56 |
+
|
| 57 |
+
# Build (query, candidate_text) pairs
|
| 58 |
+
pairs = [
|
| 59 |
+
[query_text[:256], case["text"][:400]]
|
| 60 |
+
for case, _ in candidates
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
# Predict: returns shape (n_pairs, 3) for NLI model
|
| 64 |
+
# apply_softmax=True converts logits → probabilities
|
| 65 |
+
raw = model.predict(pairs, show_progress_bar=False)
|
| 66 |
+
raw = np.array(raw)
|
| 67 |
+
|
| 68 |
+
if raw.ndim == 2 and raw.shape[1] == 3:
|
| 69 |
+
scores = raw[:, 2] # NLI model — entailment column
|
| 70 |
+
else:
|
| 71 |
+
scores = raw.flatten() # single-score model — use directly
|
| 72 |
+
|
| 73 |
+
# Zip scores back to candidates and sort descending
|
| 74 |
+
scored = [
|
| 75 |
+
(candidates[i][0], float(scores[i]))
|
| 76 |
+
for i in range(len(candidates))
|
| 77 |
+
]
|
| 78 |
+
scored.sort(key=lambda x: x[1], reverse=True)
|
| 79 |
+
|
| 80 |
+
return scored[:top_k]
|
src/retrieval.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FAISS retrieval wrapper — handles index loading and similarity search."""
|
| 2 |
+
import numpy as np, faiss, json
|
| 3 |
+
from config import FAISS_INDEX_PATH, CASES_JSON_PATH, EMBEDDINGS_PATH, TOP_K_RETRIEVAL
|
| 4 |
+
|
| 5 |
+
_index = None
|
| 6 |
+
_cases = None
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _load():
|
| 10 |
+
global _index, _cases
|
| 11 |
+
if _index is None:
|
| 12 |
+
_index = faiss.read_index(FAISS_INDEX_PATH)
|
| 13 |
+
if _cases is None:
|
| 14 |
+
with open(CASES_JSON_PATH, encoding="utf-8") as f:
|
| 15 |
+
_cases = json.load(f)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def search_faiss(query_vec: np.ndarray, top_k: int = TOP_K_RETRIEVAL) -> list:
|
| 19 |
+
"""
|
| 20 |
+
Search FAISS index with a normalized query vector.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
query_vec: (1, dim) float32 numpy array, L2-normalized
|
| 24 |
+
top_k: number of candidates to retrieve
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
List of dicts: [{case data + 'faiss_score'}]
|
| 28 |
+
"""
|
| 29 |
+
_load()
|
| 30 |
+
distances, indices = _index.search(query_vec, top_k)
|
| 31 |
+
|
| 32 |
+
results = []
|
| 33 |
+
for dist, idx in zip(distances[0], indices[0]):
|
| 34 |
+
if idx < 0 or idx >= len(_cases):
|
| 35 |
+
continue
|
| 36 |
+
case = _cases[int(idx)].copy()
|
| 37 |
+
case["faiss_score"] = float(dist)
|
| 38 |
+
case["result_index"] = int(idx)
|
| 39 |
+
results.append(case)
|
| 40 |
+
|
| 41 |
+
return results
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def get_case_by_index(idx: int) -> dict:
|
| 45 |
+
"""Get a single case by its index."""
|
| 46 |
+
_load()
|
| 47 |
+
if 0 <= idx < len(_cases):
|
| 48 |
+
return _cases[idx].copy()
|
| 49 |
+
return {}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def get_total_cases() -> int:
|
| 53 |
+
"""Return the total number of indexed cases."""
|
| 54 |
+
_load()
|
| 55 |
+
return len(_cases)
|
src/search_pipeline.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Search pipeline orchestrator — single entry point for all search.
|
| 3 |
+
|
| 4 |
+
Usage in app.py:
|
| 5 |
+
from src.search_pipeline import SearchPipeline
|
| 6 |
+
pipeline = SearchPipeline()
|
| 7 |
+
ok, msg = pipeline.health_check()
|
| 8 |
+
response = pipeline.search("your query", top_k=5)
|
| 9 |
+
|
| 10 |
+
Architecture:
|
| 11 |
+
validate → NLP → embed → FAISS → rerank → explain
|
| 12 |
+
All ML code lives HERE. app.py is UI only.
|
| 13 |
+
|
| 14 |
+
Runtime safety:
|
| 15 |
+
Missing files → clear error message, not traceback.
|
| 16 |
+
health_check() lets app.py show a banner before first search.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import json, numpy as np, faiss, logging, time
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from dataclasses import dataclass, field
|
| 22 |
+
from typing import Optional
|
| 23 |
+
|
| 24 |
+
from config import (
|
| 25 |
+
CASES_JSON_PATH, EMBEDDINGS_PATH, FAISS_INDEX_PATH,
|
| 26 |
+
EMBEDDING_MODEL, TOP_K_RETRIEVAL, TOP_K_RESULTS, MAX_TEXT_LENGTH
|
| 27 |
+
)
|
| 28 |
+
from src.query_validator import validate_query
|
| 29 |
+
from src.nlp_pipeline import (
|
| 30 |
+
clean_text, extract_verdict, extract_ipc_sections,
|
| 31 |
+
extract_case_type, extract_entities, extract_evidence_types
|
| 32 |
+
)
|
| 33 |
+
from src.reranker import rerank
|
| 34 |
+
from src.explanation_engine import explain_results
|
| 35 |
+
|
| 36 |
+
log = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
# ── Required files + error messages ─────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
REQUIRED_FILES = {
|
| 41 |
+
"cases": CASES_JSON_PATH,
|
| 42 |
+
"embeddings": EMBEDDINGS_PATH,
|
| 43 |
+
"faiss_index": FAISS_INDEX_PATH,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
SETUP_INSTRUCTIONS = {
|
| 47 |
+
"cases": (
|
| 48 |
+
"data/processed/cases.json not found.\n"
|
| 49 |
+
"Run: python src/fetcher.py then python src/nlp_pipeline.py"
|
| 50 |
+
),
|
| 51 |
+
"embeddings": (
|
| 52 |
+
"data/processed/embeddings.npy not found.\n"
|
| 53 |
+
"Run the Colab notebook (Phase 4) and download all outputs."
|
| 54 |
+
),
|
| 55 |
+
"faiss_index": (
|
| 56 |
+
"data/processed/faiss.index not found.\n"
|
| 57 |
+
"Run the Colab notebook (Phase 4) and download all outputs."
|
| 58 |
+
),
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def check_required_files() -> tuple:
|
| 63 |
+
"""
|
| 64 |
+
Check all required files exist before loading any model.
|
| 65 |
+
Returns (ok: bool, error_message: str).
|
| 66 |
+
"""
|
| 67 |
+
for name, path in REQUIRED_FILES.items():
|
| 68 |
+
if not Path(path).exists():
|
| 69 |
+
return False, SETUP_INSTRUCTIONS[name]
|
| 70 |
+
return True, ""
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ── Result dataclasses ───────────────────────────────────────────────────────
|
| 74 |
+
|
| 75 |
+
@dataclass
|
| 76 |
+
class SearchResult:
|
| 77 |
+
"""One result from the search pipeline."""
|
| 78 |
+
case: dict
|
| 79 |
+
score: float
|
| 80 |
+
explanation: dict
|
| 81 |
+
rank: int
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@dataclass
|
| 85 |
+
class PipelineResponse:
|
| 86 |
+
"""Full response from pipeline.search()."""
|
| 87 |
+
success: bool
|
| 88 |
+
query_case: Optional[dict] = None
|
| 89 |
+
results: list = field(default_factory=list)
|
| 90 |
+
error: Optional[str] = None
|
| 91 |
+
error_type: Optional[str] = None
|
| 92 |
+
latency_ms: Optional[float] = None
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ── Pipeline class ───────────────────────────────────────────────────────────
|
| 96 |
+
|
| 97 |
+
class SearchPipeline:
|
| 98 |
+
"""
|
| 99 |
+
Full LexAI search pipeline with lazy-loaded singleton resources.
|
| 100 |
+
|
| 101 |
+
Create once per Streamlit session (@st.cache_resource).
|
| 102 |
+
"""
|
| 103 |
+
|
| 104 |
+
def __init__(self):
|
| 105 |
+
self._cases = None
|
| 106 |
+
self._index = None
|
| 107 |
+
self._embed_model = None
|
| 108 |
+
self._ready = False
|
| 109 |
+
self._init_error = None
|
| 110 |
+
|
| 111 |
+
files_ok, file_error = check_required_files()
|
| 112 |
+
if not files_ok:
|
| 113 |
+
self._init_error = file_error
|
| 114 |
+
log.error(f"SearchPipeline init: {file_error}")
|
| 115 |
+
else:
|
| 116 |
+
self._ready = True
|
| 117 |
+
|
| 118 |
+
def health_check(self) -> tuple:
|
| 119 |
+
"""
|
| 120 |
+
Returns (ok: bool, message: str).
|
| 121 |
+
Call from app.py startup to show warning banner if setup incomplete.
|
| 122 |
+
"""
|
| 123 |
+
if self._init_error:
|
| 124 |
+
return False, self._init_error
|
| 125 |
+
files_ok, file_error = check_required_files()
|
| 126 |
+
if not files_ok:
|
| 127 |
+
return False, file_error
|
| 128 |
+
return True, "Pipeline ready."
|
| 129 |
+
|
| 130 |
+
def _load_assets(self):
|
| 131 |
+
"""Lazy-load heavy assets on first search call."""
|
| 132 |
+
if self._cases is not None:
|
| 133 |
+
return
|
| 134 |
+
|
| 135 |
+
log.info("Loading search assets (first call)...")
|
| 136 |
+
|
| 137 |
+
with open(CASES_JSON_PATH, encoding="utf-8") as f:
|
| 138 |
+
self._cases = json.load(f)
|
| 139 |
+
|
| 140 |
+
self._index = faiss.read_index(FAISS_INDEX_PATH)
|
| 141 |
+
|
| 142 |
+
from sentence_transformers import SentenceTransformer
|
| 143 |
+
self._embed_model = SentenceTransformer(EMBEDDING_MODEL)
|
| 144 |
+
|
| 145 |
+
log.info(
|
| 146 |
+
f"Assets loaded: {len(self._cases)} cases, "
|
| 147 |
+
f"{self._index.ntotal} FAISS vectors."
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
def _build_query_case(self, query_text: str) -> dict:
|
| 151 |
+
"""Run NLP extraction on the raw query text."""
|
| 152 |
+
clean = clean_text(query_text)
|
| 153 |
+
return {
|
| 154 |
+
"text": clean,
|
| 155 |
+
"verdict": extract_verdict(clean),
|
| 156 |
+
"ipc_sections": extract_ipc_sections(clean),
|
| 157 |
+
"case_type": extract_case_type(clean),
|
| 158 |
+
"entities": extract_entities(clean),
|
| 159 |
+
"evidence_types": extract_evidence_types(clean),
|
| 160 |
+
"court": "query",
|
| 161 |
+
"date": "",
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
def search(
|
| 165 |
+
self,
|
| 166 |
+
query_text: str,
|
| 167 |
+
top_k: int = TOP_K_RESULTS,
|
| 168 |
+
verdict_filter: str = "All"
|
| 169 |
+
) -> PipelineResponse:
|
| 170 |
+
"""
|
| 171 |
+
Full pipeline: validate → NLP → embed → FAISS → rerank → explain.
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
query_text: raw text from the user
|
| 175 |
+
top_k: number of results to return
|
| 176 |
+
verdict_filter: "All" or a specific verdict label
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
PipelineResponse
|
| 180 |
+
"""
|
| 181 |
+
t_start = time.time()
|
| 182 |
+
|
| 183 |
+
# Step 1: Runtime safety
|
| 184 |
+
if not self._ready:
|
| 185 |
+
return PipelineResponse(
|
| 186 |
+
success=False,
|
| 187 |
+
error=self._init_error,
|
| 188 |
+
error_type="setup_error"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
# Step 2: Validate query
|
| 192 |
+
is_valid, validation_error = validate_query(query_text)
|
| 193 |
+
if not is_valid:
|
| 194 |
+
return PipelineResponse(
|
| 195 |
+
success=False,
|
| 196 |
+
error=validation_error,
|
| 197 |
+
error_type="validation_error"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# Step 3: Load assets (lazy)
|
| 201 |
+
try:
|
| 202 |
+
self._load_assets()
|
| 203 |
+
except Exception as e:
|
| 204 |
+
log.error(f"Asset load failed: {e}")
|
| 205 |
+
return PipelineResponse(
|
| 206 |
+
success=False,
|
| 207 |
+
error=f"Failed to load search index: {e}",
|
| 208 |
+
error_type="load_error"
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
# Step 4: NLP extraction
|
| 212 |
+
query_case = self._build_query_case(query_text)
|
| 213 |
+
|
| 214 |
+
# Step 5: Embed + FAISS search
|
| 215 |
+
q_words = query_case["text"].split()[:MAX_TEXT_LENGTH]
|
| 216 |
+
q_emb = self._embed_model.encode(
|
| 217 |
+
[" ".join(q_words)]
|
| 218 |
+
).astype("float32")
|
| 219 |
+
faiss.normalize_L2(q_emb)
|
| 220 |
+
scores_arr, idxs = self._index.search(q_emb, TOP_K_RETRIEVAL)
|
| 221 |
+
|
| 222 |
+
candidates = []
|
| 223 |
+
for idx, score in zip(idxs[0], scores_arr[0]):
|
| 224 |
+
if 0 <= idx < len(self._cases):
|
| 225 |
+
case = self._cases[idx]
|
| 226 |
+
if verdict_filter == "All" or case.get("verdict") == verdict_filter:
|
| 227 |
+
candidates.append((case, float(score)))
|
| 228 |
+
|
| 229 |
+
if not candidates:
|
| 230 |
+
return PipelineResponse(
|
| 231 |
+
success=True,
|
| 232 |
+
query_case=query_case,
|
| 233 |
+
results=[],
|
| 234 |
+
error=None,
|
| 235 |
+
latency_ms=round((time.time() - t_start) * 1000, 1)
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
# Step 6: Reranking
|
| 239 |
+
from config import USE_RERANKER
|
| 240 |
+
if USE_RERANKER:
|
| 241 |
+
reranked = rerank(query_text, candidates, top_k=top_k)
|
| 242 |
+
else:
|
| 243 |
+
reranked = candidates[:top_k]
|
| 244 |
+
|
| 245 |
+
# Step 7: Explanation engine
|
| 246 |
+
explanations = explain_results(query_case, reranked)
|
| 247 |
+
|
| 248 |
+
results = [
|
| 249 |
+
SearchResult(
|
| 250 |
+
case=case,
|
| 251 |
+
score=score,
|
| 252 |
+
explanation=exp,
|
| 253 |
+
rank=i + 1
|
| 254 |
+
)
|
| 255 |
+
for i, ((case, score), exp) in enumerate(zip(reranked, explanations))
|
| 256 |
+
]
|
| 257 |
+
|
| 258 |
+
latency = round((time.time() - t_start) * 1000, 1)
|
| 259 |
+
log.info(f"Search: {len(results)} results in {latency}ms")
|
| 260 |
+
|
| 261 |
+
return PipelineResponse(
|
| 262 |
+
success=True,
|
| 263 |
+
query_case=query_case,
|
| 264 |
+
results=results,
|
| 265 |
+
latency_ms=latency
|
| 266 |
+
)
|