resberry commited on
Commit
0c04f5b
·
verified ·
1 Parent(s): 0069fa3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +349 -50
app.py CHANGED
@@ -2,11 +2,14 @@ import os
2
  import json
3
  import pickle
4
  import re
 
5
 
6
  import faiss
7
  import gradio as gr
8
  import pandas as pd
 
9
  import torch
 
10
  from peft import PeftModel
11
  from sentence_transformers import SentenceTransformer
12
  from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
@@ -14,23 +17,25 @@ from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
14
  # ============================================================
15
  # SPACE PATHS / ENV
16
  # ============================================================
17
- # In Space Settings -> Variables:
18
  # BASE_MODEL_PATH=meta-llama/Llama-3.1-8B-Instruct
19
  # ALLOW_REBUILD_FROM_CSV=false
20
  # USE_4BIT=true
21
  # TOP_K=4
22
  # SCORE_THRESHOLD=0.40
23
  # USE_SCORE_THRESHOLD=true
 
24
  #
25
- # In Space Settings -> Secrets:
26
  # HF_TOKEN=hf_xxxxxxxxx
 
27
 
28
  BASE_MODEL_PATH = os.getenv("BASE_MODEL_PATH", "meta-llama/Llama-3.1-8B-Instruct")
29
  HF_TOKEN = os.getenv("HF_TOKEN", None)
 
30
 
31
  ADAPTER_DIR = "adapter_refined_v10"
32
 
33
- # IMPORTANT: this must match your actual folder name in the Space repo
34
  RAG_DIR = "RAGmaterials"
35
  FAISS_INDEX_PATH = os.path.join(RAG_DIR, "ecg_rag.index")
36
  META_PATH = os.path.join(RAG_DIR, "ecg_rag_meta.pkl")
@@ -42,6 +47,7 @@ USE_4BIT = os.getenv("USE_4BIT", "true").lower() == "true"
42
  TOP_K = int(os.getenv("TOP_K", "4"))
43
  USE_SCORE_THRESHOLD = os.getenv("USE_SCORE_THRESHOLD", "true").lower() == "true"
44
  SCORE_THRESHOLD = float(os.getenv("SCORE_THRESHOLD", "0.40"))
 
45
 
46
  # ============================================================
47
  # EMBEDDING / RAG SETTINGS
@@ -49,36 +55,79 @@ SCORE_THRESHOLD = float(os.getenv("SCORE_THRESHOLD", "0.40"))
49
  EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
50
  MAX_CONTEXT_CHARS = 3200
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  # ============================================================
53
  # GENERATION SETTINGS
54
  # ============================================================
55
  SYSTEM_PROMPT = (
56
  "You are a highly precise medical assistant specializing strictly in cardiology and ECG.\n\n"
57
  "You must follow ALL rules exactly:\n"
58
- "1) Provide only a direct medical definition or explanation of the asked term.\n"
59
- "2) Do NOT add background information, causes, risk factors, complications, prognosis, or treatment unless explicitly requested.\n"
60
- "3) Limit the response to a maximum of 2 sentences.\n"
61
- "4) Use formal medical terminology.\n"
62
- "5) Do NOT include citations, references, brackets, or external sources.\n"
63
- "6) Do NOT include examples unless explicitly requested.\n"
64
- "7) Do NOT ask questions.\n"
65
- "8) Do NOT include conversational phrases.\n"
66
- "9) Do NOT include notes, formatting labels, or navigation text.\n"
67
- "10) Do NOT repeat information.\n"
68
- "11) End with a complete declarative sentence.\n"
69
- "12) If the answer is uncertain, respond exactly with: I’m not sure about that."
70
  )
71
 
72
  RAG_INSTRUCTION = (
73
  "You MUST follow these rules:\n"
74
- "1) Use ONLY the provided CONTEXT to answer.\n"
75
- "2) If CONTEXT is empty OR does not contain the answer, say exactly: \"I couldn't find this in my sources.\" "
76
  "Then give ONLY a very general 1–2 sentence reply.\n"
77
- "3) If you use CONTEXT, EVERY sentence MUST end with citations like [S1].\n"
78
- "4) Output ONLY the answer text. No headings, no 'Sources:', no 'References:', no URLs.\n"
79
- "5) Do NOT paste or quote the CONTEXT.\n"
80
- "6) Do NOT repeat yourself.\n"
81
- "7) Stop immediately after the final sentence.\n"
 
 
 
 
 
 
 
82
  )
83
 
84
  MAX_LEN = 2048
@@ -121,15 +170,15 @@ def every_sentence_has_citation(txt: str) -> bool:
121
  parts = [p.strip() for p in re.split(r"(?<=[.!?])\s+", txt.strip()) if p.strip()]
122
  if not parts:
123
  return False
124
- return all(re.search(r"\[S\d+\]\s*$", p) for p in parts)
125
 
126
  def keep_only_cited_sentences(txt: str) -> str:
127
  sents = [p.strip() for p in re.split(r"(?<=[.!?])\s+", txt.strip()) if p.strip()]
128
- kept = [s for s in sents if re.search(r"\[S\d+\]\s*$", s)]
129
  return " ".join(kept).strip()
130
 
131
  def remove_citations(txt: str) -> str:
132
- return re.sub(r"\s*\[S\d+\]", "", txt).strip()
133
 
134
  def clean_text(x: str) -> str:
135
  x = str(x).replace("\x00", " ").strip()
@@ -254,6 +303,211 @@ def build_context(hits, max_chars=MAX_CONTEXT_CHARS):
254
 
255
  return "\n\n".join(blocks)
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  # ============================================================
258
  # LOAD BASE MODEL + LORA
259
  # ============================================================
@@ -349,7 +603,7 @@ def _generate(messages):
349
  return answer
350
 
351
  @torch.no_grad()
352
- def generate_with_rag(user_query: str, show_citations: bool = True):
353
  hits = retrieve(user_query, k=TOP_K)
354
  best_score = hits[0][0] if hits else -1.0
355
 
@@ -360,11 +614,31 @@ def generate_with_rag(user_query: str, show_citations: bool = True):
360
  context = build_context(hits)
361
  used_context = bool(context.strip())
362
 
363
- user_payload = f"QUESTION:\n{user_query}\n\nCONTEXT:\n{context}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
 
365
  messages = [
366
  {"role": "system", "content": SYSTEM_PROMPT},
367
  {"role": "system", "content": RAG_INSTRUCTION},
 
 
 
368
  {"role": "user", "content": user_payload},
369
  ]
370
 
@@ -375,24 +649,24 @@ def generate_with_rag(user_query: str, show_citations: bool = True):
375
  return True
376
  if BAD_OUTPUT_PATTERN.search(a):
377
  return True
378
- if used_context and show_citations and (not every_sentence_has_citation(a)):
379
  return True
380
  return False
381
 
382
  if is_invalid(answer):
383
  strict_format = (
384
- "Rewrite using ONLY CONTEXT.\n"
385
  "Output ONLY the answer text.\n"
386
  "No headings, no 'Sources', no 'References', no URLs.\n"
387
- "If not found in CONTEXT: say exactly \"I couldn't find this in my sources.\" "
388
  "then give only a very general 1–2 sentence reply.\n"
389
- "If you use CONTEXT: EVERY sentence must end with [S#].\n"
390
  "Stop immediately after the final sentence."
391
  )
392
  messages2 = messages + [{"role": "user", "content": strict_format}]
393
  answer = _generate(messages2)
394
 
395
- if used_context and show_citations:
396
  cited = keep_only_cited_sentences(answer)
397
  if cited.strip():
398
  answer = cited
@@ -400,30 +674,52 @@ def generate_with_rag(user_query: str, show_citations: bool = True):
400
  if hits:
401
  fallback = extract_fallback_answer_from_doc(hits[0][2])
