stevafernandes commited on
Commit
d19f25f
·
verified ·
1 Parent(s): 5f46d74

Upload 4 files

Browse files
Files changed (4) hide show
  1. app.py +157 -82
  2. knowledge_base.py +320 -0
  3. packages.txt +2 -0
  4. requirements.txt +7 -3
app.py CHANGED
@@ -1,36 +1,36 @@
1
  """
2
- P3 Myeloma - AI Follow-up Chatbot
3
- ==================================
4
  Takes a participant's answers to the 16 Shared Decision Making questions
5
  and runs an adaptive follow-up conversation using Google's Gemini API.
 
 
 
 
 
 
 
 
 
6
  Deploy on Hugging Face Spaces (SDK: Gradio).
7
- Set your Gemini key as a Space Secret named GEMINI_API_KEY,
8
- or paste it in the UI field at runtime.
9
-
10
- NOTE ON HARDWARE:
11
- This app only calls the Gemini API and does NOT need a GPU. The recommended
12
- fix for "No @spaces.GPU function detected during startup" is to switch the
13
- Space hardware to **CPU basic** (Settings -> Hardware). If the Space must stay
14
- on ZeroGPU, the small `spaces.GPU` stub below satisfies the startup check.
15
  """
16
 
17
  import os
18
  import json
19
 
20
  # ----------------------------------------------------------------------
21
- # ZeroGPU compatibility shim.
22
- # On ZeroGPU hardware the runtime requires at least one @spaces.GPU function
23
- # to exist at startup, otherwise it raises:
24
- # "No @spaces.GPU function detected during startup"
25
- # This app is API-only (no GPU work), so we register a trivial stub.
26
- # If `spaces` isn't installed (e.g. CPU basic), we skip it silently.
27
  # ----------------------------------------------------------------------
28
  try:
29
  import spaces
30
 
31
  @spaces.GPU
32
  def _zerogpu_warmup():
33
- """No-op to satisfy the ZeroGPU startup check. Not used at runtime."""
34
  return None
35
  except Exception:
36
  pass
@@ -39,6 +39,8 @@ from google import genai
39
  from google.genai import types
40
  import gradio as gr
41
 
 
 
42
  # ----------------------------------------------------------------------
43
  # The 16 profile questions (verbatim from the intake form)
44
  # ----------------------------------------------------------------------
@@ -87,6 +89,27 @@ MAX_FOLLOWUPS = 8 # cap the adaptive conversation
87
  # The conversation always opens with this fixed question from the patient.
88
  FIRST_QUESTION = "Using my profile, what are the best treatment options for my myeloma?"
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  # ----------------------------------------------------------------------
91
  # Prompt building
92
  # ----------------------------------------------------------------------
@@ -103,28 +126,20 @@ def detect_tensions(answers):
103
  a = {i: (answers[i].strip().lower() == "yes") for i in range(len(answers))}
104
  flags = []
105
 
106
- # Aggressive cure-seeking vs quality-over-quantity
107
  if (a.get(5) or a.get(6) or a.get(7)) and a.get(11):
108
  flags.append("Wants an aggressive/curative approach but also values quality of life over quantity. Worth clarifying how they weigh these when they conflict.")
109
- # Willing to endure side effects vs wants active lifestyle / quality of life
110
  if a.get(7) and (a.get(14) or a.get(11)):
111
  flags.append("Willing to endure many side effects, yet wants to stay active / prioritizes quality of life. Probe acceptable side-effect threshold.")
112
- # Lives alone AND no strong social support -> logistics risk
113
  if a.get(3) and not a.get(4):
114
  flags.append("Lives alone with no caregiver and limited social support. Treatment logistics and safety monitoring need exploration.")
115
- # Lives alone but prefers home meds -> safety of self-administration
116
  if a.get(3) and a.get(9):
117
  flags.append("Lives alone but prefers taking medications at home. Explore support for safe self-administration and side-effect monitoring.")
118
- # Defers fully to team but also wants to collaborate / understand options
119
  if a.get(2) and (a.get(0) or a.get(1)):
120
  flags.append("Says they leave decisions to the care team, yet also wants to collaborate / understand options. Clarify how involved they actually want to be.")
121
- # Cost matters + trial interest (trials can offset cost) — opportunity, not conflict
122
  if a.get(13) and a.get(12):
123
  flags.append("Cost matters and they're open to clinical trials — trials may reduce drug cost; worth surfacing.")
124
- # Fewer pills preference but aggressive
125
  if a.get(10) and (a.get(6) or a.get(7)):
126
  flags.append("Prefers the fewest pills possible but wants an aggressive approach. Explore tolerance for treatment intensity vs convenience.")
127
- # Outpatient + lives alone
128
  if a.get(8) and a.get(3):
129
  flags.append("Prefers outpatient treatment but lives alone — explore transport and post-visit support.")
130
  return flags
@@ -135,33 +150,49 @@ def system_instruction():
135
  "You are a warm, plain-spoken health navigator helping a multiple myeloma patient "
136
  "(relapsed or refractory) prepare for a shared decision-making conversation with their "
137
  "care team. You are NOT a doctor and you never give medical advice, diagnoses, dosing, or "
138
- "treatment recommendations. Your job is ONLY to ask thoughtful follow-up questions that help "
139
- "the patient clarify their own values, priorities, constraints, and concerns.\n\n"
140
- "RULES:\n"
141
- "1. Ask exactly ONE question per turn. Keep it to 1-3 sentences, conversational, jargon-free.\n"
142
- "2. Base each question on the patient's profile and their previous answers. Prioritize the "
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  "FLAGGED TENSIONS provided — gently explore apparent contradictions without judgment.\n"
144
  "3. Do not repeat questions already asked. Build on what they say.\n"
145
- "4. Never recommend or rank treatments. If the patient asks for medical advice, kindly "
146
- "redirect them to discuss it with their care team, then continue with a follow-up question.\n"
 
147
  "5. Tone: empathetic, respectful, never alarming.\n"
148
  "6. TREATMENT-OPTIONS QUESTION: When the patient asks what the best treatment options are for "
149
  "their myeloma (e.g. 'Using my profile, what are the best treatment options for my myeloma?'), "
150
- "your answer must be grounded STRICTLY in the 16 yes/no profile answers provided to you. Do "
151
  "this:\n"
152
  " - Open with one warm sentence.\n"
153
- " - Then walk through how THEIR specific stated priorities map to the kinds of treatment "
154
- "approaches commonly discussed for relapsed/refractory myeloma. Tie each point explicitly back "
155
- "to their answers (for example: a preference for taking medications at home and the fewest pills "
156
- "points toward asking the team about convenient outpatient or oral regimens; openness to clinical "
157
- "trials means trials are worth raising; willingness to endure side effects and wanting an "
158
- "aggressive approach points toward asking about more intensive options; prioritizing quality of "
159
- "life points toward asking about regimens that protect daily functioning; living alone with "
160
- "limited support points toward asking about logistics and monitoring).\n"
161
- " - Do NOT invent clinical details, drug names beyond broad well-known categories, dosing, or "
162
- "anything not implied by their answers. Do NOT rank, prescribe, or state which option is "
163
- "medically best. Frame everything as 'based on what you told us, here are options and questions "
164
- "to raise with your care team.'\n"
 
165
  " - End by reminding them their care team must confirm what is medically appropriate, then ask "
166
  "your first single follow-up question drawn from their profile and the flagged tensions.\n"
167
  "7. When you judge that you have enough to summarize their priorities (or after several "
@@ -177,7 +208,7 @@ def system_instruction():
177
  def get_client(user_key):
178
  key = (user_key or "").strip() or os.environ.get("GEMINI_API_KEY", "").strip()
179
  if not key:
180
- return None, "No API key found. Paste your Gemini API key above, or set GEMINI_API_KEY as a Space secret."
181
  try:
182
  client = genai.Client(api_key=key)
183
  return client, None
@@ -195,16 +226,54 @@ def to_gemini_contents(history):
195
  return contents
196
 
197
 
 
 
 
 
 
 
 
198
  def gemini_reply(client, convo, n_followups):
199
- """convo: internal history list. Returns assistant text."""
 
 
 
 
 
 
 
 
 
 
 
 
200
  contents = to_gemini_contents(convo)
201
- # Nudge toward summarizing as we approach the cap
202
- extra = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  if n_followups >= MAX_FOLLOWUPS - 1:
204
- extra = "\n\nYou have asked enough questions. Provide the final <<SUMMARY>> now."
 
205
  cfg = types.GenerateContentConfig(
206
- system_instruction=system_instruction() + extra,
207
- temperature=0.7,
208
  max_output_tokens=2048,
209
  )
210
  resp = client.models.generate_content(model=MODEL, contents=contents, config=cfg)
@@ -226,12 +295,16 @@ def start_chat(api_key, *radio_values):
226
  gr.Warning(err)
227
  return (gr.update(), [], [], 0, gr.update(visible=True), gr.update(visible=False))
228
 
 
 
 
 
 
 
229
  profile = build_profile_text(answers)
230
  flags = detect_tensions(answers)
231
  flag_text = "\n".join(f"- {f}" for f in flags) if flags else "- (No obvious contradictions; explore their highest-stakes priorities.)"
232
 
