stevafernandes commited on
Commit
dd4d624
·
verified ·
1 Parent(s): 5830d4e

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +322 -0
app.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
7
+ Deploy on Hugging Face Spaces (SDK: Gradio).
8
+ Set your Gemini key as a Space Secret named GEMINI_API_KEY,
9
+ or paste it in the UI field at runtime.
10
+ """
11
+
12
+ import os
13
+ import json
14
+ from google import genai
15
+ from google.genai import types
16
+ import gradio as gr
17
+
18
+ # ----------------------------------------------------------------------
19
+ # The 16 profile questions (verbatim from the intake form)
20
+ # ----------------------------------------------------------------------
21
+ QUESTIONS = [
22
+ "I prefer my healthcare team (hematologist, nurse practitioner, or physician assistant), and I collaborate in deciding which treatment for relapsed or refractory myeloma is best for me",
23
+ "It is important for me to understand my treatment options for relapsed or refractory myeloma",
24
+ "I trust my healthcare team very much, that's why I leave it up to them to recommend therapy that is right for me",
25
+ "I live alone with no caregiver",
26
+ "I have strong social support and a network that can help with my treatment appointments",
27
+ "I will do whatever it takes to so I can be cured, cancer-free, kill all the myeloma, or at least be in remission and live a long life",
28
+ "I would like to take an aggressive approach to treat my myeloma",
29
+ "I am willing to endure as many side effects as possible to control my myeloma",
30
+ "I prefer to receive treatment in an outpatient setting",
31
+ "I prefer to take medications at home",
32
+ "I prefer to take the least possible amount of pills to control my cancer",
33
+ "Quality of life is more important to me than quantity of life",
34
+ "Clinical drug trial participation is of interest to me",
35
+ "My out-of-pocket cost of treatment is important to me",
36
+ "I prefer to continue an active lifestyle during my myeloma treatment",
37
+ "I worry about how my treatment will affect future treatment options",
38
+ ]
39
+
40
+ # Short tags used for the summary view
41
+ QUESTION_TAGS = [
42
+ "Shared decision-making",
43
+ "Wants to understand options",
44
+ "Defers to care team",
45
+ "Lives alone / no caregiver",
46
+ "Strong social support",
47
+ "Goal: cure / long life",
48
+ "Wants aggressive approach",
49
+ "Tolerant of side effects",
50
+ "Prefers outpatient",
51
+ "Prefers meds at home",
52
+ "Prefers fewer pills",
53
+ "Quality over quantity",
54
+ "Interested in clinical trials",
55
+ "Out-of-pocket cost matters",
56
+ "Wants active lifestyle",
57
+ "Worries about future options",
58
+ ]
59
+
60
+ MODEL = "gemini-2.5-flash"
61
+ MAX_FOLLOWUPS = 8 # cap the adaptive conversation
62
+
63
+ # ----------------------------------------------------------------------
64
+ # Prompt building
65
+ # ----------------------------------------------------------------------
66
+ def build_profile_text(answers):
67
+ """answers: list of 'Yes'/'No' aligned to QUESTIONS."""
68
+ lines = []
69
+ for q, tag, a in zip(QUESTIONS, QUESTION_TAGS, answers):
70
+ lines.append(f"- [{a.upper()}] ({tag}) {q}")
71
+ return "\n".join(lines)
72
+
73
+
74
+ def detect_tensions(answers):
75
+ """Flag notable patterns/contradictions worth probing. Pure logic, no API."""
76
+ a = {i: (answers[i].strip().lower() == "yes") for i in range(len(answers))}
77
+ flags = []
78
+
79
+ # Aggressive cure-seeking vs quality-over-quantity
80
+ if (a.get(5) or a.get(6) or a.get(7)) and a.get(11):
81
+ flags.append("Wants an aggressive/curative approach but also values quality of life over quantity. Worth clarifying how they weigh these when they conflict.")
82
+ # Willing to endure side effects vs wants active lifestyle / quality of life
83
+ if a.get(7) and (a.get(14) or a.get(11)):
84
+ flags.append("Willing to endure many side effects, yet wants to stay active / prioritizes quality of life. Probe acceptable side-effect threshold.")
85
+ # Lives alone AND no strong social support -> logistics risk
86
+ if a.get(3) and not a.get(4):
87
+ flags.append("Lives alone with no caregiver and limited social support. Treatment logistics and safety monitoring need exploration.")
88
+ # Lives alone but prefers home meds -> safety of self-administration
89
+ if a.get(3) and a.get(9):
90
+ flags.append("Lives alone but prefers taking medications at home. Explore support for safe self-administration and side-effect monitoring.")
91
+ # Defers fully to team but also wants to collaborate / understand options
92
+ if a.get(2) and (a.get(0) or a.get(1)):
93
+ 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.")
94
+ # Cost matters + trial interest (trials can offset cost) — opportunity, not conflict
95
+ if a.get(13) and a.get(12):
96
+ flags.append("Cost matters and they're open to clinical trials — trials may reduce drug cost; worth surfacing.")
97
+ # Fewer pills preference but aggressive
98
+ if a.get(10) and (a.get(6) or a.get(7)):
99
+ flags.append("Prefers the fewest pills possible but wants an aggressive approach. Explore tolerance for treatment intensity vs convenience.")
100
+ # Outpatient + lives alone
101
+ if a.get(8) and a.get(3):
102
+ flags.append("Prefers outpatient treatment but lives alone — explore transport and post-visit support.")
103
+ return flags
104
+
105
+
106
+ def system_instruction():
107
+ return (
108
+ "You are a warm, plain-spoken health navigator helping a multiple myeloma patient "
109
+ "(relapsed or refractory) prepare for a shared decision-making conversation with their "
110
+ "care team. You are NOT a doctor and you never give medical advice, diagnoses, dosing, or "
111
+ "treatment recommendations. Your job is ONLY to ask thoughtful follow-up questions that help "
112
+ "the patient clarify their own values, priorities, constraints, and concerns.\n\n"
113
+ "RULES:\n"
114
+ "1. Ask exactly ONE question per turn. Keep it to 1-3 sentences, conversational, jargon-free.\n"
115
+ "2. Base each question on the patient's profile and their previous answers. Prioritize the "
116
+ "FLAGGED TENSIONS provided — gently explore apparent contradictions without judgment.\n"
117
+ "3. Do not repeat questions already asked. Build on what they say.\n"
118
+ "4. Never recommend or rank treatments. If the patient asks for medical advice, kindly "
119
+ "redirect them to discuss it with their care team, then continue with a follow-up question.\n"
120
+ "5. Tone: empathetic, respectful, never alarming.\n"
121
+ "6. When you judge that you have enough to summarize their priorities (or after several "
122
+ "exchanges), instead of asking another question, output a final summary. Begin that final "
123
+ "message with the exact token <<SUMMARY>> on its own line, then 4-7 short bullet points "
124
+ "capturing the patient's key priorities, constraints, and questions to raise with their care team."
125
+ )
126
+
127
+
128
+ # ----------------------------------------------------------------------
129
+ # Gemini client helpers
130
+ # ----------------------------------------------------------------------
131
+ def get_client(user_key):
132
+ key = (user_key or "").strip() or os.environ.get("GEMINI_API_KEY", "").strip()
133
+ if not key:
134
+ return None, "No API key found. Paste your Gemini API key above, or set GEMINI_API_KEY as a Space secret."
135
+ try:
136
+ client = genai.Client(api_key=key)
137
+ return client, None
138
+ except Exception as e:
139
+ return None, f"Could not initialize Gemini client: {e}"
140
+
141
+
142
+ def to_gemini_contents(history):
143
+ """history: list of {'role': 'user'|'assistant', 'content': str}
144
+ Returns Gemini Content list (assistant -> 'model')."""
145
+ contents = []
146
+ for m in history:
147
+ role = "model" if m["role"] == "assistant" else "user"
148
+ contents.append(types.Content(role=role, parts=[types.Part(text=m["content"])]))
149
+ return contents
150
+
151
+
152
+ def gemini_reply(client, convo, n_followups):
153
+ """convo: internal history list. Returns assistant text."""
154
+ contents = to_gemini_contents(convo)
155
+ # Nudge toward summarizing as we approach the cap
156
+ extra = ""
157
+ if n_followups >= MAX_FOLLOWUPS - 1:
158
+ extra = "\n\nYou have asked enough questions. Provide the final <<SUMMARY>> now."
159
+ cfg = types.GenerateContentConfig(
160
+ system_instruction=system_instruction() + extra,
161
+ temperature=0.7,
162
+ max_output_tokens=600,
163
+ )
164
+ resp = client.models.generate_content(model=MODEL, contents=contents, config=cfg)
165
+ return (resp.text or "").strip()
166
+
167
+
168
+ # ----------------------------------------------------------------------
169
+ # Gradio app
170
+ # ----------------------------------------------------------------------
171
+ def start_chat(api_key, *radio_values):
172
+ answers = [v if v in ("Yes", "No") else None for v in radio_values]
173
+ if any(v is None for v in answers):
174
+ missing = [i + 1 for i, v in enumerate(answers) if v is None]
175
+ gr.Warning(f"Please answer all 16 questions. Missing: {missing}")
176
+ return (gr.update(), [], [], 0, gr.update(visible=True), gr.update(visible=False))
177
+
178
+ client, err = get_client(api_key)
179
+ if err:
180
+ gr.Warning(err)
181
+ return (gr.update(), [], [], 0, gr.update(visible=True), gr.update(visible=False))
182
+
183
+ profile = build_profile_text(answers)
184
+ flags = detect_tensions(answers)
185
+ flag_text = "\n".join(f"- {f}" for f in flags) if flags else "- (No obvious contradictions; explore their highest-stakes priorities.)"
186
+
187
+ kickoff = (
188
+ "Here is the patient's completed profile (16 yes/no answers):\n\n"
189
+ f"{profile}\n\n"
190
+ "FLAGGED TENSIONS / PRIORITIES TO EXPLORE:\n"
191
+ f"{flag_text}\n\n"
192
+ "Begin the follow-up conversation. Warmly acknowledge in one short sentence, then ask your "
193
+ "FIRST single follow-up question."
194
+ )
195
+
196
+ convo = [{"role": "user", "content": kickoff}]
197
+ try:
198
+ reply = gemini_reply(client, convo, 0)
199
+ except Exception as e:
200
+ gr.Warning(f"Gemini error: {e}")
201
+ return (gr.update(), [], [], 0, gr.update(visible=True), gr.update(visible=False))
202
+
203
+ convo.append({"role": "assistant", "content": reply})
204
+ chat_display = [{"role": "assistant", "content": reply}]
205
+ return (
206
+ chat_display, # chatbot
207
+ chat_display, # display_state
208
+ convo, # convo_state
209
+ 1, # followup count
210
+ gr.update(visible=False), # hide intake
211
+ gr.update(visible=True), # show chat
212
+ )
213
+
214
+
215
+ def respond(user_msg, chat_display, convo, n_followups, api_key):
216
+ user_msg = (user_msg or "").strip()
217
+ if not user_msg:
218
+ return chat_display, chat_display, convo, n_followups, ""
219
+
220
+ client, err = get_client(api_key)
221
+ if err:
222
+ gr.Warning(err)
223
+ return chat_display, chat_display, convo, n_followups, user_msg
224
+
225
+ convo = convo + [{"role": "user", "content": user_msg}]
226
+ chat_display = chat_display + [{"role": "user", "content": user_msg}]
227
+
228
+ try:
229
+ reply = gemini_reply(client, convo, n_followups)
230
+ except Exception as e:
231
+ gr.Warning(f"Gemini error: {e}")
232
+ return chat_display, chat_display, convo, n_followups, ""
233
+
234
+ convo = convo + [{"role": "assistant", "content": reply}]
235
+
236
+ display_reply = reply
237
+ if "<<SUMMARY>>" in reply:
238
+ display_reply = reply.replace("<<SUMMARY>>", "").strip()
239
+ display_reply = "**Your Priorities Summary**\n\n" + display_reply + (
240
+ "\n\n_This summary reflects what you shared. Please bring it to your care team. "
241
+ "It is not medical advice._"
242
+ )
243
+
244
+ chat_display = chat_display + [{"role": "assistant", "content": display_reply}]
245
+ return chat_display, chat_display, convo, n_followups + 1, ""
246
+
247
+
248
+ def reset_all():
249
+ radio_resets = [gr.update(value=None) for _ in QUESTIONS]
250
+ return (
251
+ [], # chatbot
252
+ [], # display_state
253
+ [], # convo_state
254
+ 0, # followups
255
+ gr.update(visible=True), # intake
256
+ gr.update(visible=False), # chat
257
+ *radio_resets,
258
+ )
259
+
260
+
261
+ CSS = """
262
+ .q-card {border:1px solid #e5e5e5; border-radius:12px; padding:14px 16px; margin-bottom:10px; background:#fff;}
263
+ #title {color:#7a0c2e; font-weight:700;}
264
+ footer {visibility:hidden;}
265
+ """
266
+
267
+ with gr.Blocks(title="P3 Myeloma Follow-up Chatbot", css=CSS,
268
+ theme=gr.themes.Soft(primary_hue="red")) as demo:
269
+ gr.Markdown("# <span id='title'>AI-Enhanced Personalized Patient Profile (P3) Myeloma</span>")
270
+ gr.Markdown("### Follow-up Questions for Shared Decision Making")
271
+
272
+ with gr.Accordion("Gemini API Key (optional if set as Space secret GEMINI_API_KEY)", open=False):
273
+ api_key = gr.Textbox(label="GEMINI_API_KEY", type="password",
274
+ placeholder="Paste your key here, or leave blank to use the Space secret")
275
+
276
+ convo_state = gr.State([])
277
+ display_state = gr.State([])
278
+ followups = gr.State(0)
279
+
280
+ # ---- Intake panel ----
281
+ with gr.Group(visible=True) as intake_panel:
282
+ gr.Markdown("**Answer all 16 questions, then click Start Follow-up.**")
283
+ radios = []
284
+ for i, q in enumerate(QUESTIONS):
285
+ with gr.Row(elem_classes="q-card"):
286
+ r = gr.Radio(["Yes", "No"], label=f"{i+1}. {q}")
287
+ radios.append(r)
288
+ start_btn = gr.Button("Start Follow-up", variant="primary")
289
+
290
+ # ---- Chat panel ----
291
+ with gr.Group(visible=False) as chat_panel:
292
+ chatbot = gr.Chatbot(label="Follow-up Conversation", type="messages", height=460)
293
+ with gr.Row():
294
+ msg = gr.Textbox(placeholder="Type your answer...", show_label=False, scale=8)
295
+ send_btn = gr.Button("Send", variant="primary", scale=1)
296
+ restart_btn = gr.Button("Start Over")
297
+
298
+ start_btn.click(
299
+ start_chat,
300
+ inputs=[api_key] + radios,
301
+ outputs=[chatbot, display_state, convo_state, followups, intake_panel, chat_panel],
302
+ )
303
+
304
+ send_btn.click(
305
+ respond,
306
+ inputs=[msg, display_state, convo_state, followups, api_key],
307
+ outputs=[chatbot, display_state, convo_state, followups, msg],
308
+ )
309
+ msg.submit(
310
+ respond,
311
+ inputs=[msg, display_state, convo_state, followups, api_key],
312
+ outputs=[chatbot, display_state, convo_state, followups, msg],
313
+ )
314
+
315
+ restart_btn.click(
316
+ reset_all,
317
+ inputs=None,
318
+ outputs=[chatbot, display_state, convo_state, followups, intake_panel, chat_panel] + radios,
319
+ )
320
+
321
+ if __name__ == "__main__":
322
+ demo.launch()