402
  answer = f"{fallback} [S1]"
 
 
 
 
 
403
  else:
404
  answer = "I couldn't find this in my sources."
405
 
406
  if not show_citations:
407
  answer = remove_citations(answer)
408
 
409
- return answer, hits, best_score
410
 
411
- def format_sources_markdown(hits, max_chars=600):
412
- if not hits:
413
- return "No retrieved sources for the last answer."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
 
415
- lines = []
416
- for i, (score, idx, text, m) in enumerate(hits, 1):
417
- snippet = text[:max_chars].strip().replace("\n", "\n> ")
418
- lines.append(
419
- f"### S{i}\n"
420
- f"- score: `{score:.3f}`\n"
421
- f"- id: `{m.get('id')}`\n"
422
- f"- question: {m.get('question', '')}\n\n"
423
- f"> {snippet}"
424
- )
425
 
426
- return "\n\n".join(lines)
427
 
428
  # ============================================================
429
  # GRADIO UI
@@ -438,8 +734,10 @@ def respond(user_text, chat_history, state_history, show_citations):
438
  if not user_text:
439
  return "", chat_history, state_history, "No sources yet."
440
 
441
- answer, hits, best_score = generate_with_rag(
442
  user_text,
 
 
443
  show_citations=show_citations
444
  )
445
 
@@ -452,10 +750,11 @@ def respond(user_text, chat_history, state_history, show_citations):
452
  "user": user_text,
453
  "assistant": answer,
454
  "hits": hits,
 
455
  "best_score": best_score,
456
  }]
457
 
458
- sources_md = format_sources_markdown(hits)
459
  return "", chat_history, state_history, sources_md
460
 
461
  def reset_chat():
@@ -463,7 +762,7 @@ def reset_chat():
463
 
464
  with gr.Blocks(title="MrCardio") as demo:
465
  gr.Markdown("# MrCardio")
466
- gr.Markdown("Fine-tuned cardiology / ECG LoRA model with FAISS-based RAG.")
467
 
468
  show_citations = gr.Checkbox(value=True, label="Show citations in answers")
469
  chatbot = gr.Chatbot(type="messages", height=520)
 
2
  import json
3
  import pickle
4
  import re
5
+ from urllib.parse import urlparse, quote_plus
6
 
7
  import faiss
8
  import gradio as gr
9
  import pandas as pd
10
+ import requests
11
  import torch
12
+ from bs4 import BeautifulSoup
13
  from peft import PeftModel
14
  from sentence_transformers import SentenceTransformer
15
  from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
 
17
  # ============================================================
18
  # SPACE PATHS / ENV
19
  # ============================================================
20
+ # Variables:
21
  # BASE_MODEL_PATH=meta-llama/Llama-3.1-8B-Instruct
22
  # ALLOW_REBUILD_FROM_CSV=false
23
  # USE_4BIT=true
24
  # TOP_K=4
25
  # SCORE_THRESHOLD=0.40
26
  # USE_SCORE_THRESHOLD=true
27
+ # ENABLE_WEB_FALLBACK=true
28
  #
29
+ # Secrets:
30
  # HF_TOKEN=hf_xxxxxxxxx
31
+ # SERPAPI_API_KEY=xxxxxxxxx (optional, for better web search)
32
 
33
  BASE_MODEL_PATH = os.getenv("BASE_MODEL_PATH", "meta-llama/Llama-3.1-8B-Instruct")
34
  HF_TOKEN = os.getenv("HF_TOKEN", None)
35
+ SERPAPI_API_KEY = os.getenv("SERPAPI_API_KEY", None)
36
 
37
  ADAPTER_DIR = "adapter_refined_v10"
38
 
 
39
  RAG_DIR = "RAGmaterials"
40
  FAISS_INDEX_PATH = os.path.join(RAG_DIR, "ecg_rag.index")
41
  META_PATH = os.path.join(RAG_DIR, "ecg_rag_meta.pkl")
 
47
  TOP_K = int(os.getenv("TOP_K", "4"))
