Satyam810 commited on
Commit
b2fe8d1
·
1 Parent(s): c03e4dc

Deploy LexAI v3.4

Browse files
.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: Lexai
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
  pinned: false
11
- short_description: Streamlit template space
12
  license: mit
 
13
  ---
14
 
15
- # Welcome to Streamlit!
16
 
17
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
 
18
 
19
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
20
- forums](https://discuss.streamlit.io).
 
 
 
 
 
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
+ &nbsp;·&nbsp; {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} &nbsp;|&nbsp; "
491
+ f"{type_text} case &nbsp;|&nbsp; "
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" &nbsp;·&nbsp; {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(" &nbsp;·&nbsp; ".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
+ &nbsp;·&nbsp;
878
+ {gap['total_cases']} cases
879
+ &nbsp;·&nbsp;
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
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ )