himanithakkar commited on
Commit
dd4bee8
·
verified ·
1 Parent(s): 8f25872

Update app/app.py

Browse files
Files changed (1) hide show
  1. app/app.py +316 -321
app/app.py CHANGED
@@ -1,321 +1,316 @@
1
- # app/app.py
2
- # Windows click-to-run local RAG app that queries Claude via Anthropic API.
3
- import os, sys, time, csv, re
4
- from pathlib import Path
5
- import yaml
6
- import pandas as pd
7
- import numpy as np
8
- # claude_test.py (or app.py)
9
- from dotenv import load_dotenv
10
-
11
- load_dotenv()
12
-
13
- ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
14
- print("ANTHROPIC in env:", bool(os.getenv("ANTHROPIC_API_KEY")), flush=True)
15
-
16
- if not ANTHROPIC_API_KEY:
17
- raise SystemExit("Missing ANTHROPIC_API_KEY in environment or .env file.")
18
-
19
- # --- Privacy: keep HF offline (embeddings can come from your local cache)
20
- # os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
21
- # os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
22
-
23
- # --- Resolve paths (works both as .py and PyInstaller .exe)
24
- APP_DIR = Path(__file__).resolve().parent # .../app
25
- if hasattr(sys, "_MEIPASS"): # PyInstaller bundle
26
- ROOT = Path(sys._MEIPASS) # bundle root at runtime
27
- else:
28
- ROOT = APP_DIR.parent # project root (…/RAG_APP)
29
-
30
- CFG_PATH = ROOT / "app" / "config" / "app.yaml"
31
- MODELS_DIR = ROOT / "models"
32
- INDEX_DIR = ROOT / "outputs" / "index"
33
- LOGS_DIR = ROOT / "local_logs"
34
- DOCS_DIR = ROOT / "docs"
35
-
36
- LOGS_DIR.mkdir(parents=True, exist_ok=True)
37
-
38
- # --- Read config
39
- DEFAULT_CFG = {
40
- "retrieval": {"top_k": 12, "evidence_shown": 3, "answerability_threshold": 0.2},
41
- "generator": {
42
- "enabled_default": False,
43
- "use_top_evidence": 5,
44
- "temperature": 0.1,
45
- "max_answer_sentences": 20,
46
- "n_ctx": 4096,
47
- "threads": max(2, (os.cpu_count() or 4) - 1)
48
- },
49
- "models": {
50
- "embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
51
- "embedding_local_dir": None, # set a folder if you want to ship the embed model locally
52
- # Anthropic model alias or versioned name:
53
- # good choices: "claude-3-5-sonnet-latest", "claude-3-5-haiku-latest", "claude-3-opus-20240229"
54
- "anthropic_model": "claude-3-5-sonnet-latest"
55
- },
56
- "ui": {"show_online_badge": True, "performance_mode": "standard"} # quick|standard
57
- }
58
- cfg = DEFAULT_CFG
59
- if CFG_PATH.exists():
60
- # shallow merge is fine here; extend if you need nested merges
61
- cfg = {**cfg, **yaml.safe_load(CFG_PATH.read_text(encoding="utf-8"))}
62
-
63
- # --- Load FAISS index & embeddings
64
- import faiss
65
- from sentence_transformers import SentenceTransformer
66
-
67
- META_PATH = INDEX_DIR / "meta.parquet"
68
- FAISS_PATH = INDEX_DIR / "chunks.faiss"
69
-
70
- if not META_PATH.exists() or not FAISS_PATH.exists():
71
- raise SystemExit("Index not found. Ensure outputs/index/meta.parquet and chunks.faiss exist.")
72
-
73
- df = pd.read_parquet(META_PATH)
74
- df["text"] = df["text"].fillna("").astype(str)
75
-
76
- # Load SentenceTransformer from local dir if provided (for offline use)
77
- emb_dir = cfg["models"].get("embedding_local_dir")
78
- if emb_dir:
79
- EMBED_MODEL_PATH = Path(emb_dir)
80
- if not EMBED_MODEL_PATH.exists():
81
- raise SystemExit(f"Embedding model folder not found: {EMBED_MODEL_PATH}")
82
- embed_model = SentenceTransformer(str(EMBED_MODEL_PATH), trust_remote_code=False)
83
- else:
84
- # Will use local cache; ensure you’ve pre-fetched this once on a connected machine
85
- embed_model = SentenceTransformer(cfg["models"]["embedding_model"], trust_remote_code=False)
86
-
87
- index = faiss.read_index(str(FAISS_PATH))
88
-
89
- def _format_citation(row):
90
- p = int(row["page"]) if pd.notna(row.get("page")) else None
91
- return f"{row['title']} (p.{p})" if p else f"{row['title']}"
92
-
93
- def retrieve(query, top_k=6):
94
- qv = embed_model.encode([query], convert_to_numpy=True, normalize_embeddings=True).astype("float32")
95
- scores, idxs = index.search(qv, top_k)
96
- out = []
97
- for s, ix in zip(scores[0], idxs[0]):
98
- r = df.iloc[int(ix)]
99
- out.append({
100
- "score": float(s),
101
- "citation": _format_citation(r),
102
- "doc_id": r.get("doc_id", ""),
103
- "page": None if pd.isna(r.get("page")) else int(r["page"]),
104
- "chunk_id": int(r["chunk_id"]),
105
- "text": r["text"]
106
- })
107
- return out
108
-
109
- # --- Anthropic (Claude) generator
110
- from anthropic import Anthropic, APIError
111
-
112
- ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY")
113
- if not ANTHROPIC_API_KEY:
114
- raise SystemExit("Missing ANTHROPIC_API_KEY environment variable.")
115
-
116
- anthropic_client = Anthropic(api_key=ANTHROPIC_API_KEY)
117
- CLAUDE_MODEL = cfg["models"].get("anthropic_model", "claude-3-5-sonnet-latest")
118
-
119
- SYSTEM_PROMPT = (
120
- "You are a careful assistant for clinicians. "
121
- "Use ONLY the provided context to answer. "
122
- "Be concise. Add inline citations like [1], [2] matching the numbered context. "
123
- "If the context does not fully answer, provide the best supported guidance you can, and point to the closest relevant passages with citations"
124
- )
125
-
126
- def _citations_valid(text, k):
127
- nums = set(int(n) for n in re.findall(r"\[(\d+)\]", text))
128
- return bool(nums) and all(1 <= n <= k for n in nums)
129
-
130
- def _join_cites(nums):
131
- nums = [f"[{n}]" for n in nums]
132
- if not nums:
133
- return ""
134
- if len(nums) == 1:
135
- return nums[0]
136
- return ", ".join(nums[:-1]) + " and " + nums[-1]
137
-
138
- def _make_context_block(ctx, use_n):
139
- blocks = []
140
- for i, c in enumerate(ctx[:use_n], 1):
141
- blocks.append(f"[{i}] {c['citation']}\n{c['text']}\n")
142
- return "\n".join(blocks)
143
-
144
- def generate_answer(question, ctx, use_n, temp=0.1, max_sentences=6):
145
- context_text = _make_context_block(ctx, use_n)
146
- user_prompt = (
147
- f"Context:\n\n{context_text}\n\n"
148
- f"Question: {question}\n"
149
- "Answer using ONLY the context above and cite with [1], [2], etc."
150
- )
151
- try:
152
- resp = anthropic_client.messages.create(
153
- model=CLAUDE_MODEL,
154
- system=SYSTEM_PROMPT,
155
- max_tokens=600,
156
- temperature=float(temp),
157
- messages=[{"role": "user", "content": [{"type": "text", "text": user_prompt}]}],
158
- )
159
- except APIError as e:
160
- return f"_API error from Anthropic: {e}_"
161
-
162
- # Claude returns a list of content blocks; we are expectin text blocks
163
- parts = []
164
- for blk in resp.content:
165
- if blk.type == "text":
166
- parts.append(blk.text)
167
- full = ("\n".join(parts)).strip()
168
-
169
- # # Log raw model output to console for debugging
170
- # print("\n===== RAW MODEL OUTPUT =====\n", full, "\n============================\n", flush=True)
171
-
172
- # Trim to ~N sentences to keep it short for testers
173
- sents = re.split(r'(?<=[.!?])\s+', full)
174
- short = " ".join(sents[:max_sentences]).strip()
175
- return short
176
-
177
- # (Gradio on localhost)
178
- import gradio as gr
179
- ONLINE_BADGE = "Standards of Practice & Code of Ethics" if cfg["ui"].get("show_online_badge", True) else ""
180
- def _top_sentences(text, n=3):
181
- sents = re.split(r'(?<=[.!?])\s+', text.strip())
182
- return [s for s in sents if s][:n]
183
-
184
- def answer_extractive(query, k=6, per_chunk_sents=2):
185
- ctx = retrieve(query, top_k=k)
186
- bullets, refs = [], []
187
- for i, c in enumerate(ctx, 1):
188
- for s in _top_sentences(c["text"], per_chunk_sents):
189
- bullets.append(f"- {s} [{i}]")
190
- refs.append(f"[{i}] {c['citation']}")
191
- if not bullets:
192
- return "I couldn’t find relevant text in the corpus.", refs
193
- return "\n".join(bullets) + "\n\nSources:\n" + "\n".join(refs), refs
194
-
195
- def app_infer(question, do_generate, mode):
196
- start = time.time()
197
- if not question or not question.strip():
198
- return "", "", "", f"{ONLINE_BADGE} Ready."
199
-
200
- # Retrieval
201
- top_k = int(cfg["retrieval"]["top_k"])
202
- shown = int(cfg["retrieval"]["evidence_shown"])
203
- use_n = int(cfg["generator"]["use_top_evidence"])
204
- if mode == "quick":
205
- shown = min(3, shown)
206
- use_n = min(3, use_n)
207
-
208
- ctx = retrieve(question, top_k=top_k)
209
-
210
- # Prepare evidence panel (hidden when shown == 0)
211
- if shown > 0:
212
- ev_md_lines = []
213
- for i, c in enumerate(ctx[:shown], 1):
214
- title = c["citation"]
215
- pg = f" (p.{c['page']})" if c["page"] else ""
216
- body = c["text"].strip()
217
- body_short = body if len(body) <= 1200 else body[:1200] + "..."
218
- ev_md_lines.append(f"**[{i}] {title}**\n\n{body_short}\n")
219
- evidence_md = "\n---\n".join(ev_md_lines)
220
- else:
221
- evidence_md = ""
222
- # Decide if we should generate
223
- answer = ""
224
- sources_md = ""
225
- conf = float(ctx[0]["score"]) if ctx else 0.0
226
- threshold = float(cfg["retrieval"].get("answerability_threshold", 0.01))
227
-
228
- if not ctx:
229
- status = f"{ONLINE_BADGE} No evidence found."
230
- return evidence_md, answer, sources_md, status
231
-
232
- if do_generate and conf >= threshold:
233
- draft = generate_answer(
234
- question=question,
235
- ctx=ctx,
236
- use_n=use_n,
237
- temp=float(cfg["generator"]["temperature"]),
238
- max_sentences=int(cfg["generator"]["max_answer_sentences"])
239
- )
240
- # Validate citations
241
- # if not _citations_valid(draft, min(use_n, len(ctx))):
242
- # answer = "_Not enough evidence to generate a reliable summary. See Evidence below._"
243
- # else:
244
- # answer = draft
245
-
246
- if not _citations_valid(draft, min(use_n, len(ctx))):
247
- extractive, _ = answer_extractive(question, k=use_n, per_chunk_sents=2)
248
- answer = extractive
249
- else:
250
- answer = draft
251
-
252
-
253
-
254
- elif do_generate and conf < threshold:
255
- answer = "_Not enough evidence—see Evidence below._"
256
-
257
- # Sources list
258
- src_lines = [f"[{i}] {c['citation']}" for i, c in enumerate(ctx[:use_n], 1)]
259
- sources_md = "Sources:\n" + "\n".join(src_lines)
260
- if answer:
261
- a = answer.strip()
262
- if not a.lower().startswith("answer:"):
263
- answer = f"Answer: {a}"
264
-
265
- dur = time.time() - start
266
- status = f"{ONLINE_BADGE} Done in {dur:.1f}s (conf={conf:.2f})."
267
- return evidence_md, answer, sources_md, status
268
-
269
- def save_feedback(question, rating, note, answer_shown):
270
- fpath = LOGS_DIR / "feedback.csv"
271
- new = not fpath.exists()
272
- with fpath.open("a", newline="", encoding="utf-8") as f:
273
- w = csv.writer(f)
274
- if new:
275
- w.writerow(["timestamp","question","rating","note","answer_shown"])
276
- w.writerow([time.strftime("%Y-%m-%d %H:%M:%S"), question, rating, note, "yes" if answer_shown else "no"])
277
- return "Feedback saved. Thank you!"
278
-
279
- APP_CSS = """
280
- :root{
281
- --app-font: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
282
- "Apple Color Emoji","Segoe UI Emoji";
283
- }
284
- body, .gradio-container { font-family: var(--app-font) !important; }
285
- /* make reading nicer */
286
- .gr-markdown { font-size: 16px; line-height: 1.6; }
287
- .gr-markdown h2 { font-size: 18px; margin-top: 0.6rem; }
288
- .gr-textbox textarea { font-size: 16px; }
289
- """
290
-
291
- with gr.Blocks(title="Clinician Q&A", theme="soft", css=APP_CSS) as demo:
292
- gr.Markdown(f"## Clinician Q&A {'&nbsp;&nbsp;'+ONLINE_BADGE if ONLINE_BADGE else ''}")
293
- with gr.Row():
294
- with gr.Column(scale=1):
295
- q = gr.Textbox(label="Ask a question", placeholder="e.g., When can confidentiality be broken?")
296
- do_gen = gr.Checkbox(value=cfg["generator"]["enabled_default"], label="Use LLM")
297
- mode = gr.Radio(choices=["standard","quick"], value=cfg["ui"].get("performance_mode","standard"), label="Performance mode")
298
- run = gr.Button("Answer", variant="primary")
299
- rating = gr.Radio(choices=["Helpful","Not sure","Incorrect"], label="Feedback", value=None)
300
- note = gr.Textbox(label="Add a note (optional)")
301
- submit = gr.Button("Submit feedback")
302
- status = gr.Markdown("Ready.")
303
- with gr.Column(scale=1):
304
- ans = gr.Markdown(label="Answer")
305
- ev = gr.Markdown(label="Evidence")
306
- src = gr.Markdown(label="Sources")
307
-
308
- run.click(app_infer, inputs=[q, do_gen, mode], outputs=[ ans,ev,src, status])
309
- submit.click(lambda question, r, n, a: save_feedback(question, r, n, bool(a and a.strip())),
310
- inputs=[q, rating, note, ans], outputs=[status])
311
-
312
- # if __name__ == "__main__":
313
- # # Bind to localhost only; opens a browser tab automatically.
314
- # demo.launch(server_name="127.0.0.1", server_port=7860, inbrowser=True, show_error=True)
315
-
316
- if __name__ == "__main__":
317
- # In cloud (HF Spaces), bind to 0.0.0.0 and respect PORT if provided.
318
- port = int(os.getenv("PORT", "7860"))
319
- host = "0.0.0.0"
320
- demo.queue(max_size=32).launch(server_name=host, server_port=port, show_error=True)
321
-
 
