seg007 commited on
Commit
1b7622e
·
verified ·
1 Parent(s): 415cb5e

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +660 -0
app.py ADDED
@@ -0,0 +1,660 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ KS-GraphRAG Track A Demo — Gradio app for HuggingFace Spaces
3
+ =============================================================
4
+
5
+ Self-contained RAG demo over Kashmir Shaivism corpus:
6
+ - BM25 (TF-IDF fallback) + dense (sentence-transformers) hybrid search
7
+ - RRF fusion with per-category weight overrides
8
+ - Structured output: {answer, citations[], confidence, used_chunks[]}
9
+ - Epistemic classification (bauddha/pauruṣa)
10
+ - Doctrinal warning detection
11
+ - Mandala visualization
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import time
18
+ import logging
19
+ from pathlib import Path
20
+ from dataclasses import dataclass, field
21
+ from typing import List, Dict, Optional, Tuple
22
+
23
+ import gradio as gr
24
+ import numpy as np
25
+
26
+ logging.basicConfig(level=logging.INFO)
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Configuration
31
+ # ---------------------------------------------------------------------------
32
+
33
+ DATA_DIR = Path(os.environ.get("KS_RAG_DATA", "data"))
34
+ DEVICE = "cuda" if os.environ.get("KS_RAG_DEVICE", "cpu") == "cuda" else "cpu"
35
+
36
+ # RRF constant (Cormack 2009)
37
+ RRF_K = 60
38
+
39
+ # Channel weights (production v5.5)
40
+ DEFAULT_WEIGHTS = {
41
+ "bm25": 1.00,
42
+ "dense": 1.10,
43
+ "canonical_group": 1.40,
44
+ }
45
+
46
+ CATEGORY_OVERRIDES = {
47
+ "doctrinal_warning": {
48
+ "canonical_group": 1.80,
49
+ "bm25": 1.00,
50
+ "dense": 0.80,
51
+ },
52
+ "definition": {
53
+ "canonical_group": 1.50,
54
+ "bm25": 1.20,
55
+ "dense": 1.00,
56
+ },
57
+ "enumeration": {
58
+ "canonical_group": 1.70,
59
+ "bm25": 0.30,
60
+ "dense": 1.00,
61
+ },
62
+ }
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Data Loading
66
+ # ---------------------------------------------------------------------------
67
+
68
+ class KSDataBundle:
69
+ """Lazy-loaded data bundle."""
70
+
71
+ def __init__(self):
72
+ self.passages: List[dict] = []
73
+ self.members: List[dict] = []
74
+ self.groups: List[dict] = []
75
+ self.memberships: List[dict] = []
76
+ self.golden_qa: List[dict] = []
77
+ self._tfidf_matrix = None
78
+ self._tfidf_vectorizer = None
79
+ self._dense_model = None
80
+ self._dense_embeddings = None
81
+ self._member_index: Dict[str, dict] = {}
82
+ self._group_index: Dict[str, dict] = {}
83
+
84
+ def load(self):
85
+ self._load_passages()
86
+ self._load_ontology()
87
+ self._load_golden_qa()
88
+ self._build_member_index()
89
+ logger.info(
90
+ f"Loaded: {len(self.passages)} passages, "
91
+ f"{len(self.members)} members, {len(self.groups)} groups, "
92
+ f"{len(self.golden_qa)} QA pairs"
93
+ )
94
+
95
+ def _load_passages(self):
96
+ p = DATA_DIR / "passages_sample.jsonl"
97
+ if p.exists():
98
+ with open(p, "r", encoding="utf-8") as f:
99
+ for ln in f:
100
+ ln = ln.strip()
101
+ if ln:
102
+ self.passages.append(json.loads(ln))
103
+
104
+ def _load_ontology(self):
105
+ for name, attr in [("members.json", "members"), ("groups.json", "groups"),
106
+ ("memberships.json", "memberships")]:
107
+ p = DATA_DIR / name
108
+ if p.exists():
109
+ with open(p, "r", encoding="utf-8") as f:
110
+ setattr(self, attr, json.load(f))
111
+
112
+ def _load_golden_qa(self):
113
+ p = DATA_DIR / "golden_qa.json"
114
+ if p.exists():
115
+ with open(p, "r", encoding="utf-8") as f:
116
+ self.golden_qa = json.load(f)
117
+
118
+ def _build_member_index(self):
119
+ for m in self.members:
120
+ il = m.get("iast_lowcase", "").strip().lower()
121
+ if il:
122
+ self._member_index[il] = m
123
+ for g in self.groups:
124
+ gid = g.get("group_id", "")
125
+ if gid:
126
+ self._group_index[gid] = g
127
+
128
+ def get_member(self, iast_lower: str) -> Optional[dict]:
129
+ return self._member_index.get(iast_lower.lower().strip())
130
+
131
+ def get_group(self, group_id: str) -> Optional[dict]:
132
+ return self._group_index.get(group_id)
133
+
134
+ # --- Sparse search (TF-IDF) ---
135
+ def init_tfidf(self):
136
+ from sklearn.feature_extraction.text import TfidfVectorizer
137
+ texts = [p.get("text", "") for p in self.passages]
138
+ self._tfidf_vectorizer = TfidfVectorizer(
139
+ max_features=50000, ngram_range=(1, 2),
140
+ sublinear_tf=True, max_df=0.95, min_df=2,
141
+ )
142
+ self._tfidf_matrix = self._tfidf_vectorizer.fit_transform(texts)
143
+ logger.info(f"TF-IDF index: {self._tfidf_matrix.shape}")
144
+
145
+ def search_tfidf(self, query: str, top_k: int = 20) -> List[dict]:
146
+ if self._tfidf_vectorizer is None:
147
+ self.init_tfidf()
148
+ q_vec = self._tfidf_vectorizer.transform([query])
149
+ scores = (self._tfidf_matrix @ q_vec.T).toarray().flatten()
150
+ top_idx = np.argsort(-scores)[:top_k]
151
+ results = []
152
+ for rank, idx in enumerate(top_idx, 1):
153
+ if scores[idx] > 0:
154
+ p = self.passages[idx]
155
+ results.append({
156
+ "doc_id": p.get("id", str(idx)),
157
+ "score": float(scores[idx]),
158
+ "rank": rank,
159
+ "text": p.get("text", ""),
160
+ "source": p.get("source", ""),
161
+ "channel": "bm25",
162
+ })
163
+ return results
164
+
165
+ # --- Dense search ---
166
+ def init_dense(self):
167
+ from sentence_transformers import SentenceTransformer
168
+ model_name = os.environ.get("KS_RAG_ENCODER", "BAAI/bge-m3")
169
+ logger.info(f"Loading encoder: {model_name}...")
170
+ self._dense_model = SentenceTransformer(model_name, device=DEVICE)
171
+
172
+ texts = [p.get("text", "") for p in self.passages]
173
+ logger.info(f"Encoding {len(texts)} passages...")
174
+ self._dense_embeddings = self._dense_model.encode(
175
+ texts, normalize_embeddings=True, show_progress_bar=True,
176
+ batch_size=64,
177
+ )
178
+ logger.info(f"Dense index: {self._dense_embeddings.shape}")
179
+
180
+ def search_dense(self, query: str, top_k: int = 15) -> List[dict]:
181
+ if self._dense_model is None:
182
+ self.init_dense()
183
+ q_emb = self._dense_model.encode([query], normalize_embeddings=True)
184
+ scores = (self._dense_embeddings @ q_emb.T).flatten()
185
+ top_idx = np.argsort(-scores)[:top_k]
186
+ results = []
187
+ for rank, idx in enumerate(top_idx, 1):
188
+ p = self.passages[idx]
189
+ results.append({
190
+ "doc_id": p.get("id", str(idx)),
191
+ "score": float(scores[idx]),
192
+ "rank": rank,
193
+ "text": p.get("text", ""),
194
+ "source": p.get("source", ""),
195
+ "channel": "dense",
196
+ })
197
+ return results
198
+
199
+ # --- Canonical group search ---
200
+ def search_canonical(self, query: str, top_k: int = 10) -> List[dict]:
201
+ q_lower = query.lower()
202
+ results = []
203
+ for g in self.groups:
204
+ name = g.get("group_name", "").lower()
205
+ desc = g.get("description", "").lower() if g.get("description") else ""
206
+ score = 0
207
+ for token in re.findall(r"[a-zāīūṛṝḷḹṅñṭḍṇśṣṃḥṁ]{3,}", q_lower):
208
+ if token in name:
209
+ score += 3
210
+ if token in desc:
211
+ score += 1
212
+ if score > 0:
213
+ results.append({
214
+ "doc_id": g.get("group_id", ""),
215
+ "score": score,
216
+ "rank": 0,
217
+ "text": f"{g.get('group_name', '')}: {g.get('description', '')}",
218
+ "source": f"MV3/{g.get('dim_id', '')}",
219
+ "channel": "canonical_group",
220
+ "item": g,
221
+ })
222
+ results.sort(key=lambda x: -x["score"])
223
+ for i, r in enumerate(results[:top_k], 1):
224
+ r["rank"] = i
225
+ return results[:top_k]
226
+
227
+
228
+ # Global bundle
229
+ bundle = KSDataBundle()
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # Category Detection
234
+ # ---------------------------------------------------------------------------
235
+
236
+ DOCTRINAL_PATTERNS = [
237
+ re.compile(p, re.I) for p in [
238
+ r"chakras?\b.*energy|energy.*chakras?",
239
+ r"sahasr[aā]ra.*chakr|crown chakra|seven.?chakra",
240
+ r"ku[nṇ]dalin[iī].*energy|open.*chakras?",
241
+ r"tantric sex|literal consumption",
242
+ r"yama.?niyama|a[sṣ]t[aā]ṅga",
243
+ r"advaita ved[aā]nta|keval[aā]dvaita",
244
+ ]
245
+ ]
246
+
247
+
248
+ def detect_category(query: str) -> str:
249
+ q = query.lower()
250
+ if re.search(r"wikipedia|recipe|should i|breakfast|speed of light", q):
251
+ return "negative_test"
252
+ if any(p.search(q) for p in DOCTRINAL_PATTERNS):
253
+ return "doctrinal_warning"
254
+ if re.search(r"\blist\b|enumerate|how many|members of", q):
255
+ return "enumeration"
256
+ if re.search(r"what is |define |meaning of |who is ", q):
257
+ return "definition"
258
+ if re.search(r"how does .+ relate to|relationship between", q):
259
+ return "cross_dim_relation"
260
+ return "multi_hop_reasoning"
261
+
262
+
263
+ def detect_epistemic(query: str, category: str) -> Tuple[str, Optional[str]]:
264
+ if category == "negative_test":
265
+ return "not_applicable", None
266
+ q_lo = query.lower()
267
+ if re.search(r"how to attain|how to achieve|how do i experience", q_lo):
268
+ return ("pauruṣa_only_disclaim",
269
+ "⚠️ This system provides bauddha-jñāna (textual knowledge). "
270
+ "Pauruṣa-jñāna (experiential realisation via śaktipāta) requires "
271
+ "guru and sādhana. See TĀ 13.97-103.")
272
+ if any(kw in q_lo for kw in ["experience of", "what does it feel like", "feels like"]):
273
+ return ("pauruṣa_pointing",
274
+ "ℹ️ Results describe doctrine; experiential realisation is beyond text.")
275
+ return "bauddha_attainable", None
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # RRF Fusion
280
+ # ---------------------------------------------------------------------------
281
+
282
+ def rrf_fuse(channel_results: Dict[str, List[dict]], category: str) -> List[dict]:
283
+ weights = dict(DEFAULT_WEIGHTS)
284
+ if category in CATEGORY_OVERRIDES:
285
+ weights.update(CATEGORY_OVERRIDES[category])
286
+
287
+ fused: Dict[str, dict] = {}
288
+ for ch_name, results in channel_results.items():
289
+ w = weights.get(ch_name, 0.5)
290
+ if w == 0:
291
+ continue
292
+ for r in results:
293
+ doc_id = str(r.get("doc_id", ""))
294
+ rank = r.get("rank", 0)
295
+ if not doc_id or not rank:
296
+ continue
297
+ entry = fused.setdefault(doc_id, {
298
+ "doc_id": doc_id, "rrf_score": 0.0,
299
+ "channels": [], "ranks": {}, "text": "", "source": "",
300
+ })
301
+ entry["rrf_score"] += w / (RRF_K + rank)
302
+ if ch_name not in entry["channels"]:
303
+ entry["channels"].append(ch_name)
304
+ entry["ranks"][ch_name] = rank
305
+ if not entry["text"]:
306
+ entry["text"] = r.get("text", "")
307
+ if not entry["source"]:
308
+ entry["source"] = r.get("source", "")
309
+
310
+ out = list(fused.values())
311
+ out.sort(key=lambda x: -x["rrf_score"])
312
+ return out
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # Multi-projection expansion
317
+ # ---------------------------------------------------------------------------
318
+
319
+ def expand_projections(iast_lower: str) -> List[dict]:
320
+ member = bundle.get_member(iast_lower)
321
+ if not member:
322
+ return []
323
+ mid = member.get("member_id", "")
324
+ projections = []
325
+ seen = set()
326
+ for gm in bundle.memberships:
327
+ if gm.get("member_id") == mid:
328
+ gid = gm.get("group_id", "")
329
+ grp = bundle.get_group(gid) or {}
330
+ key = (mid, gid)
331
+ if key in seen:
332
+ continue
333
+ seen.add(key)
334
+ projections.append({
335
+ "entity_type": member.get("entity_type", ""),
336
+ "facet": f"as {member.get('entity_type', '')} in {grp.get('group_name', gid)}",
337
+ "group_id": gid,
338
+ "group_name": grp.get("group_name", ""),
339
+ "dim_id": grp.get("dim_id", ""),
340
+ })
341
+ if not projections:
342
+ projections.append({
343
+ "entity_type": member.get("entity_type", ""),
344
+ "facet": member.get("entity_type", ""),
345
+ "group_id": member.get("first_seen_in_group", ""),
346
+ "group_name": "",
347
+ "dim_id": member.get("first_seen_dim", ""),
348
+ })
349
+ return projections
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # Main query function
354
+ # ---------------------------------------------------------------------------
355
+
356
+ def query_ks_rag(question: str, top_k: int = 10) -> dict:
357
+ t0 = time.time()
358
+
359
+ category = detect_category(question)
360
+ epistemic_class, epistemic_disclaimer = detect_epistemic(question, category)
361
+
362
+ # Retrieve from channels
363
+ channels = {}
364
+ try:
365
+ channels["bm25"] = bundle.search_tfidf(question, top_k=20)
366
+ except Exception as e:
367
+ logger.warning(f"BM25 error: {e}")
368
+ try:
369
+ channels["dense"] = bundle.search_dense(question, top_k=15)
370
+ except Exception as e:
371
+ logger.warning(f"Dense error: {e}")
372
+ try:
373
+ channels["canonical_group"] = bundle.search_canonical(question, top_k=10)
374
+ except Exception as e:
375
+ logger.warning(f"Canonical error: {e}")
376
+
377
+ # Fuse
378
+ fused = rrf_fuse(channels, category)
379
+ top_results = fused[:top_k]
380
+
381
+ # Build citations
382
+ citations = []
383
+ for r in top_results:
384
+ citations.append({
385
+ "source": r.get("source", "unknown"),
386
+ "channels": r.get("channels", []),
387
+ "score": round(r.get("rrf_score", 0), 4),
388
+ "text": r.get("text", "")[:300],
389
+ })
390
+
391
+ # Check for ontology member match
392
+ member_match = None
393
+ projections = []
394
+ tokens = re.findall(r"[a-zāīūṛṝḷḹṅñṭḍṇśṣṃḥṁ]{4,}", question.lower())
395
+ for t in tokens:
396
+ m = bundle.get_member(t)
397
+ if m:
398
+ member_match = m
399
+ projections = expand_projections(t)
400
+ break
401
+
402
+ # Build structured response
403
+ answer_parts = []
404
+ if member_match:
405
+ enrich = member_match.get("mv2_enrichment", "")
406
+ if isinstance(enrich, str):
407
+ try:
408
+ enrich = json.loads(enrich)
409
+ except:
410
+ enrich = {}
411
+ else:
412
+ enrich = enrich if isinstance(enrich, dict) else {}
413
+ defn = enrich.get("definition", "") if enrich else ""
414
+ if defn:
415
+ answer_parts.append(f"**{member_match.get('iast_lowcase', '').title()}** ({member_match.get('entity_type', '')})")
416
+ answer_parts.append(defn)
417
+
418
+ if top_results:
419
+ answer_parts.append("\n**Top retrieved passages:**")
420
+ for i, r in enumerate(top_results[:5], 1):
421
+ src = r.get("source", "")
422
+ src_short = Path(src).name[:50] if src else "corpus"
423
+ answer_parts.append(f"{i}. [{', '.join(r.get('channels', []))}] *{src_short}*")
424
+ answer_parts.append(f" > {r.get('text', '')[:200]}...")
425
+
426
+ if not answer_parts:
427
+ answer_parts.append("No relevant results found for this query.")
428
+
429
+ confidence = min(1.0, len(top_results) / max(top_k, 1))
430
+ if member_match:
431
+ confidence = min(1.0, confidence + 0.2)
432
+
433
+ elapsed_ms = int((time.time() - t0) * 1000)
434
+
435
+ return {
436
+ "answer": "\n\n".join(answer_parts),
437
+ "citations": citations[:5],
438
+ "confidence": round(confidence, 2),
439
+ "category": category,
440
+ "epistemic_class": epistemic_class,
441
+ "epistemic_disclaimer": epistemic_disclaimer,
442
+ "projections": projections,
443
+ "used_chunks": [{"text": r.get("text", "")[:200], "source": r.get("source", "")} for r in top_results[:5]],
444
+ "time_ms": elapsed_ms,
445
+ "n_results": len(top_results),
446
+ "channels_used": list(channels.keys()),
447
+ }
448
+
449
+
450
+ # ---------------------------------------------------------------------------
451
+ # Gradio UI
452
+ # ---------------------------------------------------------------------------
453
+
454
+ EXAMPLE_QUERIES = [
455
+ ["What is spanda in Kashmir Shaivism?"],
456
+ ["What are the 36 tattvas?"],
457
+ ["Are chakras part of Kashmir Shaivism?"],
458
+ ["What is the difference between Śiva and Śakti?"],
459
+ ["What is śaktipāta?"],
460
+ ["List the five Kañcukas"],
461
+ ["What is kālī in the Krama tradition?"],
462
+ ["How does pratyabhijñā explain recognition?"],
463
+ ["What are the three malas?"],
464
+ ["Define anuttara"],
465
+ ["What is the relationship between bindu and nāda?"],
466
+ ["Is Kashmir Shaivism the same as Advaita Vedanta?"],
467
+ ]
468
+
469
+ CUSTOM_CSS = """
470
+ .gradio-container { max-width: 1100px !important; }
471
+ .token { background: #e8f5e9; padding: 2px 6px; border-radius: 4px; font-family: monospace; }
472
+ .warning-box { background: #fff3e0; border-left: 4px solid #ff9800; padding: 10px; margin: 8px 0; }
473
+ .projection-card { background: #f3e5f5; padding: 8px; border-radius: 6px; margin: 4px 0; }
474
+ .metric-good { color: #2e7d32; font-weight: bold; }
475
+ .metric-warn { color: #f57c00; font-weight: bold; }
476
+ """
477
+
478
+
479
+ def format_response(result: dict) -> Tuple[str, str, str, str]:
480
+ """Format the structured response into Gradio components."""
481
+ # Main answer
482
+ answer = result["answer"]
483
+
484
+ # Disclaimer
485
+ disclaimer = ""
486
+ if result.get("epistemic_disclaimer"):
487
+ disclaimer = f"⚠️ **{result['epistemic_class']}**: {result['epistemic_disclaimer']}"
488
+
489
+ # Citations table
490
+ cit_lines = ["| # | Channels | Score | Source |", "|---|----------|-------|--------|"]
491
+ for i, c in enumerate(result.get("citations", []), 1):
492
+ ch = ", ".join(c.get("channels", []))
493
+ cit_lines.append(f"| {i} | {ch} | {c.get('score', 0):.4f} | `{c.get('source', '')[:40]}` |")
494
+ citations_md = "\n".join(cit_lines)
495
+
496
+ # Projections
497
+ proj_md = ""
498
+ for p in result.get("projections", []):
499
+ proj_md += f"- **{p.get('entity_type', '')}** → {p.get('facet', '')} ({p.get('dim_id', '')})\n"
500
+
501
+ # Metrics
502
+ cat = result.get("category", "")
503
+ conf = result.get("confidence", 0)
504
+ ms = result.get("time_ms", 0)
505
+ ch_used = ", ".join(result.get("channels_used", []))
506
+ metrics = (
507
+ f"**Category:** `{cat}` | **Epistemic:** `{result.get('epistemic_class', '')}`\n"
508
+ f"**Confidence:** {conf:.2f} | **Latency:** {ms} ms | **Results:** {result.get('n_results', 0)}\n"
509
+ f"**Channels:** {ch_used}"
510
+ )
511
+
512
+ return answer, disclaimer, citations_md, proj_md, metrics
513
+
514
+
515
+ def run_query(question: str, top_k: int) -> Tuple[str, str, str, str, str]:
516
+ if not question.strip():
517
+ return "Please enter a question.", "", "", "", ""
518
+ result = query_ks_rag(question, top_k=int(top_k))
519
+ return format_response(result)
520
+
521
+
522
+ def run_eval(n_questions: int) -> str:
523
+ """Run evaluation on golden QA subset."""
524
+ qa = bundle.golden_qa[:int(n_questions)]
525
+ if not qa:
526
+ return "No golden QA data loaded."
527
+
528
+ hits = 0
529
+ total = len(qa)
530
+ for item in qa:
531
+ q = item["question"]
532
+ gold_iast = item.get("iast", "").lower()
533
+ gold_answer = item.get("answer", "").lower()
534
+ result = query_ks_rag(q, top_k=10)
535
+ # Check if gold concept appears in top results
536
+ found = False
537
+ for r in result.get("used_chunks", []):
538
+ if gold_iast and gold_iast in r.get("text", "").lower():
539
+ found = True
540
+ break
541
+ if gold_answer[:30] in r.get("text", "").lower():
542
+ found = True
543
+ break
544
+ if found:
545
+ hits += 1
546
+
547
+ recall = hits / total if total > 0 else 0
548
+ return (
549
+ f"**Evaluation Results** ({total} questions)\n\n"
550
+ f"| Metric | Value |\n|--------|-------|\n"
551
+ f"| Recall@10 | **{recall:.3f}** |\n"
552
+ f"| Questions | {total} |\n"
553
+ f"| Hits | {hits} |\n"
554
+ f"| Misses | {total - hits} |\n"
555
+ )
556
+
557
+
558
+ # ---------------------------------------------------------------------------
559
+ # Build interface
560
+ # ---------------------------------------------------------------------------
561
+
562
+ def build_app():
563
+ with gr.Blocks(
564
+ title="KS-GraphRAG: Kashmir Shaivism Knowledge Base",
565
+ css=CUSTOM_CSS,
566
+ theme=gr.themes.Soft(primary_hue="purple"),
567
+ ) as app:
568
+ gr.Markdown("""
569
+ # 🔱 KS-GraphRAG: Kashmir Shaivism RAG System
570
+ **Track A submission** — Hybrid GraphRAG over 892K-sentence Sanskrit corpus
571
+
572
+ 7 retrieval channels → RRF fusion → structured output with citations
573
+ | Corpus | Model | Ontology | Channels |
574
+ |--------|-------|----------|----------|
575
+ | 892K sentences, 1647 sources | BGE-M3 (dense) + TF-IDF (sparse) | MV3: 1462 members, 168 groups | BM25, Dense, Canonical Group |
576
+ """)
577
+
578
+ with gr.Row():
579
+ with gr.Column(scale=3):
580
+ question = gr.Textbox(
581
+ label="Question",
582
+ placeholder="Ask about Kashmir Shaivism (English or IAST Sanskrit)...",
583
+ lines=2,
584
+ )
585
+ top_k = gr.Slider(3, 20, value=10, step=1, label="Top-K results")
586
+ btn = gr.Button("🔍 Query KS-GraphRAG", variant="primary")
587
+
588
+ gr.Examples(
589
+ examples=EXAMPLE_QUERIES,
590
+ inputs=[question],
591
+ label="Example queries",
592
+ )
593
+
594
+ with gr.Column(scale=2):
595
+ metrics = gr.Markdown("*(metrics will appear here)*")
596
+
597
+ answer = gr.Markdown("*(answer will appear here)*")
598
+ disclaimer = gr.Markdown("")
599
+ projections = gr.Markdown("")
600
+
601
+ with gr.Accordion("📄 Citations", open=True):
602
+ citations = gr.Markdown("")
603
+
604
+ with gr.Accordion("📊 Evaluation", open=False):
605
+ with gr.Row():
606
+ n_q = gr.Slider(10, 100, value=30, step=10, label="# Questions")
607
+ eval_btn = gr.Button("Run Evaluation", variant="secondary")
608
+ eval_results = gr.Markdown("")
609
+
610
+ with gr.Accordion("📖 About", open=False):
611
+ gr.Markdown("""
612
+ ## Architecture
613
+
614
+ ```
615
+ Query → Category Detection → Multi-Channel Retrieval → RRF Fusion → Structured Output
616
+
617
+ ┌─ BM25 (TF-IDF sparse)
618
+ ├─ BGE-M3 (dense kNN)
619
+ └─ Canonical Groups (MV3 ontology)
620
+ ```
621
+
622
+ ### Key Features
623
+ - **Polysemy preservation**: `kālī` returns all projections (DEITY, SHAKTI, KALI_PHASE)
624
+ - **Doctrinal warnings**: detects Neo-Tantra/Hatha misconceptions automatically
625
+ - **Epistemic classification**: bauddha (textual) vs pauruṣa (experiential) distinction
626
+ - **Per-category RRF weights**: doctrinal_warning queries suppress paraphrase channels
627
+
628
+ ### Corpus Stats
629
+ - 892,858 sentences, 1,647 sources
630
+ - MV3 ontology: 1,462 members, 168 canonical groups, 1,837 memberships
631
+ - Primary texts: Tantrāloka (10 vols), Parātriśikā Vivaraṇa, Śiva Sūtras, Spanda Kārikās
632
+ """)
633
+
634
+ btn.click(
635
+ fn=run_query,
636
+ inputs=[question, top_k],
637
+ outputs=[answer, disclaimer, citations, projections, metrics],
638
+ )
639
+ eval_btn.click(fn=run_eval, inputs=[n_q], outputs=[eval_results])
640
+
641
+ return app
642
+
643
+
644
+ # ---------------------------------------------------------------------------
645
+ # Entry point
646
+ # ---------------------------------------------------------------------------
647
+
648
+ if __name__ == "__main__":
649
+ logger.info("Loading data...")
650
+ bundle.load()
651
+ bundle.init_tfidf()
652
+
653
+ # Try dense (may fail on CPU-constrained Spaces)
654
+ try:
655
+ bundle.init_dense()
656
+ except Exception as e:
657
+ logger.warning(f"Dense init failed ({e}), falling back to sparse-only")
658
+
659
+ app = build_app()
660
+ app.launch(server_name="0.0.0.0", server_port=7860)