johnnydang88 commited on
Commit
fc72d25
·
verified ·
1 Parent(s): ce5fa92

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +298 -20
app.py CHANGED
@@ -1,17 +1,25 @@
1
  """
2
  Cardiology AI Assistant — Microsoft Phi-3-Mini-4k-Instruct
3
  Hugging Face ZeroGPU Space
 
 
 
 
 
 
4
  """
5
 
6
- import os, gc, torch, warnings, pdfplumber
 
7
  import spaces
8
- from typing import List
 
9
  from langchain_core.documents import Document
10
  from langchain_text_splitters import RecursiveCharacterTextSplitter
11
  from langchain_community.vectorstores import FAISS
12
  from langchain_core.embeddings import Embeddings
13
  from transformers import AutoTokenizer, AutoModel, AutoModelForCausalLM
14
- from sentence_transformers import CrossEncoder
15
  import gradio as gr
16
 
17
  warnings.filterwarnings("ignore")
@@ -96,11 +104,17 @@ print("✅ Vector store ready.", flush=True)
96
  print("⚖️ Loading CrossEncoder (CPU)...", flush=True)
97
  reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2", device="cpu")
98
 
 
 
 
 
 
 
99
  print("🚀 Loading Phi-3-Mini in float16 (CPU)...", flush=True)
100
  MODEL_ID = "microsoft/Phi-3-mini-4k-instruct"
101
  tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=False)
102
- tokenizer.pad_token_id = tokenizer.eos_token_id
103
- tokenizer.padding_side = "left"
104
  model = AutoModelForCausalLM.from_pretrained(
105
  MODEL_ID,
106
  torch_dtype=torch.float16,
@@ -110,12 +124,199 @@ model.eval()
110
  print("✅ Phi-3 ready (CPU). GPU borrowed per request via ZeroGPU.", flush=True)
111
 
112
  # ══════════════════════════════════════════════════════════════════════════════
113
- # CPU RERANKER
114
  # ══════════════════════════════════════════════════════════════════════════════
115
  def rerank_docs(query: str, docs):
116
  scores = reranker.predict([[query, d.page_content] for d in docs])
117
  return scores
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  # ══════════════════════════════════════════════════════════════════════════════
120
  # GPU FUNCTION
121
  # ══════════════════════════════════════════════════════════════════════════════
@@ -145,7 +346,11 @@ def llm_generate(messages: list) -> str:
145
  # RAG PIPELINE
146
  # ══════════════════════════════════════════════════════════════════════════════
147
  def process_query_stream(query: str):
148
- yield "⏳ **Status:** 🔍 Retrieving relevant documents from VectorDB...\n\n---\n"
 
 
 
 
149
  retrieved = vectorstore.similarity_search(query, k=60)
150
 
151
  unique, seen = [], set()
@@ -155,14 +360,22 @@ def process_query_stream(query: str):
155
  unique.append(doc)
156
  seen.add(pg)
157
 
158
- yield "⏳ **Status:** 📊 Reranking with CrossEncoder (CPU)...\n\n---\n"
159
- scores = rerank_docs(query, unique)
160
- scored = sorted(zip(unique, scores), key=lambda x: x[1], reverse=True)
 
 
 
 
161
  top_docs = [d for d, _ in scored[:5]]
162
  context = "\n\n".join(d.page_content for d in top_docs)
163
  source_pages = ", ".join(str(d.metadata.get("page")) for d in top_docs)
164
 
165
- yield "⏳ **Status:** 🧠 Synthesizing with Phi-3 (ZeroGPU H200)...\n\n---\n"
 
 
 
 
166
  messages = [
167
  {
168
  "role": "system",
@@ -177,15 +390,25 @@ def process_query_stream(query: str):
177
  },
178
  {"role": "user", "content": f"Context:\n{context}\n\nQuestion:\n{query}"},
179
  ]
180
- response = llm_generate(messages)
181
- yield f"### ⚕️ Answer\n\n{response}\n\n📄 **Source Pages:** {source_pages}\n"
 
 
 
 
 
 
 
 
 
 
182
 
183
  # ═════════════���════════════════════════════════════════════════════════════════
184
  # GRADIO UI
185
  # ══════════════════════════════════════════════════════════════════════════════
186
  def gradio_wrapper(query):
187
  if not query or not query.strip():
188
- yield "⚠️ Please enter a valid question."
189
  return
190
  yield from process_query_stream(query)
191
 
@@ -198,22 +421,29 @@ phi_theme = gr.themes.Soft(
198
  button_primary_background_fill_hover="*primary_700",
199
  )
200
 
201
- with gr.Blocks(theme=phi_theme) as demo:
 
 
202
  gr.Markdown("# ⚕️ Cardiology AI Assistant (ESC 2024)")
203
  gr.Markdown("### ⚡ Powered by Microsoft Phi-3-Mini · ZeroGPU H200")
204
  gr.Markdown(
205
  "Ask questions based on the **2024 ESC Medical Guidelines**. "
206
- "Uses RAG with MedCPT embeddings, Cross-Encoder reranking, and Phi-3 generation."
 
207
  )
 
 
208
  with gr.Row():
209
- with gr.Column():
210
  input_text = gr.Textbox(
211
  label="Your Clinical Question",
212
  placeholder="e.g., What are the class I recommendations for anticoagulation in AF?",
213
  lines=3,
214
  )
215
- submit_btn = gr.Button("Analyze Guidelines", variant="primary")
216
- output_text = gr.Markdown(label="Assistant Response")
 
 
217
  gr.Examples(
218
  examples=[
219
  "What are the class I recommendations for anticoagulation in AF?",
@@ -221,7 +451,55 @@ with gr.Blocks(theme=phi_theme) as demo:
221
  "What is the target LDL-C for very high-risk patients?",
222
  ],
223
  inputs=input_text,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  )
225
- submit_btn.click(gradio_wrapper, inputs=input_text, outputs=output_text)
226
 
227
  demo.queue().launch(server_name="0.0.0.0", server_port=7860)
 
1
  """
2
  Cardiology AI Assistant — Microsoft Phi-3-Mini-4k-Instruct
3
  Hugging Face ZeroGPU Space
4
+ Includes: BERTScore F1, ROUGE-N, Semantic Similarity, Faithfulness, Answer Relevance, Context Recall
5
+ Same metric stack as the Llama-3 version — all fixes applied:
6
+ • SentenceTransformer forced to CPU (prevents stale CUDA zero-vector bug)
7
+ • ROUGE uses precision (overlap / answer_ngrams), not recall vs huge context
8
+ • Context capped at 60 sentences before embedding (prevents OOM)
9
+ • Per-metric try/except so one failure never kills the whole panel
10
  """
11
 
12
+ import os, gc, re, torch, warnings, pdfplumber
13
+ import numpy as np
14
  import spaces
15
+ from collections import Counter
16
+ from typing import List, Dict
17
  from langchain_core.documents import Document
18
  from langchain_text_splitters import RecursiveCharacterTextSplitter
19
  from langchain_community.vectorstores import FAISS
20
  from langchain_core.embeddings import Embeddings
21
  from transformers import AutoTokenizer, AutoModel, AutoModelForCausalLM
22
+ from sentence_transformers import CrossEncoder, SentenceTransformer
23
  import gradio as gr
24
 
25
  warnings.filterwarnings("ignore")
 
104
  print("⚖️ Loading CrossEncoder (CPU)...", flush=True)
105
  reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2", device="cpu")
106
 
107
+ # Explicitly load on CPU — after ZeroGPU releases the GPU, auto-device detection
108
+ # can latch onto a stale CUDA context and silently return zero vectors.
109
+ print("📐 Loading metrics SentenceTransformer (CPU)...", flush=True)
110
+ metrics_st = SentenceTransformer("all-MiniLM-L6-v2", device="cpu")
111
+ print("✅ Metrics encoder ready.", flush=True)
112
+
113
  print("🚀 Loading Phi-3-Mini in float16 (CPU)...", flush=True)
114
  MODEL_ID = "microsoft/Phi-3-mini-4k-instruct"
115
  tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=False)