48
  USE_SCORE_THRESHOLD = os.getenv("USE_SCORE_THRESHOLD", "true").lower() == "true"
49
  SCORE_THRESHOLD = float(os.getenv("SCORE_THRESHOLD", "0.40"))
50
+ ENABLE_WEB_FALLBACK = os.getenv("ENABLE_WEB_FALLBACK", "true").lower() == "true"
51
 
52
  # ============================================================
53
  # EMBEDDING / RAG SETTINGS
 
55
  EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
56
  MAX_CONTEXT_CHARS = 3200
57
 
58
+ # ============================================================
59
+ # WEB FALLBACK SETTINGS
60
+ # ============================================================
61
+ WEB_TIMEOUT = 12
62
+ MAX_WEB_RESULTS = 3
63
+ MAX_WEB_CHARS = 2200
64
+
65
+ TRUSTED_MEDICAL_DOMAINS = [
66
+ # UK / general trusted clinical sources
67
+ "nice.org.uk",
68
+ "nhs.uk",
69
+ "gov.uk",
70
+ "bhf.org.uk",
71
+
72
+ # Major cardiology societies / journals
73
+ "escardio.org",
74
+ "heart.org",
75
+ "acc.org",
76
+ "ahajournals.org",
77
+ "hrsonline.org",
78
+ "nejm.org",
79
+ "bmj.com",
80
+ "jamanetwork.com",
81
+ "pubmed.ncbi.nlm.nih.gov",
82
+ "ncbi.nlm.nih.gov",
83
+
84
+ # ECG / cardiology education sites
85
+ "litfl.com", # LITFL ECG Library
86
+ "ecgwaves.com",
87
+ "ecgpedia.org",
88
+ "en.ecgpedia.org",
89
+ "ecgacademy.com",
90
+ "ecglibrary.com",
91
+ "geekymedics.com",
92
+
93
+ ]
94
+
95
  # ============================================================
96
  # GENERATION SETTINGS
97
  # ============================================================
98
  SYSTEM_PROMPT = (
99
  "You are a highly precise medical assistant specializing strictly in cardiology and ECG.\n\n"
100
  "You must follow ALL rules exactly:\n"
101
+ "1) Provide only a direct medical definition or explanation of the asked term unless the user asks for management, diagnosis, contraindications, indications, or differential diagnosis.\n"
102
+ "2) Use only the retrieved evidence provided in CONTEXT and WEB_CONTEXT.\n"
103
+ "3) Prefer local CONTEXT over WEB_CONTEXT whenever both are available.\n"
104
+ "4) Do NOT fabricate clinical recommendations or sources.\n"
105
+ "5) Use formal medical terminology.\n"
106
+ "6) Do NOT ask questions.\n"
107
+ "7) Do NOT include conversational phrases.\n"
108
+ "8) Do NOT include notes, formatting labels, or navigation text.\n"
109
+ "9) Do NOT repeat information.\n"
110
+ "10) End with complete declarative sentences.\n"
111
+ "11) If the answer is uncertain or unsupported, respond exactly with: I’m not sure about that."
 
112
  )
113
 
114
  RAG_INSTRUCTION = (
115
  "You MUST follow these rules:\n"
116
+ "1) Use ONLY the provided CONTEXT and WEB_CONTEXT to answer.\n"
117
+ "2) If CONTEXT and WEB_CONTEXT are both empty OR do not contain the answer, say exactly: \"I couldn't find this in my sources.\" "
118
  "Then give ONLY a very general 1–2 sentence reply.\n"
119
+ "3) If you use CONTEXT, every sentence supported by CONTEXT must end with citations like [S1].\n"
120
+ "4) If you use WEB_CONTEXT, every sentence supported by WEB_CONTEXT must end with citations like [W1].\n"
121
+ "5) Output ONLY the answer text. No headings, no 'Sources:', no 'References:', no URLs.\n"
122
+ "6) Do NOT paste or quote the CONTEXT or WEB_CONTEXT.\n"
123
+ "7) Do NOT repeat yourself.\n"
124
+ "8) Stop immediately after the final sentence.\n"
125
+ )
126
+
127
+ MEMORY_INSTRUCTION = (
128
+ "SESSION_MEMORY and recent conversation are supplementary only.\n"
129
+ "If SESSION_MEMORY or conversation conflicts with CONTEXT or WEB_CONTEXT, prioritize CONTEXT, then WEB_CONTEXT.\n"
130
+ "Do not invent patient facts from memory.\n"
131
  )
