Spaces:
Paused
Paused
Upload 4 files
Browse files- app.py +157 -82
- knowledge_base.py +320 -0
- packages.txt +2 -0
- 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 |
-
|
| 8 |
-
|
| 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 |
-
#
|
| 23 |
-
#
|
| 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
|
| 139 |
-
"the patient clarify their own values, priorities, constraints, and concerns
|
| 140 |
-
"
|
| 141 |
-
"
|
| 142 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 146 |
-
"redirect them to
|
|
|
|
| 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 |
-
"
|
| 151 |
"this:\n"
|
| 152 |
" - Open with one warm sentence.\n"
|
| 153 |
-
" -
|
| 154 |
-
"
|
| 155 |
-
"to their answers (for example: a preference for
|
| 156 |
-
"
|
| 157 |
-
"trials
|
| 158 |
-
"
|
| 159 |
-
"
|
| 160 |
-
"
|
| 161 |
-
" -
|
| 162 |
-
"
|
| 163 |
-
"
|
| 164 |
-
"
|
|
|
|
| 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.
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
contents = to_gemini_contents(convo)
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
if n_followups >= MAX_FOLLOWUPS - 1:
|
| 204 |
-
|
|
|
|
| 205 |
cfg = types.GenerateContentConfig(
|
| 206 |
-
system_instruction=
|
| 207 |
-
temperature=0.
|
| 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,
|
| 255 |
-
chat_display,
|
| 256 |
-
convo,
|
| 257 |
-
0,
|
| 258 |
-
gr.update(visible=False),
|
| 259 |
-
gr.update(visible=True),
|
| 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 |
-
[],
|
| 300 |
-
[],
|
| 301 |
-
[],
|
| 302 |
-
0,
|
| 303 |
-
gr.update(visible=True),
|
| 304 |
-
gr.update(visible=False),
|
| 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.
|
| 2 |
-
google-genai=
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|