116
+ tokenizer.pad_token_id = tokenizer.eos_token_id
117
+ tokenizer.padding_side = "left"
118
  model = AutoModelForCausalLM.from_pretrained(
119
  MODEL_ID,
120
  torch_dtype=torch.float16,
 
124
  print("✅ Phi-3 ready (CPU). GPU borrowed per request via ZeroGPU.", flush=True)
125
 
126
  # ══════════════════════════════════════════════════════════════════════════════
127
+ # RERANKER
128
  # ══════════════════════════════════════════════════════════════════════════════
129
  def rerank_docs(query: str, docs):
130
  scores = reranker.predict([[query, d.page_content] for d in docs])
131
  return scores
132
 
133
+ # ══════════════════════════════════════════════════════════════════════════════
134
+ # EVALUATION METRICS
135
+ # All reference-free — uses retrieved context + query as the reference signal.
136
+ # Identical implementation to the Llama-3 version for consistency.
137
+ # ══════════════════════════════════════════════════════════════════════════════
138
+
139
+ def _sent_tokenize(text: str) -> List[str]:
140
+ """Lightweight sentence splitter — no NLTK required."""
141
+ sents = re.split(r'(?<=[.!?])\s+', text.strip())
142
+ return [s.strip() for s in sents if len(s.strip()) > 10]
143
+
144
+ def _encode(texts: List[str]) -> np.ndarray:
145
+ """
146
+ Encode on CPU explicitly.
147
+ After ZeroGPU releases the GPU, SentenceTransformer's auto-device detection
148
+ can latch onto a stale CUDA context and return zero vectors.
149
+ Forcing CPU guarantees correct, non-zero embeddings every time.
150
+ """
151
+ return metrics_st.encode(
152
+ texts,
153
+ normalize_embeddings=True,
154
+ show_progress_bar=False,
155
+ device="cpu",
156
+ convert_to_numpy=True,
157
+ )
158
+
159
+ def _ngrams(tokens: List[str], n: int) -> Counter:
160
+ return Counter(tuple(tokens[i:i+n]) for i in range(len(tokens) - n + 1))
161
+
162
+ def rouge_n(hypothesis: str, reference: str, n: int = 1) -> float:
163
+ """
164
+ ROUGE-N precision: fraction of answer n-grams that appear in the context.
165
+ Using precision (not recall) because the context is ~6,000+ tokens — recall
166
+ of a ~60-token answer against that pool is always ~4% even for correct answers.
167
+ """
168
+ hyp_tokens = hypothesis.lower().split()
169
+ ref_tokens = reference.lower().split()
170
+ hyp_ng = _ngrams(hyp_tokens, n)
171
+ ref_ng = _ngrams(ref_tokens, n)
172
+ overlap = sum((hyp_ng & ref_ng).values())
173
+ denom = sum(hyp_ng.values()) # precision: denominator = answer n-grams
174
+ return round(overlap / denom, 4) if denom > 0 else 0.0
175
+
176
+ def bertscore_f1(answer: str, context_sents: List[str]) -> float:
177
+ """
178
+ Approximate BERTScore F1 via sentence-level embeddings.
179
+ P = mean max-cosine(answer_sent → any context_sent)
180
+ R = mean max-cosine(context_sent → any answer_sent)
181
+ F1 = harmonic mean(P, R)
182
+ Uses pre-tokenised, capped context sentences to avoid encoding 100+ sentences.
183
+ """
184
+ ans_sents = _sent_tokenize(answer)
185
+ if not ans_sents or not context_sents:
186
+ return 0.0
187
+ try:
188
+ a_embs = _encode(ans_sents)
189
+ c_embs = _encode(context_sents)
190
+ sim = a_embs @ c_embs.T
191
+ P = float(sim.max(axis=1).mean())
192
+ R = float(sim.max(axis=0).mean())
193
+ f1 = 2 * P * R / (P + R + 1e-9)
194
+ return round(max(f1, 0.0), 4)
195
+ except Exception as e:
196
+ print(f"⚠️ bertscore_f1 error: {e}", flush=True)
197
+ return 0.0
198
+
199
+ def semantic_similarity(answer: str, query: str) -> float:
200
+ """Cosine similarity between answer embedding and query embedding."""
201
+ try:
202
+ embs = _encode([answer, query])
203
+ score = float(embs[0] @ embs[1])
204
+ return round(max(score, 0.0), 4)
205
+ except Exception as e:
206
+ print(f"⚠️ semantic_similarity error: {e}", flush=True)
207
+ return 0.0
208
+
209
+ def faithfulness(answer: str, context_sents: List[str], threshold: float = 0.35) -> float:
210
+ """
211
+ Fraction of answer sentences whose max cosine-sim to any context sentence ≥ threshold.
212
+ Threshold = 0.35 (not 0.40) so paraphrased but grounded sentences are counted.
213
+ """
214
+ ans_sents = _sent_tokenize(answer)
215
+ if not ans_sents or not context_sents:
216
+ return 0.0
217
+ try:
218
+ a_embs = _encode(ans_sents)
219
+ c_embs = _encode(context_sents)
220
+ sim = a_embs @ c_embs.T
221
+ max_per_ans = sim.max(axis=1)
222
+ faithful_count = int((max_per_ans >= threshold).sum())
223
+ return round(faithful_count / len(ans_sents), 4)
224
+ except Exception as e:
225
+ print(f"⚠️ faithfulness error: {e}", flush=True)
226
+ return 0.0
227
+
228
+ def answer_relevance(answer: str, query: str) -> float:
229
+ """Does the answer actually address what was asked?"""
230
+ return semantic_similarity(answer, query)
231
+
232
+ def context_recall(answer: str, context_sents: List[str], threshold: float = 0.35) -> float:
233
+ """
234
+ Fraction of context sentences reflected in the answer.
235
+ Mirrors RAGAS Context Recall but without ground-truth labels.
236
+ """
237
+ ans_sents = _sent_tokenize(answer)
238
+ if not ans_sents or not context_sents:
239
+ return 0.0
240
+ try:
241
+ a_embs = _encode(ans_sents)
242
+ c_embs = _encode(context_sents)
243
+ sim = a_embs @ c_embs.T
244
+ max_per_ctx = sim.max(axis=0)
245
+ recalled_count = int((max_per_ctx >= threshold).sum())
246
+ return round(recalled_count / len(context_sents), 4)
247
+ except Exception as e:
248
+ print(f"⚠️ context_recall error: {e}", flush=True)
249
+ return 0.0
250
+
251
+ def compute_all_metrics(query: str, answer: str, context: str) -> Dict[str, float]:
252
+ """
253
+ Tokenise context once, cap at 60 sentences (top-ranked chunks come first),
254
+ then run all embedding-based metrics against that capped list.
255
+ ROUGE uses the raw context string (pure token overlap, no matrices).
256
+ """
257
+ ctx_sents_all = _sent_tokenize(context)
258
+ ctx_sents = ctx_sents_all[:60]
259
+ print(f"📐 Metrics: answer={len(_sent_tokenize(answer))} sents, "
260
+ f"ctx={len(ctx_sents)}/{len(ctx_sents_all)} sents", flush=True)
261
+ return {
262
+ "BERTScore F1": bertscore_f1(answer, ctx_sents),
263
+ "ROUGE-1": rouge_n(answer, context, n=1),
264
+ "ROUGE-2": rouge_n(answer, context, n=2),
265
+ "Semantic Similarity": semantic_similarity(answer, query),
266
+ "Faithfulness": faithfulness(answer, ctx_sents),
267
+ "Answer Relevance": answer_relevance(answer, query),
268
+ "Context Recall": context_recall(answer, ctx_sents),
269
+ }
270
+
271
+ # ── Display helpers ───────────────────────────────────────────────────────────
272
+ _METRIC_DESCRIPTIONS = {
273
+ "BERTScore F1": "Sentence-level semantic overlap F1 between answer and top context sentences.",
274
+ "ROUGE-1": "Fraction of answer unigrams found in retrieved context (precision).",
275
+ "ROUGE-2": "Fraction of answer bigrams found in retrieved context (precision).",
276
+ "Semantic Similarity": "Cosine similarity between answer and question embeddings.",
277
+ "Faithfulness": "Fraction of answer sentences semantically supported by the retrieved context.",
278
+ "Answer Relevance": "How directly the answer addresses the original question.",
279
+ "Context Recall": "Fraction of top context sentences reflected in the answer.",
280
+ }
281
+
282
+ _THRESHOLDS = {
283
+ # (warn_below, ok_below, good_above)
284
+ "BERTScore F1": (0.50, 0.65, 0.80),
285
+ "ROUGE-1": (0.15, 0.30, 0.45),
286
+ "ROUGE-2": (0.05, 0.15, 0.25),
287
+ "Semantic Similarity": (0.40, 0.60, 0.75),
288
+ "Faithfulness": (0.50, 0.70, 0.85),
289
+ "Answer Relevance": (0.40, 0.60, 0.75),
290
+ "Context Recall": (0.15, 0.30, 0.50),
291
+ }
292
+
293
+ def _colour(name: str, value: float) -> str:
294
+ warn, ok, good = _THRESHOLDS.get(name, (0.3, 0.6, 0.8))
295
+ if value >= good: return "🟢"
296
+ if value >= ok: return "🟡"
297
+ return "🔴"
298
+
299
+ def _bar(value: float, width: int = 20) -> str:
300
+ filled = int(round(value * width))
301
+ return "█" * filled + "░" * (width - filled)
302
+
303
+ def format_metrics_markdown(metrics: Dict[str, float]) -> str:
304
+ lines = ["## 📊 Evaluation Metrics\n"]
305
+ lines.append(
306
+ "> Metrics are **reference-free** and computed against the retrieved context "
307
+ "and original query — no labelled ground truth required.\n"
308
+ )
309
+ lines.append("| Metric | Score | Bar | Status | Notes |")
310
+ lines.append("|--------|------:|-----|--------|-------|")
311
+ for name, value in metrics.items():
312
+ pct = f"{value:.2%}"
313
+ bar = f"`{_bar(value)}`"
314
+ icon = _colour(name, value)
315
+ desc = _METRIC_DESCRIPTIONS.get(name, "")
316
+ lines.append(f"| **{name}** | {pct} | {bar} | {icon} | {desc} |")
317
+ lines.append("\n**Colour key:** 🟢 Good · 🟡 Acceptable · 🔴 Needs attention")
318
+ return "\n".join(lines)
319
+
320
  # ══════════════════════════════════════════════════════════════════════════════
321
  # GPU FUNCTION
322
  # ══════════════════════════════════════════════════════════════════════════════
 
346
  # RAG PIPELINE
347
  # ══════════════════════════════════════════════════════════════════════════════
348
  def process_query_stream(query: str):
349
+ # ── Step 1: retrieval ────────────────────────────────────────────────────
350
+ yield (
351
+ "⏳ **Status:** 🔍 Retrieving relevant documents from VectorDB...\n\n---\n",
352
+ ""
353
+ )
354
  retrieved = vectorstore.similarity_search(query, k=60)
355
 
356
  unique, seen = [], set()
 
360
  unique.append(doc)
361
  seen.add(pg)
362
 
363
+ # ── Step 2: rerank ───────────────────────────────────────────────────────
364
+ yield (
365
+ "⏳ **Status:** 📊 Reranking with CrossEncoder (CPU)...\n\n---\n",
366
+ ""
367
+ )
368
+ scores = rerank_docs(query, unique)
369
+ scored = sorted(zip(unique, scores), key=lambda x: x[1], reverse=True)
370
  top_docs = [d for d, _ in scored[:5]]
371
  context = "\n\n".join(d.page_content for d in top_docs)
372
  source_pages = ", ".join(str(d.metadata.get("page")) for d in top_docs)
373
 
374
+ # ── Step 3: generate ─────────────────────────────────────────────────────
375
+ yield (
376
+ "⏳ **Status:** 🧠 Synthesizing with Phi-3 (ZeroGPU H200)...\n\n---\n",
377
+ ""
378
+ )
379
  messages = [
380
  {
381
  "role": "system",
 
390
  },
391
  {"role": "user", "content": f"Context:\n{context}\n\nQuestion:\n{query}"},
392
  ]
393
+ answer = llm_generate(messages)
394
+ answer_md = f"### ⚕️ Answer\n\n{answer}\n\n📄 **Source Pages:** {source_pages}\n"
395
+
396
+ # ── Step 4: metrics ──────────────────────────────────────────────────────
397
+ yield (
398
+ answer_md,
399
+ "⏳ **Status:** 📐 Computing evaluation metrics (CPU)...\n"
400
+ )
401
+ metrics = compute_all_metrics(query, answer, context)
402
+ metrics_md = format_metrics_markdown(metrics)
403
+
404
+ yield (answer_md, metrics_md)
405
 
406
  # ═════════════���════════════════════════════════════════════════════════════════
407
  # GRADIO UI
408
  # ══════════════════════════════════════════════════════════════════════════════
409
  def gradio_wrapper(query):
410
  if not query or not query.strip():
411
+ yield "⚠️ Please enter a valid question.", ""
412
  return
413
  yield from process_query_stream(query)
414
 
 
421
  button_primary_background_fill_hover="*primary_700",
422
  )