132
 
133
  MAX_LEN = 2048
 
170
  parts = [p.strip() for p in re.split(r"(?<=[.!?])\s+", txt.strip()) if p.strip()]
171
  if not parts:
172
  return False
173
+ return all(re.search(r"\[(S|W)\d+\]\s*$", p) for p in parts)
174
 
175
  def keep_only_cited_sentences(txt: str) -> str:
176
  sents = [p.strip() for p in re.split(r"(?<=[.!?])\s+", txt.strip()) if p.strip()]
177
+ kept = [s for s in sents if re.search(r"\[(S|W)\d+\]\s*$", s)]
178
  return " ".join(kept).strip()
179
 
180
  def remove_citations(txt: str) -> str:
181
+ return re.sub(r"\s*\[(S|W)\d+\]", "", txt).strip()
182
 
183
  def clean_text(x: str) -> str:
184
  x = str(x).replace("\x00", " ").strip()
 
303
 
304
  return "\n\n".join(blocks)
305
 
306
+ # ============================================================
307
+ # MEMORY HELPERS
308
+ # ============================================================
309
+ def build_memory_messages(chat_history, max_turns=4):
310
+ memory_messages = []
311
+ if not chat_history:
312
+ return memory_messages
313
+
314
+ trimmed = chat_history[-(max_turns * 2):]
315
+
316
+ for msg in trimmed:
317
+ role = msg.get("role")
318
+ content = (msg.get("content") or "").strip()
319
+ if role in {"user", "assistant"} and content:
320
+ memory_messages.append({"role": role, "content": content})
321
+
322
+ return memory_messages
323
+
324
+ def extract_session_memory(state_history):
325
+ memory = {
326
+ "age": None,
327
+ "sex": None,
328
+ "symptoms": [],
329
+ "conditions": [],
330
+ "medications": [],
331
+ "ecg_findings": [],
332
+ }
333
+
334
+ if not state_history:
335
+ return memory
336
+
337
+ for turn in state_history:
338
+ user_text = (turn.get("user") or "").lower()
339
+
340
+ age_match = re.search(r"\b(\d{1,3})\s*[- ]?\s*year[- ]?old\b", user_text)
341
+ if age_match:
342
+ memory["age"] = age_match.group(1)
343
+
344
+ if re.search(r"\bmale\b", user_text):
345
+ memory["sex"] = "male"
346
+ elif re.search(r"\bfemale\b", user_text):
347
+ memory["sex"] = "female"
348
+
349
+ symptom_keywords = ["chest pain", "palpitations", "syncope", "dyspnoea", "shortness of breath", "dizziness"]
350
+ condition_keywords = ["atrial fibrillation", "heart failure", "hypertension", "myocardial infarction", "nstemi", "stemi"]
351
+ medication_keywords = ["aspirin", "clopidogrel", "apixaban", "rivaroxaban", "warfarin", "metoprolol", "bisoprolol", "atorvastatin"]
352
+ ecg_keywords = ["st elevation", "st depression", "t wave inversion", "qt prolongation", "atrial fibrillation", "bundle branch block"]
353
+
354
+ for kw in symptom_keywords:
355
+ if kw in user_text and kw not in memory["symptoms"]:
356
+ memory["symptoms"].append(kw)
357
+
358
+ for kw in condition_keywords:
359
+ if kw in user_text and kw not in memory["conditions"]:
360
+ memory["conditions"].append(kw)
361
+
362
+ for kw in medication_keywords:
363
+ if kw in user_text and kw not in memory["medications"]:
364
+ memory["medications"].append(kw)
365
+
366
+ for kw in ecg_keywords:
367
+ if kw in user_text and kw not in memory["ecg_findings"]:
368
+ memory["ecg_findings"].append(kw)
369
+
370
+ return memory
371
+
372
+ def format_session_memory(memory):
373
+ lines = []
374
+ for k, v in memory.items():
375
+ if isinstance(v, list) and v:
376
+ lines.append(f"{k}: {', '.join(v)}")
377
+ elif v:
378
+ lines.append(f"{k}: {v}")
379
+ return "\n".join(lines) if lines else "No session memory."
380
+
381
+ # ============================================================
382
+ # TRUSTED WEB FALLBACK
383
+ # ============================================================
384
+ def needs_current_guidance(query: str) -> bool:
385
+ q = (query or "").lower()
386
+ trigger_terms = [
387
+ "guideline", "guidelines", "recommended", "recommendation",
388
+ "contraindication", "contraindications", "indication", "indications",
389
+ "management", "treatment", "treat", "anticoagulation", "drug",
390
+ "dose", "dosing", "safety", "trial", "evidence", "recent", "latest",
391
+ "current", "monitoring", "screening", "algorithm", "approach",
392
+ "differential", "diagnosis", "workup", "work-up"
393
+ ]
394
+ return any(t in q for t in trigger_terms)
395
+
396
+ def is_trusted_domain(url: str) -> bool:
397
+ try:
398
+ netloc = urlparse(url).netloc.lower()
399
+ return any(netloc.endswith(domain) for domain in TRUSTED_MEDICAL_DOMAINS)
400
+ except Exception:
401
+ return False
402
+
403
+ def fetch_page_text(url: str, max_chars: int = MAX_WEB_CHARS) -> str:
404
+ try:
405
+ resp = requests.get(
406
+ url,
407
+ timeout=WEB_TIMEOUT,
408
+ headers={"User-Agent": "Mozilla/5.0"}
409
+ )
410
+ resp.raise_for_status()
411
+ soup = BeautifulSoup(resp.text, "html.parser")
412
+
413
+ for tag in soup(["script", "style", "noscript", "svg"]):
414
+ tag.extract()
415
+
416
+ text = " ".join(soup.get_text(separator=" ").split())
417
+ return text[:max_chars].strip()
418
+ except Exception as e:
419
+ print(f"Failed to fetch page {url}: {e}")
420
+ return ""
421
+
422
+ def serpapi_search(query: str, max_results: int = 10):
423
+ if not SERPAPI_API_KEY:
424
+ return []
425
+
426
+ try:
427
+ params = {
428
+ "engine": "google",
429
+ "q": query,
430
+ "api_key": SERPAPI_API_KEY,
431
+ "num": max_results,
432
+ }
433
+ resp = requests.get("https://serpapi.com/search.json", params=params, timeout=WEB_TIMEOUT)
434
+ resp.raise_for_status()
435
+ data = resp.json()
436
+
437
+ results = []
438
+ for item in data.get("organic_results", []):
439
+ title = item.get("title", "")
440
+ url = item.get("link", "")
441
+ if title and url:
442
+ results.append({"title": title, "url": url})
443
+ return results
444
+ except Exception as e:
445
+ print(f"SerpAPI search failed: {e}")
446
+ return []
447
+
448
+ def duckduckgo_html_search(query: str, max_results: int = 10):
449
+ try:
450
+ url = f"https://html.duckduckgo.com/html/?q={quote_plus(query)}"
451
+ resp = requests.get(
452
+ url,
453
+ timeout=WEB_TIMEOUT,
454
+ headers={"User-Agent": "Mozilla/5.0"}
455
+ )
456
+ resp.raise_for_status()
457
+ soup = BeautifulSoup(resp.text, "html.parser")
458
+
459
+ results = []
460
+ for a in soup.select("a.result__a"):
461
+ title = " ".join(a.get_text(separator=" ").split()).strip()
462
+ href = a.get("href", "").strip()
463
+ if title and href:
464
+ results.append({"title": title, "url": href})
465
+ if len(results) >= max_results:
466
+ break
467
+
468
+ return results
469
+ except Exception as e:
470
+ print(f"DuckDuckGo HTML search failed: {e}")
471
+ return []
472
+
473
+ def web_search_medical(query: str, max_results: int = MAX_WEB_RESULTS):
474
+ raw_results = serpapi_search(query, max_results=10) if SERPAPI_API_KEY else duckduckgo_html_search(query, max_results=10)
475
+
476
+ trusted = []
477
+ for item in raw_results:
478
+ url = item.get("url", "")
479
+ title = item.get("title", "")
480
+ if not is_trusted_domain(url):
481
+ continue
482
+
483
+ page_text = fetch_page_text(url)
484
+ if not page_text:
485
+ continue
486
+
487
+ trusted.append({
488
+ "title": title,
489
+ "url": url,
490
+ "text": page_text,
491
+ })
492
+
493
+ if len(trusted) >= max_results:
494
+ break
495
+
496
+ return trusted
497
+
498
+ def build_web_context(web_hits, max_chars=MAX_WEB_CHARS):
499
+ blocks = []
500
+ total = 0
501
+
502
+ for i, hit in enumerate(web_hits, 1):
503
+ block = f"[W{i}]\nTitle: {hit['title']}\nText: {hit['text']}"
504
+ if total + len(block) > max_chars:
505
+ break
506
+ blocks.append(block)
507
+ total += len(block) + 2
508
+
509
+ return "\n\n".join(blocks)
510
+
511
  # ============================================================