233
- # Seed the conversation with the profile as the first user turn so every later
234
- # turn (including when the user pastes the sample question) can be grounded in it.
235
  seed = (
236
  "Here is the patient's completed profile (16 yes/no answers):\n\n"
237
  f"{profile}\n\n"
@@ -251,12 +324,12 @@ def start_chat(api_key, *radio_values):
251
  convo.append({"role": "assistant", "content": reply})
252
  chat_display = [{"role": "assistant", "content": reply}]
253
  return (
254
- chat_display, # chatbot
255
- chat_display, # display_state
256
- convo, # convo_state
257
- 0, # followup count (no follow-up asked yet)
258
- gr.update(visible=False), # hide intake
259
- gr.update(visible=True), # show chat
260
  )
261
 
262
 
@@ -270,6 +343,8 @@ def respond(user_msg, chat_display, convo, n_followups, api_key):
270
  gr.Warning(err)
271
  return chat_display, chat_display, convo, n_followups, user_msg
272
 
 
 
273
  convo = convo + [{"role": "user", "content": user_msg}]
274
  chat_display = chat_display + [{"role": "user", "content": user_msg}]
275
 
@@ -296,19 +371,18 @@ def respond(user_msg, chat_display, convo, n_followups, api_key):
296
  def reset_all():
297
  radio_resets = [gr.update(value=None) for _ in QUESTIONS]
298
  return (
299
- [], # chatbot
300
- [], # display_state
301
- [], # convo_state
302
- 0, # followups
303
- gr.update(visible=True), # intake
304
- gr.update(visible=False), # chat
305
  *radio_resets,
306
  )
307
 
308
 
309
  CSS = """
310
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
311
- /* ---- Base: white background, black text, professional font ---- */
312
  .gradio-container, .gradio-container * {
313
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif !important;
314
  }
@@ -322,7 +396,6 @@ body, .gradio-container .prose, .gradio-container p,
322
  .gradio-container span, .gradio-container label {
323
  color: #111111 !important;
324
  }
325
- /* ---- Header ---- */
326
  #app-header {
327
  border-bottom: 1px solid #e6e6e6;
328
  padding: 4px 0 18px 0;
@@ -341,7 +414,6 @@ body, .gradio-container .prose, .gradio-container p,
341
  color: #5a5a5a;
342
  margin: 4px 0 0 0;
343
  }
344
- /* ---- Question cards ---- */
345
  .q-card {
346
  border: 1px solid #e2e2e2 !important;
347
  border-radius: 8px !important;
@@ -354,12 +426,10 @@ body, .gradio-container .prose, .gradio-container p,
354
  color: #111111 !important;
355
  font-weight: 500 !important;
356
  }
357
- /* ---- Radio choices ---- */
358
  .gradio-container input[type="radio"] + span,
359
  .gradio-container .wrap label {
360
  color: #111111 !important;
361
  }
362
- /* ---- Buttons ---- */
363
  button.primary, .gradio-container button.primary {
364
  background: #7a0c2e !important;
365
  color: #ffffff !important;
@@ -377,7 +447,6 @@ button.secondary, .gradio-container button.secondary {
377
  border-radius: 6px !important;
378
  font-weight: 600 !important;
379
  }
380
- /* ---- Chatbot ---- */
381
  .gradio-container .chatbot, .gradio-container [class*="chatbot"] {
382
  background: #ffffff !important;
383
  border: 1px solid #e2e2e2 !important;
@@ -386,7 +455,6 @@ button.secondary, .gradio-container button.secondary {
386
  .gradio-container .message.bot, .gradio-container .message.user {
387
  color: #111111 !important;
388
  }
389
- /* ---- Inputs ---- */
390
  .gradio-container textarea, .gradio-container input[type="text"],
391
  .gradio-container input[type="password"] {
392
  background: #ffffff !important;
@@ -394,11 +462,9 @@ button.secondary, .gradio-container button.secondary {
394
  border: 1px solid #cfcfcf !important;
395
  border-radius: 6px !important;
396
  }
397
- /* ---- Accordion ---- */
398
  .gradio-container .label-wrap, .gradio-container details summary {
399
  color: #111111 !important;
400
  }
401
- /* ---- Sample question hint ---- */
402
  .sample-q {
403
  background: #f7f7f7 !important;
404
  border: 1px solid #e2e2e2 !important;
@@ -439,14 +505,11 @@ with gr.Blocks(title="P3 Myeloma Follow-up Chatbot", css=CSS,
439
  "<p id='app-subtitle'>Follow-up Questions for Shared Decision Making</p>"
440
  )
441
 
442
- # API key is read from the Space secret GEMINI_API_KEY (hidden, not user-facing).
443
  api_key = gr.State("")
444
-
445
  convo_state = gr.State([])
446
  display_state = gr.State([])
447
  followups = gr.State(0)
448
 
449
- # ---- Intake panel ----
450
  with gr.Group(visible=True) as intake_panel:
451
  gr.Markdown("**Answer all 16 questions, then click Start Follow-up.**")
452
  radios = []
@@ -456,7 +519,6 @@ with gr.Blocks(title="P3 Myeloma Follow-up Chatbot", css=CSS,
456
  radios.append(r)
457
  start_btn = gr.Button("Start Follow-up", variant="primary")
458
 
459
- # ---- Chat panel ----
460
  with gr.Group(visible=False) as chat_panel:
461
  chatbot = gr.Chatbot(label="Follow-up Conversation", type="messages", height=460)
462
  gr.HTML(
@@ -475,7 +537,6 @@ with gr.Blocks(title="P3 Myeloma Follow-up Chatbot", css=CSS,
475
  inputs=[api_key] + radios,
476
  outputs=[chatbot, display_state, convo_state, followups, intake_panel, chat_panel],
477
  )
478
-
479
  send_btn.click(
480
  respond,
481
  inputs=[msg, display_state, convo_state, followups, api_key],
@@ -486,7 +547,6 @@ with gr.Blocks(title="P3 Myeloma Follow-up Chatbot", css=CSS,
486
  inputs=[msg, display_state, convo_state, followups, api_key],
487
  outputs=[chatbot, display_state, convo_state, followups, msg],
488
  )
489
-
490
  restart_btn.click(
491
  reset_all,
492
  inputs=None,
@@ -494,4 +554,19 @@ with gr.Blocks(title="P3 Myeloma Follow-up Chatbot", css=CSS,
494
  )
495
 
496
  if __name__ == "__main__":
497
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ P3 Myeloma - AI Follow-up Chatbot (knowledge-base grounded)
3
+ ===========================================================
4
  Takes a participant's answers to the 16 Shared Decision Making questions
5
  and runs an adaptive follow-up conversation using Google's Gemini API.
6
+
7
+ GROUNDING:
8
+ General informational questions from the user are answered STRICTLY from the
9
+ documents in the uploaded ZIP corpus (Archive_2.zip) using retrieval-augmented
10
+ generation. At startup the app extracts text from every PDF/PNG in the corpus,
11
+ embeds it with Gemini embeddings, and builds a searchable index. Each user
12
+ question retrieves the most relevant passages, and Gemini is instructed to
13
+ answer only from those passages (or say the answer isn't in the documents).
14
+
15
  Deploy on Hugging Face Spaces (SDK: Gradio).
16
+ - Upload Archive_2.zip to the Space repo root (or set KB_ZIP / KB_DIR).
17
+ - Set your Gemini key as a Space Secret named GEMINI_API_KEY.
18
+ - Recommended packages.txt: poppler-utils, tesseract-ocr (see notes at bottom).
 
 
 
 
 
19
  """
20
 
21
  import os
22
  import json
23
 
24
  # ----------------------------------------------------------------------
25
+ # ZeroGPU compatibility shim (harmless on CPU basic; skipped if not present).
26
+ # Satisfies "No @spaces.GPU function detected during startup" if the Space is
27
+ # on ZeroGPU. This app is API-only and does not use a GPU — CPU basic is fine.
 
 
 
28
  # ----------------------------------------------------------------------
29
  try:
30
  import spaces
31
 
32
  @spaces.GPU
33
  def _zerogpu_warmup():
 
34
  return None
35
  except Exception:
36
  pass
 
39
  from google.genai import types
40
  import gradio as gr
41
 
42
+ from knowledge_base import KnowledgeBase
43
+
44
  # ----------------------------------------------------------------------
45
  # The 16 profile questions (verbatim from the intake form)
46
  # ----------------------------------------------------------------------
 
89
  # The conversation always opens with this fixed question from the patient.
90
  FIRST_QUESTION = "Using my profile, what are the best treatment options for my myeloma?"
91
 
92
+ # ----------------------------------------------------------------------
93
+ # Knowledge base — built once at startup.
94
+ # ----------------------------------------------------------------------
95
+ KB = KnowledgeBase()
96
+ _KB_BUILD_ATTEMPTED = False
97
+
98
+
99
+ def ensure_kb(client):
100
+ """Build the KB on first successful client. Returns (ready, message)."""
101
+ global _KB_BUILD_ATTEMPTED
102
+ if KB.ready:
103
+ return True, ""
104
+ if _KB_BUILD_ATTEMPTED and KB.error:
105
+ return False, KB.error
106
+ _KB_BUILD_ATTEMPTED = True
107
+ KB.build(client)
108
+ if KB.ready:
109
+ return True, ""
110
+ return False, (KB.error or "Knowledge base could not be initialized.")
111
+
112
+
113
  # ----------------------------------------------------------------------
114
  # Prompt building
115
  # ----------------------------------------------------------------------
 
126
  a = {i: (answers[i].strip().lower() == "yes") for i in range(len(answers))}
127
  flags = []
128
 
 
129
  if (a.get(5) or a.get(6) or a.get(7)) and a.get(11):
130
  flags.append("Wants an aggressive/curative approach but also values quality of life over quantity. Worth clarifying how they weigh these when they conflict.")
 
131
  if a.get(7) and (a.get(14) or a.get(11)):
132
  flags.append("Willing to endure many side effects, yet wants to stay active / prioritizes quality of life. Probe acceptable side-effect threshold.")
 
133
  if a.get(3) and not a.get(4):
134
  flags.append("Lives alone with no caregiver and limited social support. Treatment logistics and safety monitoring need exploration.")
 
135
  if a.get(3) and a.get(9):
136
  flags.append("Lives alone but prefers taking medications at home. Explore support for safe self-administration and side-effect monitoring.")
 
137
  if a.get(2) and (a.get(0) or a.get(1)):
138
  flags.append("Says they leave decisions to the care team, yet also wants to collaborate / understand options. Clarify how involved they actually want to be.")
 
139
  if a.get(13) and a.get(12):
140
  flags.append("Cost matters and they're open to clinical trials — trials may reduce drug cost; worth surfacing.")
 
141
  if a.get(10) and (a.get(6) or a.get(7)):
142
  flags.append("Prefers the fewest pills possible but wants an aggressive approach. Explore tolerance for treatment intensity vs convenience.")
 
143
  if a.get(8) and a.get(3):
144
  flags.append("Prefers outpatient treatment but lives alone — explore transport and post-visit support.")
145
  return flags
 
150
  "You are a warm, plain-spoken health navigator helping a multiple myeloma patient "
151
  "(relapsed or refractory) prepare for a shared decision-making conversation with their "
152
  "care team. You are NOT a doctor and you never give medical advice, diagnoses, dosing, or "
153
+ "treatment recommendations. Your job is to (a) ask thoughtful follow-up questions that help "
154
+ "the patient clarify their own values, priorities, constraints, and concerns, and (b) answer "
155
+ "the patient's general informational questions using ONLY the reference documents provided to "
156
+ "you in the RETRIEVED CONTEXT for that turn.\n\n"
157
+ "STRICT GROUNDING RULES (most important):\n"
158
+ "A. For any general/informational question the patient asks (e.g. 'what is CAR T-cell "
159
+ "therapy?', 'what are the side effects of stem cell transplant?', 'what does relapsed mean?'), "
160
+ "you MUST base your answer solely on the text inside the RETRIEVED CONTEXT block for that turn. "
161
+ "Do NOT use outside knowledge, and do NOT add facts that are not present in that context.\n"
162
+ "B. If the RETRIEVED CONTEXT does not contain enough information to answer, say so plainly: "
163
+ "\"I couldn't find that in the reference documents provided for this tool. Please ask your care "
164
+ "team.\" Do not guess or fill gaps from general knowledge.\n"
165
+ "C. When you use information from the context, attribute it naturally to the source document "
166
+ "name(s) shown in the context (e.g. \"According to the NCCN patient guideline ...\").\n"
167
+ "D. Never invent drug names, doses, statistics, or study results that are not in the context.\n\n"
168
+ "CONVERSATION RULES:\n"
169
+ "1. When you ask a follow-up question, ask exactly ONE question per turn, 1-3 sentences, "
170
+ "conversational and jargon-free.\n"
171
+ "2. Base follow-up questions on the patient's profile and previous answers. Prioritize the "
172
  "FLAGGED TENSIONS provided — gently explore apparent contradictions without judgment.\n"
173
  "3. Do not repeat questions already asked. Build on what they say.\n"
174
+ "4. Never recommend or rank treatments. If the patient asks for medical advice or 'what should "
175
+ "I do', kindly redirect them to their care team, then (if appropriate) continue with a follow-up "
176
+ "question.\n"
177
  "5. Tone: empathetic, respectful, never alarming.\n"
178
  "6. TREATMENT-OPTIONS QUESTION: When the patient asks what the best treatment options are for "
179
  "their myeloma (e.g. 'Using my profile, what are the best treatment options for my myeloma?'), "
180
+ "combine TWO sources: their 16 yes/no profile answers AND the RETRIEVED CONTEXT documents. Do "
181
  "this:\n"
182
  " - Open with one warm sentence.\n"
183
+ " - Walk through how THEIR specific stated priorities map to the kinds of treatment approaches "
184
+ "described in the RETRIEVED CONTEXT for relapsed/refractory myeloma. Tie each point explicitly "
185
+ "back to their answers (for example: a preference for medications at home and fewest pills points "
186
+ "toward asking about convenient outpatient or oral regimens; openness to clinical trials means "
187
+ "trials are worth raising; willingness to endure side effects and wanting an aggressive approach "
188
+ "points toward asking about more intensive options; prioritizing quality of life points toward "
189
+ "regimens that protect daily functioning; living alone with limited support points toward "
190
+ "logistics and monitoring).\n"
191
+ " - Only describe treatment approaches that actually appear in the RETRIEVED CONTEXT. Do NOT "
192
+ "invent clinical details, drug names, dosing, or anything not supported by the context or implied "
193
+ "by their answers. Do NOT rank, prescribe, or state which option is medically best. Frame "
194
+ "everything as 'based on what you told us and these reference materials, here are options and "
195
+ "questions to raise with your care team.'\n"
196
  " - End by reminding them their care team must confirm what is medically appropriate, then ask "
197
  "your first single follow-up question drawn from their profile and the flagged tensions.\n"
198
  "7. When you judge that you have enough to summarize their priorities (or after several "
 
208
  def get_client(user_key):
209
  key = (user_key or "").strip() or os.environ.get("GEMINI_API_KEY", "").strip()
210
  if not key:
211
+ return None, "No API key found. Set GEMINI_API_KEY as a Space secret."
212
  try:
213
  client = genai.Client(api_key=key)
214
  return client, None
 
226
  return contents
227
 
228
 
229
+ def _latest_user_query(convo):
230
+ for m in reversed(convo):
231
+ if m["role"] == "user":
232
+ return m["content"]
233
+ return ""
234
+
235
+
236
  def gemini_reply(client, convo, n_followups):
237
+ """convo: internal history list. Retrieves KB context for the latest user
238
+ turn and injects it, then asks Gemini to answer grounded in that context."""
239
+ # Retrieve relevant passages for the most recent user message.
240
+ context_block = ""
241
+ query = _latest_user_query(convo)
242
+ if KB.ready and query.strip():
243
+ try:
244
+ retrieved = KB.retrieve(client, query)
245
+ if retrieved:
246
+ context_block = KB.context_block(retrieved)
247
+ except Exception:
248
+ context_block = ""
249
+
250
  contents = to_gemini_contents(convo)
251
+
252
+ # Build the per-turn system instruction with the retrieved context appended.
253
+ sys = system_instruction()
254
+ if context_block:
255
+ sys += (
256
+ "\n\n==================== RETRIEVED CONTEXT (reference documents) ====================\n"
257
+ "The following passages were retrieved from the uploaded reference documents for THIS "
258
+ "turn. Answer general/informational questions using ONLY these passages. If they do not "
259
+ "contain the answer, say you couldn't find it in the reference documents.\n\n"
260
+ f"{context_block}\n"
261
+ "================================================================================\n"
262
+ )
263
+ else:
264
+ sys += (
265
+ "\n\n[No reference passages were retrieved for this turn. If the patient asked a general "
266
+ "informational question, tell them you couldn't find it in the reference documents and "
267
+ "suggest they ask their care team. You may still ask a values-clarifying follow-up "
268
+ "question or work with their profile.]\n"
269
+ )
270
+
271
  if n_followups >= MAX_FOLLOWUPS - 1:
272
+ sys += "\n\nYou have asked enough questions. Provide the final <<SUMMARY>> now."
273
+
274
  cfg = types.GenerateContentConfig(
275
+ system_instruction=sys,
276
+ temperature=0.3, # lower temp -> stays closer to the sources
277
  max_output_tokens=2048,
278
  )
279
  resp = client.models.generate_content(model=MODEL, contents=contents, config=cfg)
 
295
  gr.Warning(err)
296
  return (gr.update(), [], [], 0, gr.update(visible=True), gr.update(visible=False))
297
 
298
+ # Build the knowledge base on first use (may take a bit on cold start).
299
+ ready, msg = ensure_kb(client)
300
+ if not ready:
301
+ gr.Warning(f"Reference documents unavailable: {msg}")
302
+ # We still allow the conversation, but answers will note missing docs.
303
+
304
  profile = build_profile_text(answers)
305
  flags = detect_tensions(answers)
306
  flag_text = "\n".join(f"- {f}" for f in flags) if flags else "- (No obvious contradictions; explore their highest-stakes priorities.)"
307
 
 
 
308
  seed = (
309
  "Here is the patient's completed profile (16 yes/no answers):\n\n"
310
  f"{profile}\n\n"
 
324
  convo.append({"role": "assistant", "content": reply})
325
  chat_display = [{"role": "assistant", "content": reply}]
326
  return (
327
+ chat_display,
328
+ chat_display,
329
+ convo,
330
+ 0,
331
+ gr.update(visible=False),
332
+ gr.update(visible=True),
333
  )
334
 
335
 
 
343
  gr.Warning(err)
344
  return chat_display, chat_display, convo, n_followups, user_msg
345
 
346
+ ensure_kb(client) # no-op if already built
347
+
348
  convo = convo + [{"role": "user", "content": user_msg}]
349
  chat_display = chat_display + [{"role": "user", "content": user_msg}]
350
 
 
371
  def reset_all():
372
  radio_resets = [gr.update(value=None) for _ in QUESTIONS]
373
  return (
374
+ [],
375
+ [],
376
+ [],
377
+ 0,
378
+ gr.update(visible=True),
379
+ gr.update(visible=False),
380
  *radio_resets,
381
  )
382
 
383
 
384
  CSS = """
385
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
 
386
  .gradio-container, .gradio-container * {
387
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif !important;
388
  }
 
396
  .gradio-container span, .gradio-container label {
397
  color: #111111 !important;
398
  }
 
399
  #app-header {
400
  border-bottom: 1px solid #e6e6e6;
401
  padding: 4px 0 18px 0;
 
414
  color: #5a5a5a;
415
  margin: 4px 0 0 0;
416
  }
 
417
  .q-card {
418
  border: 1px solid #e2e2e2 !important;
419
  border-radius: 8px !important;
 
426
  color: #111111 !important;
427
  font-weight: 500 !important;
428
  }
 
429
  .gradio-container input[type="radio"] + span,
430
  .gradio-container .wrap label {
431
  color: #111111 !important;
432
  }
 
433
  button.primary, .gradio-container button.primary {
434
  background: #7a0c2e !important;
435
  color: #ffffff !important;
 
447
  border-radius: 6px !important;
448
  font-weight: 600 !important;
449
  }
 
450
  .gradio-container .chatbot, .gradio-container [class*="chatbot"] {
451
  background: #ffffff !important;
452
  border: 1px solid #e2e2e2 !important;
 
455
  .gradio-container .message.bot, .gradio-container .message.user {
456
  color: #111111 !important;
457
  }
 
458
  .gradio-container textarea, .gradio-container input[type="text"],
459
  .gradio-container input[type="password"] {
460
  background: #ffffff !important;
 
462
  border: 1px solid #cfcfcf !important;
463
  border-radius: 6px !important;
464
  }
 
465
  .gradio-container .label-wrap, .gradio-container details summary {
466
  color: #111111 !important;
467
  }
 
468
  .sample-q {
469
  background: #f7f7f7 !important;
470
  border: 1px solid #e2e2e2 !important;
 
505
  "<p id='app-subtitle'>Follow-up Questions for Shared Decision Making</p>"
506
  )
507
 
 
508
  api_key = gr.State("")
 
509
  convo_state = gr.State([])
510
  display_state = gr.State([])
511
  followups = gr.State(0)
512
 
 
513
  with gr.Group(visible=True) as intake_panel:
514
  gr.Markdown("**Answer all 16 questions, then click Start Follow-up.**")
515
  radios = []
 
519
  radios.append(r)
520
  start_btn = gr.Button("Start Follow-up", variant="primary")
521
 
 
522
  with gr.Group(visible=False) as chat_panel:
523
  chatbot = gr.Chatbot(label="Follow-up Conversation", type="messages", height=460)
524
  gr.HTML(
 
537
  inputs=[api_key] + radios,
538
  outputs=[chatbot, display_state, convo_state, followups, intake_panel, chat_panel],
539
  )
 
540
  send_btn.click(
541
  respond,
542
  inputs=[msg, display_state, convo_state, followups, api_key],
 
547
  inputs=[msg, display_state, convo_state, followups, api_key],
548
  outputs=[chatbot, display_state, convo_state, followups, msg],
549
  )
 
550
  restart_btn.click(
551
  reset_all,
552
  inputs=None,
 
554
  )
555
 
556
  if __name__ == "__main__":
557
+ demo.launch()
558
+
559
+ # ----------------------------------------------------------------------
560
+ # HUGGING FACE SPACE SETUP NOTES
561
+ # ----------------------------------------------------------------------
562
+ # 1) Files in the Space repo:
563
+ # app.py
564
+ # knowledge_base.py
565
+ # Archive_2.zip <- the uploaded corpus (repo root)
566
+ # requirements.txt <- google-genai, gradio, numpy, pypdf, pdf2image, pytesseract, pillow
567
+ # packages.txt <- poppler-utils, tesseract-ocr (system deps for PDF text + OCR)
568
+ # 2) Space Secret:
569
+ # GEMINI_API_KEY = <your key>
570
+ # 3) Hardware: CPU basic is sufficient (no GPU needed).
571
+ # 4) On first launch the app extracts + embeds the corpus once and caches the
572
+ # index to _kb_index.npz, so subsequent restarts are fast.
knowledge_base.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ knowledge_base.py — Retrieval-Augmented Generation over the uploaded ZIP corpus.
3
+
4
+ At import/startup:
5
+ 1. Locate the corpus (an extracted folder, or a .zip we extract once).
6
+ 2. Extract text from every PDF (poppler pdftotext -> pypdf fallback) and
7
+ OCR any PNG/JPG (optional; skipped gracefully if tesseract is absent).
8
+ 3. Chunk the text, embed all chunks once with Gemini embeddings, cache to disk.
9
+
10
+ At query time:
11
+ - embed the user question, cosine-retrieve top-K chunks, and return them so
12
+ the chat model can answer STRICTLY from that context.
13
+ """
14
+ import os, re, glob, json, hashlib, subprocess, zipfile, shutil, time
15
+ import numpy as np
16
+ from google.genai import types
17
+
18
+ EMBED_MODEL = "gemini-embedding-001"
19
+ EMBED_DIM = 768 # output_dimensionality for the embedding model
20
+ TOP_K = 8 # chunks retrieved per query
21
+ TARGET_CHARS = 1400
22
+ OVERLAP = 200
23
+ MACOSX = "__MACOSX"
24
+
25
+ # Where to look for the corpus, in priority order. On HF Spaces the repo root
26
+ # is the app's working dir, so an uploaded "Archive_2.zip" or a pre-extracted
27
+ # "knowledge_base/" folder both work.
28
+ def _corpus_candidates():
29
+ return [os.environ.get("KB_DIR", "").strip(), "knowledge_base", "kb", "data", "."]
30
+
31
+ def _zip_candidates():
32
+ return [os.environ.get("KB_ZIP", "").strip(), "Archive_2.zip"]
33
+ EXTRACT_DIR = "_kb_extracted"
34
+ CACHE_PATH = "_kb_index.npz"
35
+ MANIFEST_PATH = "_kb_manifest.json"
36
+
37
+
38
+ # ----------------------------------------------------------------------
39
+ # Corpus location / extraction
40
+ # ----------------------------------------------------------------------
41
+ def _has_docs(d):
42
+ if not d or not os.path.isdir(d):
43
+ return False
44
+ for p in glob.glob(os.path.join(d, "**", "*"), recursive=True):
45
+ if MACOSX in p or os.path.basename(p).startswith("._"):
46
+ continue
47
+ if p.lower().endswith((".pdf", ".png", ".jpg", ".jpeg", ".txt", ".md")):
48
+ return True
49
+ return False
50
+
51
+
52
+ def locate_corpus():
53
+ """Return a directory containing the source documents, extracting a zip if needed."""
54
+ for d in _corpus_candidates():
55
+ if _has_docs(d):
56
+ # Avoid treating '.' as corpus if it only contains app files;
57
+ # require at least one PDF for the '.' case.
58
+ if d == "." and not glob.glob("*.pdf"):
59
+ continue
60
+ return d
61
+ for z in _zip_candidates():
62
+ if z and os.path.isfile(z):
63
+ os.makedirs(EXTRACT_DIR, exist_ok=True)
64
+ with zipfile.ZipFile(z) as zf:
65
+ for name in zf.namelist():
66
+ if MACOSX in name or os.path.basename(name).startswith("._"):
67
+ continue
68
+ zf.extract(name, EXTRACT_DIR)
69
+ if _has_docs(EXTRACT_DIR):
70
+ return EXTRACT_DIR
71
+ return None
72
+
73
+
74
+ def list_source_files(kb_dir):
75
+ out = []
76
+ for p in sorted(glob.glob(os.path.join(kb_dir, "**", "*"), recursive=True)):
77
+ if MACOSX in p or os.path.basename(p).startswith("._"):
78
+ continue
79
+ if os.path.isfile(p) and p.lower().endswith((".pdf", ".png", ".jpg", ".jpeg", ".txt", ".md")):
80
+ out.append(p)
81
+ return out
82
+
83
+
84
+ # ----------------------------------------------------------------------
85
+ # Text extraction
86
+ # ----------------------------------------------------------------------
87
+ def _pdftotext(path):
88
+ try:
89
+ r = subprocess.run(["pdftotext", "-layout", path, "-"],
90
+ capture_output=True, timeout=120)
91
+ if r.returncode == 0:
92
+ return r.stdout.decode("utf-8", "ignore")
93
+ except Exception:
94
+ pass
95
+ return ""
96
+
97
+
98
+ def _pypdf_text(path):
99
+ try:
100
+ from pypdf import PdfReader
101
+ reader = PdfReader(path)
102
+ return "\n".join((pg.extract_text() or "") for pg in reader.pages)
103
+ except Exception:
104
+ return ""
105
+
106
+
107
+ def _ocr_image(path):
108
+ try:
109
+ import pytesseract
110
+ from PIL import Image
111
+ return pytesseract.image_to_string(Image.open(path).convert("RGB"))
112
+ except Exception:
113
+ return ""
114
+
115
+
116
+ def _ocr_pdf(path):
117
+ """Rasterize + OCR a scanned/image PDF. Optional; empty if tools missing."""
118
+ try:
119
+ import pytesseract
120
+ from pdf2image import convert_from_path
121
+ text = []
122
+ for img in convert_from_path(path, dpi=200):
123
+ text.append(pytesseract.image_to_string(img))
124
+ return "\n".join(text)
125
+ except Exception:
126
+ return ""
127
+
128
+
129
+ def extract_file_text(path):
130
+ ext = os.path.splitext(path)[1].lower()
131
+ if ext == ".pdf":
132
+ txt = _pdftotext(path)
133
+ if len(txt.strip()) < 50:
134
+ txt = _pypdf_text(path)
135
+ if len(txt.strip()) < 50:
136
+ txt = _ocr_pdf(path) # image-only PDF
137
+ return txt
138
+ if ext in (".png", ".jpg", ".jpeg"):
139
+ return _ocr_image(path)
140
+ if ext in (".txt", ".md"):
141
+ try:
142
+ with open(path, encoding="utf-8", errors="ignore") as fh:
143
+ return fh.read()
144
+ except Exception:
145
+ return ""
146
+ return ""
147
+
148
+
149
+ # ----------------------------------------------------------------------
150
+ # Chunking
151
+ # ----------------------------------------------------------------------
152
+ def clean_text(t):
153
+ t = t.replace("\x00", " ")
154
+ t = re.sub(r"[ \t]+", " ", t)
155
+ t = re.sub(r"\n{3,}", "\n\n", t)
156
+ return t.strip()
157
+
158
+
159
+ def chunk_text(text, source, target_chars=TARGET_CHARS, overlap=OVERLAP):
160
+ text = clean_text(text)
161
+ if not text:
162
+ return []
163
+ chunks, start, n = [], 0, len(text)
164
+ while start < n:
165
+ end = min(start + target_chars, n)
166
+ if end < n:
167
+ window = text[start:end]
168
+ cut = max(window.rfind("\n\n"), window.rfind(". "), window.rfind("\n"))
169
+ if cut > target_chars * 0.5:
170
+ end = start + cut + 1
171
+ chunk = text[start:end].strip()
172
+ if len(chunk) > 40:
173
+ chunks.append({"text": chunk, "source": os.path.basename(source)})
174
+ if end >= n:
175
+ break
176
+ start = max(end - overlap, start + 1)
177
+ return chunks
178
+
179
+
180
+ # ----------------------------------------------------------------------
181
+ # Embedding (Gemini) with batching + disk cache
182
+ # ----------------------------------------------------------------------
183
+ def _l2norm(v):
184
+ v = np.asarray(v, dtype=np.float32)
185
+ nrm = np.linalg.norm(v, axis=-1, keepdims=True)
186
+ nrm[nrm == 0] = 1.0
187
+ return v / nrm
188
+
189
+
190
+ def _embed_batch(client, texts, task_type):
191
+ """Return np.array (len(texts), EMBED_DIM). Falls back to per-item on error."""
192
+ cfg = types.EmbedContentConfig(task_type=task_type, output_dimensionality=EMBED_DIM)
193
+ try:
194
+ resp = client.models.embed_content(model=EMBED_MODEL, contents=texts, config=cfg)
195
+ return np.array([e.values for e in resp.embeddings], dtype=np.float32)
196
+ except Exception:
197
+ vecs = []
198
+ for t in texts:
199
+ resp = client.models.embed_content(model=EMBED_MODEL, contents=t, config=cfg)
200
+ vecs.append(resp.embeddings[0].values)
201
+ return np.array(vecs, dtype=np.float32)
202
+
203
+
204
+ def _corpus_fingerprint(files):
205
+ h = hashlib.sha256()
206
+ for p in sorted(files):
207
+ st = os.stat(p)
208
+ h.update(p.encode()); h.update(str(st.st_size).encode()); h.update(str(int(st.st_mtime)).encode())
209
+ h.update(f"{EMBED_MODEL}:{EMBED_DIM}:{TARGET_CHARS}:{OVERLAP}".encode())
210
+ return h.hexdigest()
211
+
212
+
213
+ class KnowledgeBase:
214
+ def __init__(self):
215
+ self.ready = False
216
+ self.error = None
217
+ self.chunks = [] # list of {text, source}
218
+ self.matrix = None # (N, EMBED_DIM) normalized
219
+ self.sources = [] # unique source filenames
220
+ self.dir = None
221
+
222
+ # ---- build or load ----
223
+ def build(self, client, embed_batch=_embed_batch, log=print):
224
+ try:
225
+ self.dir = locate_corpus()
226
+ if not self.dir:
227
+ self.error = ("No knowledge-base documents found. Upload Archive_2.zip to the "
228
+ "Space (repo root) or add a knowledge_base/ folder of PDFs.")
229
+ return False
230
+ files = list_source_files(self.dir)
231
+ if not files:
232
+ self.error = "Knowledge-base folder found but contains no readable documents."
233
+ return False
234
+
235
+ fp = _corpus_fingerprint(files)
236
+ if self._load_cache(fp):
237
+ self.ready = True
238
+ log(f"[KB] Loaded cached index: {len(self.chunks)} chunks from {len(self.sources)} files.")
239
+ return True
240
+
241
+ # Extract + chunk
242
+ all_chunks = []
243
+ for p in files:
244
+ txt = extract_file_text(p)
245
+ cs = chunk_text(txt, p)
246
+ if cs:
247
+ all_chunks.extend(cs)
248
+ log(f"[KB] {os.path.basename(p)}: {len(cs)} chunks")
249
+ else:
250
+ log(f"[KB] {os.path.basename(p)}: no extractable text (skipped)")
251
+ if not all_chunks:
252
+ self.error = "Documents found but no text could be extracted from any of them."
253
+ return False
254
+
255
+ # Embed in batches
256
+ texts = [c["text"] for c in all_chunks]
257
+ vecs = []
258
+ B = 64
259
+ for i in range(0, len(texts), B):
260
+ batch = texts[i:i + B]
261
+ vecs.append(embed_batch(client, batch, "RETRIEVAL_DOCUMENT"))
262
+ log(f"[KB] embedded {min(i+B, len(texts))}/{len(texts)} chunks")
263
+ matrix = np.vstack(vecs)
264
+ self.chunks = all_chunks
265
+ self.matrix = _l2norm(matrix)
266
+ self.sources = sorted({c["source"] for c in all_chunks})
267
+ self._save_cache(fp)
268
+ self.ready = True
269
+ log(f"[KB] Index built: {len(self.chunks)} chunks from {len(self.sources)} files.")
270
+ return True
271
+ except Exception as e:
272
+ self.error = f"Failed to build knowledge base: {e}"
273
+ return False
274
+
275
+ def _save_cache(self, fp):
276
+ try:
277
+ np.savez_compressed(CACHE_PATH, matrix=self.matrix,
278
+ texts=np.array([c["text"] for c in self.chunks], dtype=object),
279
+ srcs=np.array([c["source"] for c in self.chunks], dtype=object),
280
+ fp=np.array([fp]))
281
+ with open(MANIFEST_PATH, "w") as fh:
282
+ json.dump({"fingerprint": fp, "sources": self.sources,
283
+ "n_chunks": len(self.chunks)}, fh, indent=2)
284
+ except Exception:
285
+ pass
286
+
287
+ def _load_cache(self, fp):
288
+ if not os.path.isfile(CACHE_PATH):
289
+ return False
290
+ try:
291
+ d = np.load(CACHE_PATH, allow_pickle=True)
292
+ if str(d["fp"][0]) != fp:
293
+ return False
294
+ self.matrix = d["matrix"].astype(np.float32)
295
+ texts = list(d["texts"]); srcs = list(d["srcs"])
296
+ self.chunks = [{"text": t, "source": s} for t, s in zip(texts, srcs)]
297
+ self.sources = sorted(set(srcs))
298
+ return True
299
+ except Exception:
300
+ return False
301
+
302
+ # ---- retrieve ----
303
+ def retrieve(self, client, query, k=TOP_K, embed_batch=_embed_batch):
304
+ if not self.ready:
305
+ return []
306
+ qv = embed_batch(client, [query], "RETRIEVAL_QUERY")[0]
307
+ qv = _l2norm(qv)
308
+ sims = self.matrix @ qv
309
+ k = min(k, len(sims))
310
+ idx = np.argpartition(-sims, k - 1)[:k]
311
+ idx = idx[np.argsort(-sims[idx])]
312
+ return [{"text": self.chunks[i]["text"], "source": self.chunks[i]["source"],
313
+ "score": float(sims[i])} for i in idx]
314
+
315
+ def context_block(self, retrieved):
316
+ """Format retrieved chunks as a numbered, source-attributed context block."""
317
+ parts = []
318
+ for i, r in enumerate(retrieved, 1):
319
+ parts.append(f"[Source {i}: {r['source']}]\n{r['text']}")
320
+ return "\n\n---\n\n".join(parts)
packages.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ poppler-utils
2
+ tesseract-ocr
requirements.txt CHANGED
@@ -1,3 +1,7 @@
1
- gradio==5.49.1
2
- google-genai==1.38.0
3
- pydantic==2.11.10
 
 
 
 
 
1
+ gradio==5.9.1
2
+ google-genai>=0.3.0
3
+ numpy>=1.24
4
+ pypdf>=4.0
5
+ pdf2image>=1.17
6
+ pytesseract>=0.3.10
7
+ pillow>=10.0