423
 
424
+ with gr.Blocks(theme=phi_theme, title="Cardiology AI Assistant") as demo:
425
+
426
+ # ── Header ───────────────────────────────────────────────────────────────
427
  gr.Markdown("# ⚕️ Cardiology AI Assistant (ESC 2024)")
428
  gr.Markdown("### ⚡ Powered by Microsoft Phi-3-Mini · ZeroGPU H200")
429
  gr.Markdown(
430
  "Ask questions based on the **2024 ESC Medical Guidelines**. "
431
+ "Uses RAG with MedCPT embeddings, Cross-Encoder reranking, Phi-3 generation, "
432
+ "and **live evaluation metrics**."
433
  )
434
+
435
+ # ── Input ────────────────────────────────────────────────────────────────
436
  with gr.Row():
437
+ with gr.Column(scale=4):
438
  input_text = gr.Textbox(
439
  label="Your Clinical Question",
440
  placeholder="e.g., What are the class I recommendations for anticoagulation in AF?",
441
  lines=3,
442
  )
443
+ with gr.Column(scale=1, min_width=160):
444
+ submit_btn = gr.Button("🔍 Analyze Guidelines", variant="primary", size="lg")
445
+
446
+ # ── Examples ─────────────────────────────────────────────────────────────
447
  gr.Examples(
448
  examples=[
449
  "What are the class I recommendations for anticoagulation in AF?",
 
451
  "What is the target LDL-C for very high-risk patients?",
452
  ],
453
  inputs=input_text,
454
+ label="Example Questions",
455
+ )
456
+
457
+ gr.Markdown("---")
458
+
459
+ # ── Answer output (full width) ────────────────────────────────────────────
460
+ answer_output = gr.Markdown(
461
+ label="Assistant Response",
462
+ value="*Your answer will appear here after submission.*",
463
+ )
464
+
465
+ gr.Markdown("---")
466
+
467
+ # ── Metrics output (full width, below answer) ─────────────────────────────
468
+ metrics_output = gr.Markdown(
469
+ label="Evaluation Metrics",
470
+ value="*Metrics will appear here once the answer is generated.*",
471
+ )
472
+
473
+ gr.Markdown("---")
474
+
475
+ # ── Metric legend ─────────────────────────────────────────────────────────
476
+ with gr.Accordion("ℹ️ About the Evaluation Metrics", open=False):
477
+ gr.Markdown("""
478
+ ### How each metric is computed
479
+
480
+ | Metric | Method | Interpretation |
481
+ |--------|--------|---------------|
482
+ | **BERTScore F1** | Sentence-level cosine-sim F1 between answer sentences and top-60 context sentences using `all-MiniLM-L6-v2` (forced CPU) | Measures how semantically similar the answer is to the source context |
483
+ | **ROUGE-1** | **Precision**: fraction of answer unigrams that appear in the retrieved context | Are the words the model used actually in the retrieved passages? |
484
+ | **ROUGE-2** | **Precision**: fraction of answer bigrams that appear in the retrieved context | Are the phrases the model used actually in the retrieved passages? |
485
+ | **Semantic Similarity** | Cosine similarity of full answer ↔ question embeddings | Does the answer embed in the same semantic space as the question? |
486
+ | **Faithfulness** | Fraction of answer sentences with cosine-sim ≥ 0.35 to any context sentence | Are answer claims grounded in retrieved text? |
487
+ | **Answer Relevance** | Cosine similarity of answer ↔ question embeddings | How directly does the answer respond to the question? |
488
+ | **Context Recall** | Fraction of top-60 context sentences with cosine-sim ��� 0.35 to any answer sentence | How much of the retrieved evidence is used in the answer? |
489
+
490
+ > **Why precision for ROUGE?** The retrieved context is ~6,000 tokens; a correct ~60-token answer
491
+ > has only ~4% unigram *recall* against that pool — even if every word came from the context.
492
+ > Precision asks the right question: *"Did the model use words that actually appear in the retrieved passages?"*
493
+
494
+ > **All metrics are reference-free** — they use the retrieved context and original query as the
495
+ > reference signal, so no annotated ground-truth is needed.
496
+ """)
497
+
498
+ # ── Wire up ───────────────────────────────────────────────────────────────
499
+ submit_btn.click(
500
+ fn=gradio_wrapper,
501
+ inputs=input_text,
502
+ outputs=[answer_output, metrics_output],
503
  )
 
504
 
505
  demo.queue().launch(server_name="0.0.0.0", server_port=7860)