512
  # LOAD BASE MODEL + LORA
513
  # ============================================================
 
603
  return answer
604
 
605
  @torch.no_grad()
606
+ def generate_with_rag(user_query: str, chat_history=None, state_history=None, show_citations: bool = True):
607
  hits = retrieve(user_query, k=TOP_K)
608
  best_score = hits[0][0] if hits else -1.0
609
 
 
614
  context = build_context(hits)
615
  used_context = bool(context.strip())
616
 
617
+ web_hits = []
618
+ web_context = ""
619
+ used_web = False
620
+
621
+ if ENABLE_WEB_FALLBACK and ((not used_context) or (best_score < SCORE_THRESHOLD)) and needs_current_guidance(user_query):
622
+ web_hits = web_search_medical(user_query, max_results=MAX_WEB_RESULTS)
623
+ web_context = build_web_context(web_hits)
624
+ used_web = bool(web_context.strip())
625
+
626
+ memory_messages = build_memory_messages(chat_history, max_turns=4) if chat_history else []
627
+ session_memory = format_session_memory(extract_session_memory(state_history or []))
628
+
629
+ user_payload = (
630
+ f"QUESTION:\n{user_query}\n\n"
631
+ f"SESSION_MEMORY:\n{session_memory}\n\n"
632
+ f"CONTEXT:\n{context}\n\n"
633
+ f"WEB_CONTEXT:\n{web_context}"
634
+ )
635
 