1
+ # app/app.py
2
+ import os, sys, time, csv, re
3
+ from pathlib import Path
4
+ import yaml
5
+ import pandas as pd
6
+ import numpy as np
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+ ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
12
+ print("ANTHROPIC in env:", bool(os.getenv("ANTHROPIC_API_KEY")), flush=True)
13
+
14
+ if not ANTHROPIC_API_KEY:
15
+ raise SystemExit("Missing ANTHROPIC_API_KEY in environment or .env file.")
16
+
17
+
18
+ # os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
19
+ # os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
20
+
21
+ # --- Resolve paths (works both as .py and PyInstaller .exe)
22
+ APP_DIR = Path(__file__).resolve().parent # .../app
23
+ if hasattr(sys, "_MEIPASS"):
24
+ ROOT = Path(sys._MEIPASS)
25
+ else:
26
+ ROOT = APP_DIR.parent
27
+
28
+ CFG_PATH = ROOT / "app" / "config" / "app.yaml"
29
+ MODELS_DIR = ROOT / "models"
30
+ INDEX_DIR = ROOT / "outputs" / "index"
31
+ LOGS_DIR = ROOT / "local_logs"
32
+ DOCS_DIR = ROOT / "docs"
33
+
34
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
35
+
36
+ # --- Read config
37
+ DEFAULT_CFG = {
38
+ "retrieval": {"top_k": 12, "evidence_shown": 3, "answerability_threshold": 0.2},
39
+ "generator": {
40
+ "enabled_default": False,
41
+ "use_top_evidence": 5,
42
+ "temperature": 0.1,
43
+ "max_answer_sentences": 20,
44
+ "n_ctx": 4096,
45
+ "threads": max(2, (os.cpu_count() or 4) - 1)
46
+ },
47
+ "models": {
48
+ "embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
49
+ "embedding_local_dir": None,
50
+ "anthropic_model": "claude-3-5-sonnet-latest"
51
+ },
52
+ "ui": {"show_online_badge": True, "performance_mode": "standard"} # quick|standard
53
+ }
54
+ cfg = DEFAULT_CFG
55
+ if CFG_PATH.exists():
56
+ cfg = {**cfg, **yaml.safe_load(CFG_PATH.read_text(encoding="utf-8"))}
57
+
58
+ # --- Load FAISS index & embeddings
59
+ import faiss
60
+ from sentence_transformers import SentenceTransformer
61
+
62
+ META_PATH = INDEX_DIR / "meta.parquet"
63
+ FAISS_PATH = INDEX_DIR / "chunks.faiss"
64
+
65
+ if not META_PATH.exists() or not FAISS_PATH.exists():
66
+ raise SystemExit("Index not found. Ensure outputs/index/meta.parquet and chunks.faiss exist.")
67
+
68
+ df = pd.read_parquet(META_PATH)
69
+ df["text"] = df["text"].fillna("").astype(str)
70
+
71
+ #loading sentence transformer
72
+ emb_dir = cfg["models"].get("embedding_local_dir")
73
+ if emb_dir:
74
+ EMBED_MODEL_PATH = Path(emb_dir)
75
+ if not EMBED_MODEL_PATH.exists():
76
+ raise SystemExit(f"Embedding model folder not found: {EMBED_MODEL_PATH}")
77
+ embed_model = SentenceTransformer(str(EMBED_MODEL_PATH), trust_remote_code=False)
78
+ else:
79
+
80
+ embed_model = SentenceTransformer(cfg["models"]["embedding_model"], trust_remote_code=False)
81
+
82
+ index = faiss.read_index(str(FAISS_PATH))
83
+
84
+ def _format_citation(row):
85
+ p = int(row["page"]) if pd.notna(row.get("page")) else None
86
+ return f"{row['title']} (p.{p})" if p else f"{row['title']}"
87
+
88
+ def retrieve(query, top_k=6):
89
+ qv = embed_model.encode([query], convert_to_numpy=True, normalize_embeddings=True).astype("float32")
90
+ scores, idxs = index.search(qv, top_k)
91
+ out = []
92
+ for s, ix in zip(scores[0], idxs[0]):
93
+ r = df.iloc[int(ix)]
94
+ out.append({
95
+ "score": float(s),
96
+ "citation": _format_citation(r),
97
+ "doc_id": r.get("doc_id", ""),
98
+ "page": None if pd.isna(r.get("page")) else int(r["page"]),
99
+ "chunk_id": int(r["chunk_id"]),
100
+ "text": r["text"]
101
+ })
102
+ return out
103
+
104
+ # Anthropic (Claude) LLM
105
+ from anthropic import Anthropic, APIError
106
+
107
+ ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY")
108
+ if not ANTHROPIC_API_KEY:
109
+ raise SystemExit("Missing ANTHROPIC_API_KEY environment variable.")
110
+
111
+ anthropic_client = Anthropic(api_key=ANTHROPIC_API_KEY)
112
+ CLAUDE_MODEL = cfg["models"].get("anthropic_model", "claude-3-5-sonnet-latest")
113
+
114
+ SYSTEM_PROMPT = (
115
+ "You are a careful assistant for clinicians. "
116
+ "Use ONLY the provided context to answer. "
117
+ "Be concise. Add inline citations like [1], [2] matching the numbered context. "
118
+ "If the context does not fully answer, provide the best supported guidance you can, and point to the closest relevant passages with citations"
119
+ )
120
+
121
+ def _citations_valid(text, k):
122
+ nums = set(int(n) for n in re.findall(r"\[(\d+)\]", text))
123
+ return bool(nums) and all(1 <= n <= k for n in nums)
124
+
125
+ def _join_cites(nums):
126
+ nums = [f"[{n}]" for n in nums]
127
+ if not nums:
128
+ return ""
129
+ if len(nums) == 1:
130
+ return nums[0]
131
+ return ", ".join(nums[:-1]) + " and " + nums[-1]
132
+
133
+ def _make_context_block(ctx, use_n):
134
+ blocks = []
135
+ for i, c in enumerate(ctx[:use_n], 1):
136
+ blocks.append(f"[{i}] {c['citation']}\n{c['text']}\n")
137
+ return "\n".join(blocks)
138
+
139
+ def generate_answer(question, ctx, use_n, temp=0.1, max_sentences=6):
140
+ context_text = _make_context_block(ctx, use_n)
141
+ user_prompt = (
142
+ f"Context:\n\n{context_text}\n\n"
143
+ f"Question: {question}\n"
144
+ "Answer using ONLY the context above and cite with [1], [2], etc."
145
+ )
146
+ try:
147
+ resp = anthropic_client.messages.create(
148
+ model=CLAUDE_MODEL,
149
+ system=SYSTEM_PROMPT,
150
+ max_tokens=600,
151
+ temperature=float(temp),
152
+ messages=[{"role": "user", "content": [{"type": "text", "text": user_prompt}]}],
153
+ )
154
+ except APIError as e:
155
+ return f"_API error from Anthropic: {e}_"
156
+
157
+ # Claude returns a list of content blocks
158
+ parts = []
159
+ for blk in resp.content:
160
+ if blk.type == "text":
161
+ parts.append(blk.text)
162
+ full = ("\n".join(parts)).strip()
163
+
164
+ # # Log raw model output to console for debugging
165
+ # print("\n===== RAW MODEL OUTPUT =====\n", full, "\n============================\n", flush=True)
166
+
167
+ # Trim to ~N sentences to keep it short for testers
168
+ sents = re.split(r'(?<=[.!?])\s+', full)
169
+ short = " ".join(sents[:max_sentences]).strip()
170
+ return short
171
+
172
+ # gradio
173
+ import gradio as gr
174
+ ONLINE_BADGE = "Standards of Practice & Code of Ethics" if cfg["ui"].get("show_online_badge", True) else ""
175
+ def _top_sentences(text, n=3):
176
+ sents = re.split(r'(?<=[.!?])\s+', text.strip())
177
+ return [s for s in sents if s][:n]
178
+
179
+ def answer_extractive(query, k=6, per_chunk_sents=2):
180
+ ctx = retrieve(query, top_k=k)
181
+ bullets, refs = [], []
182
+ for i, c in enumerate(ctx, 1):
183
+ for s in _top_sentences(c["text"], per_chunk_sents):
184
+ bullets.append(f"- {s} [{i}]")
185
+ refs.append(f"[{i}] {c['citation']}")
186
+ if not bullets:
187
+ return "I couldn’t find relevant text in the corpus.", refs
188
+ return "\n".join(bullets) + "\n\nSources:\n" + "\n".join(refs), refs
189
+
190
+ def app_infer(question, do_generate, mode):
191
+ start = time.time()
192
+ if not question or not question.strip():
193
+ return "", "", "", f"{ONLINE_BADGE} Ready."
194
+
195
+ # Retrieval
196
+ top_k = int(cfg["retrieval"]["top_k"])
197
+ shown = int(cfg["retrieval"]["evidence_shown"])
198
+ use_n = int(cfg["generator"]["use_top_evidence"])
199
+ if mode == "quick":
200
+ shown = min(3, shown)
201
+ use_n = min(3, use_n)
202
+
203
+ ctx = retrieve(question, top_k=top_k)
204
+
205
+ # Prepare evidence panel (currently hidden as shown == 0)
206
+ if shown > 0:
207
+ ev_md_lines = []
208
+ for i, c in enumerate(ctx[:shown], 1):
209
+ title = c["citation"]
210
+ pg = f" (p.{c['page']})" if c["page"] else ""
211
+ body = c["text"].strip()
212
+ body_short = body if len(body) <= 1200 else body[:1200] + "..."
213
+ ev_md_lines.append(f"**[{i}] {title}**\n\n{body_short}\n")
214
+ evidence_md = "\n---\n".join(ev_md_lines)
215
+ else:
216
+ evidence_md = ""
217
+ # Decide if we should generate?
218
+ answer = ""
219
+ sources_md = ""
220
+ conf = float(ctx[0]["score"]) if ctx else 0.0
221
+ threshold = float(cfg["retrieval"].get("answerability_threshold", 0.01))
222
+
223
+ if not ctx:
224
+ status = f"{ONLINE_BADGE} No evidence found."
225
+ return evidence_md, answer, sources_md, status
226
+
227
+ if do_generate and conf >= threshold:
228
+ draft = generate_answer(
229
+ question=question,
230
+ ctx=ctx,
231
+ use_n=use_n,
232
+ temp=float(cfg["generator"]["temperature"]),
233
+ max_sentences=int(cfg["generator"]["max_answer_sentences"])
234
+ )
235
+ # Validate citations
236
+ # if not _citations_valid(draft, min(use_n, len(ctx))):
237
+ # answer = "_Not enough evidence to generate a reliable summary. See Evidence below._"
238
+ # else:
239
+ # answer = draft
240
+
241
+ if not _citations_valid(draft, min(use_n, len(ctx))):
242
+ extractive, _ = answer_extractive(question, k=use_n, per_chunk_sents=2)
243
+ answer = extractive
244
+ else:
245
+ answer = draft
246
+
247
+
248
+
249
+ elif do_generate and conf < threshold:
250
+ answer = "_Not enough evidence—see Evidence below._"
251
+
252
+ # Sources list
253
+ src_lines = [f"[{i}] {c['citation']}" for i, c in enumerate(ctx[:use_n], 1)]
254
+ sources_md = "Sources:\n" + "\n".join(src_lines)
255
+ if answer:
256
+ a = answer.strip()
257
+ if not a.lower().startswith("answer:"):
258
+ answer = f"Answer: {a}"
259
+
260
+ dur = time.time() - start
261
+ status = f"{ONLINE_BADGE} Done in {dur:.1f}s (conf={conf:.2f})."
262
+ return evidence_md, answer, sources_md, status
263
+
264
+ def save_feedback(question, rating, note, answer_shown):
265
+ fpath = LOGS_DIR / "feedback.csv"
266
+ new = not fpath.exists()
267
+ with fpath.open("a", newline="", encoding="utf-8") as f:
268
+ w = csv.writer(f)
269
+ if new:
270
+ w.writerow(["timestamp","question","rating","note","answer_shown"])
271
+ w.writerow([time.strftime("%Y-%m-%d %H:%M:%S"), question, rating, note, "yes" if answer_shown else "no"])
272
+ return "Feedback saved. Thank you!"
273
+
274
+ APP_CSS = """
275
+ :root{
276
+ --app-font: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
277
+ "Apple Color Emoji","Segoe UI Emoji";
278
+ }
279
+ body, .gradio-container { font-family: var(--app-font) !important; }
280
+ /* make reading nicer */
281
+ .gr-markdown { font-size: 16px; line-height: 1.6; }
282
+ .gr-markdown h2 { font-size: 18px; margin-top: 0.6rem; }
283
+ .gr-textbox textarea { font-size: 16px; }
284
+ """
285
+
286
+ with gr.Blocks(title="Clinician Q&A", theme="soft", css=APP_CSS) as demo:
287
+ gr.Markdown(f"## Clinician Q&A {'&nbsp;&nbsp;'+ONLINE_BADGE if ONLINE_BADGE else ''}")
288
+ with gr.Row():
289
+ with gr.Column(scale=1):
290
+ q = gr.Textbox(label="Ask a question", placeholder="e.g., When can confidentiality be broken?")
291
+ do_gen = gr.Checkbox(value=cfg["generator"]["enabled_default"], label="Use LLM")
292
+ mode = gr.Radio(choices=["standard","quick"], value=cfg["ui"].get("performance_mode","standard"), label="Performance mode")
293
+ run = gr.Button("Answer", variant="primary")
294
+ rating = gr.Radio(choices=["Helpful","Not sure","Incorrect"], label="Feedback", value=None)
295
+ note = gr.Textbox(label="Add a note (optional)")
296
+ submit = gr.Button("Submit feedback")
297
+ status = gr.Markdown("Ready.")
298
+ with gr.Column(scale=1):
299
+ ans = gr.Markdown(label="Answer")
300
+ ev = gr.Markdown(label="Evidence")
301
+ src = gr.Markdown(label="Sources")
302
+
303
+ run.click(app_infer, inputs=[q, do_gen, mode], outputs=[ ans,ev,src, status])
304
+ submit.click(lambda question, r, n, a: save_feedback(question, r, n, bool(a and a.strip())),
305
+ inputs=[q, rating, note, ans], outputs=[status])
306
+
307
+ # if __name__ == "__main__":
308
+ # # Bind to localhost only; opens a browser tab automatically.
309
+ # demo.launch(server_name="127.0.0.1", server_port=7860, inbrowser=True, show_error=True)
310
+
311
+ if __name__ == "__main__":
312
+ # In cloud (HF Spaces), bind to 0.0.0.0 and respect PORT if provided.
313
+ port = int(os.getenv("PORT", "7860"))
314
+ host = "0.0.0.0"
315
+ demo.queue(max_size=32).launch(server_name=host, server_port=port, show_error=True)
316
+