636
  messages = [
637
  {"role": "system", "content": SYSTEM_PROMPT},
638
  {"role": "system", "content": RAG_INSTRUCTION},
639
+ {"role": "system", "content": MEMORY_INSTRUCTION},
640
+ {"role": "system", "content": "If CONTEXT conflicts with WEB_CONTEXT, prioritize CONTEXT."},
641
+ ] + memory_messages + [
642
  {"role": "user", "content": user_payload},
643
  ]
644
 
 
649
  return True
650
  if BAD_OUTPUT_PATTERN.search(a):
651
  return True
652
+ if (used_context or used_web) and show_citations and (not every_sentence_has_citation(a)):
653
  return True
654
  return False
655
 
656
  if is_invalid(answer):
657
  strict_format = (
658
+ "Rewrite using ONLY CONTEXT and WEB_CONTEXT.\n"
659
  "Output ONLY the answer text.\n"
660
  "No headings, no 'Sources', no 'References', no URLs.\n"
661
+ "If nothing useful is found: say exactly \"I couldn't find this in my sources.\" "
662
  "then give only a very general 1–2 sentence reply.\n"
663
+ "If you use CONTEXT or WEB_CONTEXT: EVERY sentence must end with [S#] or [W#].\n"
664
  "Stop immediately after the final sentence."
665
  )
666
  messages2 = messages + [{"role": "user", "content": strict_format}]
667
  answer = _generate(messages2)
668
 
669
+ if (used_context or used_web) and show_citations:
670
  cited = keep_only_cited_sentences(answer)
671
  if cited.strip():
672
  answer = cited
 
674
  if hits:
675
  fallback = extract_fallback_answer_from_doc(hits[0][2])
676
  answer = f"{fallback} [S1]"
677
+ elif web_hits:
678
+ fallback = web_hits[0]["text"][:220].rsplit(" ", 1)[0].strip()
679
+ if not fallback.endswith("."):
680
+ fallback += "."
681
+ answer = f"{fallback} [W1]"
682
  else:
683
  answer = "I couldn't find this in my sources."
684
 
685
  if not show_citations:
686
  answer = remove_citations(answer)
687
 
688
+ return answer, hits, web_hits, best_score
689
 
690
+ # ============================================================
691
+ # SOURCE FORMATTING
692
+ # ============================================================
693
+ def format_sources_markdown(hits, web_hits=None, max_chars=600):
694
+ sections = []
695
+
696
+ if hits:
697
+ lines = []
698
+ for i, (score, idx, text, m) in enumerate(hits, 1):
699
+ snippet = text[:max_chars].strip().replace("\n", "\n> ")
700
+ lines.append(
701
+ f"### S{i}\n"
702
+ f"- score: `{score:.3f}`\n"
703
+ f"- id: `{m.get('id')}`\n"
704
+ f"- question: {m.get('question', '')}\n\n"
705
+ f"> {snippet}"
706
+ )
707
+ sections.append("\n\n".join(lines))
708
+ else:
709
+ sections.append("No retrieved local sources for the last answer.")
710
 
711
+ if web_hits:
712
+ web_lines = ["## Web sources"]
713
+ for i, hit in enumerate(web_hits, 1):
714
+ web_lines.append(
715
+ f"### W{i}\n"
716
+ f"- title: {hit['title']}\n"
717
+ f"- url: {hit['url']}\n\n"
718
+ f"> {hit['text'][:max_chars].strip()}"
719
+ )
720
+ sections.append("\n\n".join(web_lines))
721
 
722
+ return "\n\n".join(sections).strip()
723
 
724
  # ============================================================
725
  # GRADIO UI
 
734
  if not user_text:
735
  return "", chat_history, state_history, "No sources yet."
736
 
737
+ answer, hits, web_hits, best_score = generate_with_rag(
738
  user_text,
739
+ chat_history=chat_history,
740
+ state_history=state_history,
741
  show_citations=show_citations
742
  )
743
 
 
750
  "user": user_text,
751
  "assistant": answer,
752
  "hits": hits,
753
+ "web_hits": web_hits,
754
  "best_score": best_score,
755
  }]
756
 
757
+ sources_md = format_sources_markdown(hits, web_hits=web_hits)
758
  return "", chat_history, state_history, sources_md
759
 
760
  def reset_chat():
 
762
 
763
  with gr.Blocks(title="MrCardio") as demo:
764
  gr.Markdown("# MrCardio")
765
+ gr.Markdown("Fine-tuned cardiology / ECG LoRA model with FAISS-based RAG, short-term memory, and trusted medical web fallback.")
766
 
767
  show_citations = gr.Checkbox(value=True, label="Show citations in answers")
768
  chatbot = gr.Chatbot(type="messages", height=520)