""" MyeAI (Myeloma AI) =========================================================== Takes a participant's answers to the 16 Shared Decision Making questions and runs an adaptive follow-up conversation using Google's Gemini API. GROUNDING: General informational questions from the user are answered STRICTLY from the documents in the uploaded ZIP corpus (Archive_2.zip) using retrieval-augmented generation. At startup the app extracts text from every PDF/PNG in the corpus, embeds it with Gemini embeddings, and builds a searchable index. Each user question retrieves the most relevant passages, and Gemini is instructed to answer only from those passages (or say the answer isn't in the documents). Deploy on Hugging Face Spaces (SDK: Gradio). - Upload Archive_2.zip to the Space repo root (or set KB_ZIP / KB_DIR). - Set your Gemini key as a Space Secret named GEMINI_API_KEY. - Recommended packages.txt: poppler-utils, tesseract-ocr (see notes at bottom). """ import os import json import html # ---------------------------------------------------------------------- # ZeroGPU compatibility shim (harmless on CPU basic; skipped if not present). # Satisfies "No @spaces.GPU function detected during startup" if the Space is # on ZeroGPU. This app is API-only and does not use a GPU — CPU basic is fine. # ---------------------------------------------------------------------- try: import spaces @spaces.GPU def _zerogpu_warmup(): return None except Exception: pass from google import genai from google.genai import types import gradio as gr from knowledge_base import KnowledgeBase from supabase_store import (SupabaseStore, normalize_email, is_valid_email, normalize_user_id, is_valid_user_id, user_ids_match, CONFLICT) # ---------------------------------------------------------------------- # The 16 profile questions (verbatim from the intake form) # ---------------------------------------------------------------------- QUESTIONS = [ "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", "It is important for me to understand my treatment options for relapsed or refractory myeloma", "I trust my healthcare team very much, that's why I leave it up to them to recommend therapy that is right for me", "I live alone with no caregiver", "I have strong social support and a network that can help with my treatment appointments", "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", "I would like to take an aggressive approach to treat my myeloma", "I am willing to endure as many side effects as possible to control my myeloma", "I prefer to receive treatment in an outpatient setting", "I prefer to take medications at home", "I prefer to take the least possible amount of pills to control my cancer", "Quality of life is more important to me than quantity of life", "Clinical drug trial participation is of interest to me", "My out-of-pocket cost of treatment is important to me", "I prefer to continue an active lifestyle during my myeloma treatment", "I worry about how my treatment will affect future treatment options", ] # Short tags used for the summary view QUESTION_TAGS = [ "Shared decision-making", "Wants to understand options", "Defers to care team", "Lives alone / no caregiver", "Strong social support", "Goal: cure / long life", "Wants aggressive approach", "Tolerant of side effects", "Prefers outpatient", "Prefers meds at home", "Prefers fewer pills", "Quality over quantity", "Interested in clinical trials", "Out-of-pocket cost matters", "Wants active lifestyle", "Worries about future options", ] MODEL = "gemini-2.5-flash" MAX_FOLLOWUPS = 8 # cap the adaptive conversation # The conversation always opens with this fixed question from the patient. FIRST_QUESTION = "Using my profile, what are the best treatment options for my myeloma?" # ---------------------------------------------------------------------- # Knowledge base — built once at startup. # ---------------------------------------------------------------------- KB = KnowledgeBase() _KB_BUILD_ATTEMPTED = False def ensure_kb(client): """Build the KB on first successful client. Returns (ready, message).""" global _KB_BUILD_ATTEMPTED if KB.ready: return True, "" if _KB_BUILD_ATTEMPTED and KB.error: return False, KB.error _KB_BUILD_ATTEMPTED = True KB.build(client) if KB.ready: return True, "" return False, (KB.error or "Knowledge base could not be initialized.") # ---------------------------------------------------------------------- # Supabase persistence — every participant is identified by their email. # Their 16 answers and their whole conversation are written to Supabase as # they go, and restored automatically when they sign back in. # ---------------------------------------------------------------------- STORE = SupabaseStore() def _coerce_answers(value): """Normalize a stored answers list into exactly 16 'Yes'/'No'/None values.""" out = [None] * len(QUESTIONS) if isinstance(value, list): for i, v in enumerate(value[: len(QUESTIONS)]): if isinstance(v, str): low = v.strip().lower() if low == "yes": out[i] = "Yes" elif low == "no": out[i] = "No" return out def _coerce_messages(value): """Normalize a stored message list into what gr.Chatbot(type='messages') needs. Anything malformed is dropped rather than allowed to break the chat window. """ out = [] if isinstance(value, list): for m in value: if not isinstance(m, dict): continue role, content = m.get("role"), m.get("content") if role in ("user", "assistant") and isinstance(content, str): out.append({"role": role, "content": content}) return out def _persist(email, can_save, revision, **fields): """Best-effort save of conversation state. Returns the revision to keep. ``can_save`` is False when we could not read this participant's row at sign-in: we must not write in that case, because we would be overwriting a session we never managed to load. ``revision`` is an optimistic lock. If another tab or device has already moved the conversation on, the write is refused rather than silently discarding their newer messages. """ if not email or not can_save or not STORE.enabled: return revision ok, new_revision, err = STORE.save_session( email, expected_revision=revision, **fields) if ok: return new_revision if err == CONFLICT: gr.Warning("This session was continued in another window or on another " "device, so this message was not saved. Reload the page to " "pick up the latest version of your conversation.") # Keep the revision we already hold, so every later write from this # stale session is refused too rather than silently winning next time. return revision gr.Warning(f"Could not save to Supabase: {err}") return revision def _persist_answers(email, can_save, answers, already_warned): """Save intake answers only, without touching the conversation. Outside the revision lock on purpose: writing answers cannot lose a conversation, and leaving `revision` alone keeps any in-flight conversation save valid. The store refuses any write that would replace a more complete set of answers, so out-of-order auto-saves cannot undo progress. Returns whether the participant has been told about a failure. Nothing else writes the answers, so a silent failure here would lose the questionnaire while the status line still promised it was being saved — but the warning is raised only once, not on all sixteen clicks. """ if not email or not can_save or not STORE.enabled: return already_warned ok, err = STORE.save_answers_monotonic(email, answers) if ok: return False if not already_warned: gr.Warning(f"Your answers are not being saved right now: {err}") return True def _log_turns(email, can_save, turns): """Append to the immutable transcript log. Silent on failure by design. Honours ``can_save`` too: when the sign-in read failed we tell the participant that nothing from this visit will be stored, so nothing may be written — not even to the append-only log. """ if not email or not can_save or not STORE.enabled or not turns: return STORE.append_messages(email, turns) def _status_line(email, saving, restored=False): if not email: return "" if saving: note = "Your answers and conversation are saved automatically." if restored: note = "Your previous answers and conversation were restored. " + note else: note = "Saving is unavailable, so this session will **not** be stored." return f"Signed in as **{email}**. {note}" def _user_badge(user_id): """The User ID shown in the top-right corner while signed in. The sign-in error message points participants here, so this has to be present on every screen after sign-in. """ if not user_id: return "" # is_valid_user_id already rejects angle brackets and ampersands, but the # badge is rendered as HTML, so escape anyway rather than depend on that. return f"User ID
{html.escape(user_id)}" ANSWERS_SUMMARY_HEADING = "**Your responses to the 16 questions**" def build_answers_summary(answers): """The 16 questions and this participant's answers, shown as the opening message of the chat so they can see the profile the conversation is built on without leaving the conversation.""" lines = [ANSWERS_SUMMARY_HEADING, ""] for i, (question, answer) in enumerate(zip(QUESTIONS, answers), start=1): shown = answer if answer in ("Yes", "No") else "Not answered" lines.append(f"{i}. **{shown}** — {question}") return "\n".join(lines) def _gate_view(user_id, email, panel, status, radio_updates, chat_display=None, convo=None, n_followups=0, can_save=True, revision=0, uid_attempts=0): """Build the output tuple shared by every sign-in outcome.""" chat_display = chat_display or [] convo = convo or [] return ( user_id, # user_id_state email, # email_state chat_display, # chatbot chat_display, # display_state convo, # convo_state n_followups, can_save, # can_save state revision, # revision state uid_attempts, # uid_attempts state gr.update(value=_user_badge(user_id)), # user_badge gr.update(visible=(panel == "email")), # email_panel gr.update(visible=(panel == "intake")), # intake_panel gr.update(visible=(panel == "chat")), # chat_panel gr.update(value=status), # status_md gr.update(visible=(panel != "email")), # switch_btn gr.update(value=""), # msg — never carry a # draft across sign-ins gr.update(visible=False), # reset_confirm gr.update(visible=True), # restart_btn *radio_updates, ) # ---------------------------------------------------------------------- # Prompt building # ---------------------------------------------------------------------- def build_profile_text(answers): """answers: list of 'Yes'/'No' aligned to QUESTIONS.""" lines = [] for q, tag, a in zip(QUESTIONS, QUESTION_TAGS, answers): lines.append(f"- [{a.upper()}] ({tag}) {q}") return "\n".join(lines) def detect_tensions(answers): """Flag notable patterns/contradictions worth probing. Pure logic, no API.""" a = {i: (answers[i].strip().lower() == "yes") for i in range(len(answers))} flags = [] if (a.get(5) or a.get(6) or a.get(7)) and a.get(11): flags.append("Wants an aggressive/curative approach but also values quality of life over quantity. Worth clarifying how they weigh these when they conflict.") if a.get(7) and (a.get(14) or a.get(11)): flags.append("Willing to endure many side effects, yet wants to stay active / prioritizes quality of life. Probe acceptable side-effect threshold.") if a.get(3) and not a.get(4): flags.append("Lives alone with no caregiver and limited social support. Treatment logistics and safety monitoring need exploration.") if a.get(3) and a.get(9): flags.append("Lives alone but prefers taking medications at home. Explore support for safe self-administration and side-effect monitoring.") if a.get(2) and (a.get(0) or a.get(1)): 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.") if a.get(13) and a.get(12): flags.append("Cost matters and they're open to clinical trials — trials may reduce drug cost; worth surfacing.") if a.get(10) and (a.get(6) or a.get(7)): flags.append("Prefers the fewest pills possible but wants an aggressive approach. Explore tolerance for treatment intensity vs convenience.") if a.get(8) and a.get(3): flags.append("Prefers outpatient treatment but lives alone — explore transport and post-visit support.") return flags def system_instruction(): return ( "You are a warm, plain-spoken health navigator helping a multiple myeloma patient " "(relapsed or refractory) prepare for a shared decision-making conversation with their " "care team. You are NOT a doctor and you never give medical advice, diagnoses, dosing, or " "treatment recommendations. Your job is to (a) ask thoughtful follow-up questions that help " "the patient clarify their own values, priorities, constraints, and concerns, and (b) answer " "the patient's general informational questions using ONLY the source excerpts supplied to " "you for that turn.\n\n" "LANGUAGE — DECIDE THIS FRESH ON EVERY SINGLE REPLY:\n" "Write each reply in the language of the patient's LATEST message — the last user " "turn in this conversation — and nothing else. Ignore the language of every earlier " "turn, including your own earlier replies. Language is NOT a fixed property of this " "patient: it is decided again, from scratch, for each reply. If they switch " "languages, you switch with them, immediately and without comment. For example: if " "the earlier exchange was in Spanish and their new message is in English, reply in " "English; if the whole conversation so far was in English and their new message is " "in Hindi, reply in Hindi. When the latest message is in English, reply in English.\n" "This governs the whole reply, including any apology or \"I couldn't find that\" " "message. The source excerpts are written in English; translating the relevant " "information out of them into the language of the latest message is REQUIRED and " "does NOT breach the grounding rules below. The facts must still come only from the " "source excerpts; only the wording changes. Never tell the patient you are unable to " "answer, or unable to help, because of the language they used. Two things stay " "exactly as they are in every language: document titles taken from the " "[Document: ...] labels, and the literal token <>.\n\n" "LENGTH — BE BRIEF. THIS IS A HARD RULE:\n" "Target 80 words. Absolute maximum 120 words, counting bullets. Prefer 2-4 short " "sentences in a single paragraph. If you use bullets, use at most 3, one line each " "(the final summary in rule 7 is the only exception). No preamble, no restating the " "question, no announcing what you are about to say, no sign-off, and do not repeat " "the same caveat every turn. If there is more to say, give the key points and add " "one short line offering to go further.\n\n" "CITING SOURCES — SOUND PROFESSIONAL, NEVER MENTION THE PLUMBING:\n" "Name the document the way a clinician would, using the title exactly as it appears " "in its [Document: ...] label, including any author-and-year in brackets. For example: " "\"According to the NCCN Multiple Myeloma Patient Guidelines (2026), ...\" or \"CAR T " "Cell Therapy for Multiple Myeloma (International Myeloma Foundation, 2024) notes " "that ...\". Name at most two documents in one reply, and only ones you actually used.\n" "NEVER write any of these to the patient: \"retrieved context\", \"the context " "provided\", \"the context above\", \"context block\", \"source excerpts\", \"the " "passages\", \"the excerpts\", \"the corpus\", \"knowledge base\", \"the documents " "provided to me\", \"my instructions\", \"the prompt\", \"the system\" — and their " "equivalents in whatever language you are replying in. They are internal plumbing and " "must never appear in what the patient reads: never describe how you obtained the " "information, only cite the document. If you cannot name a title, simply give the " "information with no source phrase at all.\n\n" "STRICT GROUNDING RULES (most important):\n" "A. For any general/informational question the patient asks (e.g. 'what is CAR T-cell " "therapy?', 'what are the side effects of stem cell transplant?', 'what does relapsed mean?'), " "you MUST base your answer solely on the text inside the source excerpts for that turn. " "Do NOT use outside knowledge, and do NOT add facts that are not present in that text.\n" "B. If the source excerpts do not contain enough information to answer, say so plainly " "and briefly, in the language of their latest message — in English that would be: " "\"I don't have that in my reference materials. Please ask your care team.\" Do not " "guess or fill gaps from general knowledge. Never use this as a way of sidestepping a " "question that was asked in another language.\n" "C. When you use information from a source, attribute it to the document TITLE as set out " "in CITING SOURCES above — the title, never a number, and never \"Source 1/2/3\". Use the " "title exactly as shown; do not invent or embellish document names.\n" "D. Never invent drug names, doses, statistics, or study results that are not in the " "source text.\n\n" "CONVERSATION RULES:\n" "1. When you ask a follow-up question, ask exactly ONE question per turn, 1-2 short " "sentences, conversational and jargon-free.\n" "2. Base follow-up questions on the patient's profile and previous answers. Prioritize the " "FLAGGED TENSIONS provided — gently explore apparent contradictions without judgment.\n" "3. Do not repeat questions already asked. Build on what they say.\n" "4. Never recommend or rank treatments. If the patient asks for medical advice or 'what should " "I do', kindly redirect them to their care team, then (if appropriate) continue with a follow-up " "question.\n" "5. Tone: empathetic, respectful, never alarming.\n" "6. TREATMENT-OPTIONS QUESTION: When the patient asks what the best treatment options are for " "their myeloma (e.g. 'Using my profile, what are the best treatment options for my myeloma?'), " "combine TWO sources: their 16 yes/no profile answers AND the source excerpts. " "Keep the whole reply under 120 words — one opening line, at most 3 bullets, one closing " "line, one question:\n" " - One warm opening line.\n" " - At most 3 bullets, one short line each, linking THEIR stated priorities to the kinds of " "treatment approaches described in the source excerpts for relapsed/refractory myeloma (for " "example: prefers medications at home and fewest pills -> ask about convenient outpatient or " "oral regimens; open to clinical trials -> trials are worth raising; willing to endure side " "effects and wants an aggressive approach -> ask about more intensive options; prioritizes " "quality of life -> regimens that protect daily functioning; lives alone with limited support " "-> logistics and monitoring).\n" " - Only describe treatment approaches that actually appear in the source excerpts. Do NOT " "invent clinical details, drug names, dosing, or anything not supported by the source text " "or implied by their answers. Do NOT rank, prescribe, or state which option is medically best. Frame " "it as options and questions to raise with their care team, based on what they told us. Do " "not describe where the information came from in mechanical terms — cite document titles as " "set out in CITING SOURCES.\n" " - One closing line reminding them their care team must confirm what is medically " "appropriate, then ONE follow-up question drawn from their profile and the flagged tensions.\n" "7. When you judge that you have enough to summarize their priorities (or after several " "exchanges), instead of asking another question, output a final summary. Begin that final " "message with the exact token <> on its own line — that token is literal and must " "never be translated or reworded — then 4-6 bullet points of one short line each, written in " "the language of their latest message, capturing their key priorities, constraints, and questions to raise " "with their care team." ) # ---------------------------------------------------------------------- # Gemini client helpers # ---------------------------------------------------------------------- def get_client(user_key): key = (user_key or "").strip() or os.environ.get("GEMINI_API_KEY", "").strip() if not key: return None, "No API key found. Set GEMINI_API_KEY as a Space secret." try: client = genai.Client(api_key=key) return client, None except Exception as e: return None, f"Could not initialize Gemini client: {e}" def to_gemini_contents(history): """history: list of {'role': 'user'|'assistant', 'content': str} Returns Gemini Content list (assistant -> 'model').""" contents = [] for m in history: role = "model" if m["role"] == "assistant" else "user" contents.append(types.Content(role=role, parts=[types.Part(text=m["content"])])) return contents def _latest_user_query(convo): for m in reversed(convo): if m["role"] == "user": return m["content"] return "" def _empty_reply_reason(resp): """Why the model returned nothing, e.g. ' (stopped early: RECITATION)'. Best effort and never raises: it only exists to make the warning the participant sees, and the Space logs, more diagnosable. """ def _name(value): return (getattr(value, "name", None) or str(value or "")).strip() try: blocked = _name(getattr(getattr(resp, "prompt_feedback", None), "block_reason", None)) # google-genai spells it BLOCKED_REASON_UNSPECIFIED; the older # generativelanguage SDK spelled it BLOCK_REASON_UNSPECIFIED. Accept # both so the "no real reason given" case always falls through to the # finish_reason below rather than being reported as a block. if blocked and blocked.upper() not in ( "BLOCKED_REASON_UNSPECIFIED", "BLOCK_REASON_UNSPECIFIED", "NONE"): return f" (the question was blocked: {blocked})" except Exception: pass try: reason = _name(resp.candidates[0].finish_reason) if reason and reason.upper() not in ("STOP", "FINISH_REASON_UNSPECIFIED"): return f" (stopped early: {reason})" except Exception: pass return "" def gemini_reply(client, convo, n_followups): """convo: internal history list. Retrieves KB context for the latest user turn and injects it, then asks Gemini to answer grounded in that context.""" # Retrieve relevant passages for the most recent user message. context_block = "" query = _latest_user_query(convo) if KB.ready and query.strip(): try: retrieved = KB.retrieve(client, query) if retrieved: context_block = KB.context_block(retrieved) except Exception: context_block = "" contents = to_gemini_contents(convo) # Build the per-turn system instruction with the retrieved context appended. sys = system_instruction() if context_block: sys += ( "\n\n==================== SOURCE EXCERPTS (INTERNAL) ====================\n" "Internal working material for THIS turn — never mention, name or describe this " "section to the patient. Answer general/informational questions using ONLY the text " "below, and cite the [Document: ...] titles as set out in CITING SOURCES. If the text " "below does not answer the question, say you don't have it in your reference materials " "and suggest their care team. This text is in English: that says nothing about which " "language to reply in. Reply in the language of the patient's LATEST message, ignoring " "the language of earlier turns and of your own earlier replies. Keep the whole reply " "under 120 words.\n\n" f"{context_block}\n" "================================================================================\n" ) else: sys += ( "\n\n[Internal: no source excerpts were available for this turn. If the patient asked a " "general informational question, tell them you don't have it in your reference materials " "and suggest they ask their care team — briefly, without describing this mechanism, and " "in the language of the patient's LATEST message, ignoring the language of earlier " "turns. You may still ask a values-clarifying follow-up question or work with their " "profile. Keep the whole reply under 120 words.]\n" ) if n_followups >= MAX_FOLLOWUPS - 1: sys += ("\n\nYou have asked enough questions. Provide the final <> now, in the " "language of the patient's latest message, keeping the token <> exactly " "as written.") cfg = types.GenerateContentConfig( system_instruction=sys, temperature=0.3, # lower temp -> stays closer to the sources # gemini-2.5-flash has "thinking" on by default, and those thinking # tokens are drawn from max_output_tokens. That could exhaust the budget # and truncate the visible answer mid-sentence (e.g. the long treatment- # options response). Disable thinking (budget=0) so the whole budget goes # to the answer, and raise the ceiling for extra headroom. max_output_tokens=8192, thinking_config=types.ThinkingConfig(thinking_budget=0), ) resp = client.models.generate_content(model=MODEL, contents=contents, config=cfg) text = (resp.text or "").strip() if not text: # A response with no text part — a safety or recitation block, or a # truncated candidate. `resp.text` is None here and the SDK does not # raise, so without this the empty string would be committed as a blank # assistant bubble and written to Supabase. Raise instead, so the # callers' existing error handling rolls the turn back and tells the # participant something went wrong. raise RuntimeError( "the model returned an empty response" + _empty_reply_reason(resp) + ". Please rephrase your question and try again.") return text # ---------------------------------------------------------------------- # Gradio app # ---------------------------------------------------------------------- def sign_in(user_id_raw, email_raw, uid_attempts): """Sign in with a User ID and email, restoring a previous session if one exists. The User ID is checked against the one stored for that email, so a participant cannot open someone else's session by guessing an address.""" user_id = normalize_user_id(user_id_raw) email = normalize_email(email_raw) blank_radios = [gr.update(value=None) for _ in QUESTIONS] keep_radios = [gr.update() for _ in QUESTIONS] try: uid_attempts = int(uid_attempts or 0) except (TypeError, ValueError): uid_attempts = 0 if not is_valid_user_id(user_id): gr.Warning("Please enter your User ID to begin.") return _gate_view("", "", "email", "", keep_radios, uid_attempts=uid_attempts) if not is_valid_email(email): gr.Warning("Please enter a valid email address, for example name@example.com.") return _gate_view("", "", "email", "", keep_radios, uid_attempts=uid_attempts) # Persistence not configured — the chatbot still works, it just won't save. if not STORE.enabled: gr.Warning(STORE.config_hint() + ". You can continue, but nothing will be saved.") return _gate_view(user_id, email, "intake", _status_line(email, saving=False), blank_radios, can_save=False) row, err = STORE.load_session(email) if err: # We could not read their row, so we must not write over it either — # a blank new session saved on top would destroy a real transcript. gr.Warning(f"Could not reach Supabase ({err}). You can continue, but " "nothing from this visit will be saved.") return _gate_view(user_id, email, "intake", _status_line(email, saving=False), blank_radios, can_save=False) # First time we've seen this address. if row is None: created, err = STORE.ensure_session(email, user_id=user_id) if err: # Without a row the questionnaire's auto-save has nothing to # update, so it would quietly do nothing while the status line # promised otherwise. Say so instead. gr.Warning(f"Could not start your session in Supabase ({err}). You " "can continue, but nothing from this visit will be saved.") return _gate_view(user_id, email, "intake", _status_line(email, saving=False), blank_radios, can_save=False) revision = int((created or {}).get("revision") or 0) return _gate_view(user_id, email, "intake", _status_line(email, saving=True), blank_radios, revision=revision) # Returning participant — the User ID has to match the stored one. stored_uid = normalize_user_id(row.get("user_id")) if stored_uid and not user_ids_match(stored_uid, user_id): attempts = uid_attempts + 1 if attempts >= 2: gr.Warning( "That User ID still does not match the one saved for this email " "address. Your User ID is displayed at the top right of the " "page while you are signed in — please check it there and try " "again." ) else: gr.Warning("That User ID does not match the one saved for this " "email address. Please check it and try again.") return _gate_view("", "", "email", "", keep_radios, uid_attempts=attempts) answers = _coerce_answers(row.get("answers")) chat_display = _coerce_messages(row.get("chat_display")) convo = _coerce_messages(row.get("convo")) try: n_followups = int(row.get("followups") or 0) except (TypeError, ValueError): n_followups = 0 try: revision = int(row.get("revision") or 0) except (TypeError, ValueError): revision = 0 radio_updates = [gr.update(value=a) for a in answers] if not stored_uid: # A row created before User IDs existed, or one whose sign-in never # managed to write it. Adopt the ID they just gave us. revision = _persist(email, True, revision, user_id=user_id) # Show the User ID the way it is stored, so what the badge displays is the # canonical study ID rather than whatever casing was typed this time. display_uid = stored_uid or user_id if chat_display and convo: # A conversation saved before the opening summary existed has none, so # add it here: every chat should begin with the 16 questions. if (any(a is not None for a in answers) and not chat_display[0]["content"].startswith(ANSWERS_SUMMARY_HEADING)): chat_display = [{"role": "assistant", "content": build_answers_summary(answers)}] + chat_display gr.Info("Welcome back — your answers and conversation have been restored.") return _gate_view(display_uid, email, "chat", _status_line(email, saving=True, restored=True), radio_updates, chat_display=chat_display, convo=convo, n_followups=n_followups, revision=revision) if any(a is not None for a in answers): gr.Info("Welcome back — your saved answers have been filled in.") return _gate_view(display_uid, email, "intake", _status_line(email, saving=True, restored=True), radio_updates, revision=revision) return _gate_view(display_uid, email, "intake", _status_line(email, saving=True), blank_radios, revision=revision) def switch_email(): """Sign out: clear the in-page session and go back to the sign-in screen. Nothing is deleted from Supabase — signing back in restores it. """ return _gate_view("", "", "email", "", [gr.update(value=None) for _ in QUESTIONS]) + ( gr.update(value=""), # clear the User ID box gr.update(value=""), # clear the email box ) def save_partial_answers(email, can_save, already_warned, *radio_values): """Persist the intake as it is filled in, so a half-finished questionnaire survives a closed tab. Fires on every radio click; writes nothing else.""" answers = [v if v in ("Yes", "No") else None for v in radio_values] if not any(a is not None for a in answers): return already_warned return _persist_answers(email, can_save, answers, already_warned) def start_chat(api_key, email, can_save, revision, chat_display, convo, n_followups, *radio_values): answers = [v if v in ("Yes", "No") else None for v in radio_values] if any(v is None for v in answers): missing = [i + 1 for i, v in enumerate(answers) if v is None] gr.Warning(f"Please answer all 16 questions. Missing: {missing}") # Nothing has been written yet, so a rejected submission must change # nothing: hand the existing conversation state straight back. return (gr.update(), chat_display, convo, n_followups, revision, gr.update(visible=True), gr.update(visible=False)) # Starting a follow-up replaces any previous conversation. Write the new # answers and the cleared conversation together, before the slow model # call, so a Gemini failure leaves a consistent row (new answers, no chat) # rather than new answers stapled to the old conversation. revision = _persist(email, can_save, revision, answers=answers, convo=[], chat_display=[], followups=0) # From here the stored conversation has been cleared, so the failure paths # clear the screen and the in-page state to match it. client, err = get_client(api_key) if err: gr.Warning(err) return ([], [], [], 0, revision, gr.update(visible=True), gr.update(visible=False)) # Build the knowledge base on first use (may take a bit on cold start). ready, msg = ensure_kb(client) if not ready: gr.Warning(f"Reference documents unavailable: {msg}") # We still allow the conversation, but answers will note missing docs. profile = build_profile_text(answers) flags = detect_tensions(answers) flag_text = "\n".join(f"- {f}" for f in flags) if flags else "- (No obvious contradictions; explore their highest-stakes priorities.)" seed = ( "Here is the patient's completed profile (16 yes/no answers):\n\n" f"{profile}\n\n" "FLAGGED TENSIONS / PRIORITIES TO EXPLORE:\n" f"{flag_text}\n\n" "Acknowledge in one short sentence that you have their profile. Do NOT ask a question yet " "and do NOT list treatment options yet. Simply invite them to ask their question." ) convo = [{"role": "user", "content": seed}] try: reply = gemini_reply(client, convo, 0) except Exception as e: gr.Warning(f"Gemini error: {e}") return ([], [], [], 0, revision, gr.update(visible=True), gr.update(visible=False)) convo.append({"role": "assistant", "content": reply}) # The conversation opens with the participant's own answers, so the profile # the follow-up is built on is visible without leaving the chat. It is part # of chat_display (what they see) but not of convo (the model already has # the profile in its hidden seed turn). summary = build_answers_summary(answers) chat_display = [ {"role": "assistant", "content": summary}, {"role": "assistant", "content": reply}, ] revision = _persist(email, can_save, revision, answers=answers, convo=convo, chat_display=chat_display, followups=0) _log_turns(email, can_save, [ {"role": "assistant", "content": summary, "turn": 0}, {"role": "assistant", "content": reply, "turn": 0}, ]) return ( chat_display, chat_display, convo, 0, revision, gr.update(visible=False), # intake_panel gr.update(visible=True), # chat_panel ) def respond(user_msg, chat_display, convo, n_followups, api_key, email, can_save, revision): # Carrying on with the conversation means the participant is not starting # over, so any pending confirmation is dismissed rather than left armed. dismiss = (gr.update(visible=False), gr.update(visible=True)) user_msg = (user_msg or "").strip() if not user_msg: return (chat_display, chat_display, convo, n_followups, revision, "") + dismiss client, err = get_client(api_key) if err: gr.Warning(err) return (chat_display, chat_display, convo, n_followups, revision, user_msg) + dismiss ensure_kb(client) # no-op if already built convo = convo + [{"role": "user", "content": user_msg}] chat_display = chat_display + [{"role": "user", "content": user_msg}] try: reply = gemini_reply(client, convo, n_followups) except Exception as e: gr.Warning(f"Gemini error: {e}") return (chat_display, chat_display, convo, n_followups, revision, "") + dismiss convo = convo + [{"role": "assistant", "content": reply}] display_reply = reply if "<>" in reply: display_reply = reply.replace("<>", "").strip() display_reply = "**Your Priorities Summary**\n\n" + display_reply + ( "\n\n_This summary reflects what you shared. Please bring it to your care team. " "It is not medical advice._" ) chat_display = chat_display + [{"role": "assistant", "content": display_reply}] revision = _persist(email, can_save, revision, convo=convo, chat_display=chat_display, followups=n_followups + 1) _log_turns(email, can_save, [ {"role": "user", "content": user_msg, "turn": n_followups + 1}, {"role": "assistant", "content": display_reply, "turn": n_followups + 1}, ]) return (chat_display, chat_display, convo, n_followups + 1, revision, "") + dismiss def show_reset_warning(): """Start Over is destructive, so it asks first.""" gr.Warning("Starting over will erase your current chat history and begin a " "new session.") return ( gr.update(visible=True), # reset_confirm gr.update(visible=False), # restart_btn — replaced by the choice ) def cancel_reset(): """Back out of Start Over, leaving the conversation untouched.""" return ( gr.update(visible=False), # reset_confirm gr.update(visible=True), # restart_btn ) def reset_all(email, can_save, revision): """Start Over, confirmed: clear this page AND the stored session, so a participant who restarts and leaves is not dropped back into the conversation they abandoned. The p3_messages transcript log is deliberately left intact, so nothing is lost for the study.""" revision = _persist(email, can_save, revision, answers=[None] * len(QUESTIONS), convo=[], chat_display=[], followups=0) radio_resets = [gr.update(value=None) for _ in QUESTIONS] return ( [], [], [], 0, revision, gr.update(visible=True), # intake_panel gr.update(visible=False), # chat_panel gr.update(value=""), # msg — drop any unsent draft gr.update(visible=False), # reset_confirm gr.update(visible=True), # restart_btn *radio_resets, ) CSS = """ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); .gradio-container, .gradio-container * { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif !important; } .gradio-container { background: #ffffff !important; color: #111111 !important; max-width: 880px !important; margin: 0 auto !important; } body, .gradio-container .prose, .gradio-container p, .gradio-container span, .gradio-container label { color: #111111 !important; } #app-header { border-bottom: 1px solid #e6e6e6; padding: 4px 0 18px 0; margin-bottom: 6px; } #app-title { font-size: 1.7rem; font-weight: 700; color: #111111; letter-spacing: -0.01em; margin: 0; } .q-card { border: 1px solid #e2e2e2 !important; border-radius: 8px !important; padding: 16px 18px !important; margin-bottom: 12px !important; background: #ffffff !important; box-shadow: 0 1px 2px rgba(0,0,0,0.04) !important; } .q-card label, .q-card span { color: #111111 !important; font-weight: 500 !important; } .gradio-container input[type="radio"] + span, .gradio-container .wrap label { color: #111111 !important; } button.primary, .gradio-container button.primary { background: #7a0c2e !important; color: #ffffff !important; border: none !important; border-radius: 6px !important; font-weight: 600 !important; } button.primary:hover, .gradio-container button.primary:hover { background: #5f0a24 !important; } button.secondary, .gradio-container button.secondary { background: #ffffff !important; color: #111111 !important; border: 1px solid #cfcfcf !important; border-radius: 6px !important; font-weight: 600 !important; } .gradio-container .chatbot, .gradio-container [class*="chatbot"] { background: #ffffff !important; border: 1px solid #e2e2e2 !important; border-radius: 8px !important; } .gradio-container .message.bot, .gradio-container .message.user { color: #111111 !important; } .gradio-container textarea, .gradio-container input[type="text"], .gradio-container input[type="password"] { background: #ffffff !important; color: #111111 !important; border: 1px solid #cfcfcf !important; border-radius: 6px !important; } .gradio-container .label-wrap, .gradio-container details summary { color: #111111 !important; } .sample-q { background: #f7f7f7 !important; border: 1px solid #e2e2e2 !important; border-left: 3px solid #7a0c2e !important; border-radius: 6px !important; padding: 10px 14px !important; margin: 12px 0 4px 0 !important; } .sample-q-label { display: block; font-size: 0.85rem; font-weight: 600; color: #5a5a5a !important; margin-bottom: 4px; } .sample-q-text { display: block; font-size: 0.95rem; color: #111111 !important; background: #ffffff !important; border: 1px solid #e2e2e2 !important; border-radius: 4px !important; padding: 6px 10px !important; font-family: 'Inter', sans-serif !important; } #status-line { font-size: 0.9rem !important; color: #444444 !important; margin: 2px 0 0 0 !important; } #status-line p {color: #444444 !important; margin: 0 !important;} #switch-btn { max-width: 200px !important; margin: 6px 0 10px 0 !important; } .email-card { border: 1px solid #e2e2e2 !important; border-radius: 8px !important; padding: 18px 20px !important; background: #ffffff !important; box-shadow: 0 1px 2px rgba(0,0,0,0.04) !important; } #app-header { align-items: flex-start !important; } #user-id-badge { text-align: right !important; font-size: 0.85rem !important; color: #5a5a5a !important; line-height: 1.3 !important; } #user-id-badge p { color: #5a5a5a !important; margin: 0 !important; text-align: right !important; } #user-id-badge strong { color: #7a0c2e !important; font-size: 1rem !important; letter-spacing: 0.02em; } button.stop, .gradio-container button.stop { background: #b3261e !important; color: #ffffff !important; border: none !important; border-radius: 6px !important; font-weight: 600 !important; } button.stop:hover, .gradio-container button.stop:hover { background: #8c1d16 !important; } .reset-warning { border: 1px solid #7a0c2e !important; border-left: 4px solid #7a0c2e !important; border-radius: 8px !important; padding: 14px 16px !important; margin-top: 8px !important; background: #fdf6f8 !important; } footer {visibility: hidden;} """ with gr.Blocks(title="MyeAI (Myeloma AI)", css=CSS, theme=gr.themes.Base( primary_hue=gr.themes.colors.red, neutral_hue=gr.themes.colors.gray, font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], )) as demo: with gr.Row(elem_id="app-header"): gr.HTML("

MyeAI (Myeloma AI)

") # The sign-in error for a wrong User ID points participants here, so it # has to be visible on every screen once they are signed in. user_badge = gr.Markdown("", elem_id="user-id-badge") api_key = gr.State("") convo_state = gr.State([]) display_state = gr.State([]) followups = gr.State(0) email_state = gr.State("") # False when we could not read this participant's stored row, so we must # not write over it. True otherwise. can_save = gr.State(True) # Optimistic lock guarding against a stale tab / second device. revision = gr.State(0) # Throttles the "your answers are not being saved" warning to once. answers_warned = gr.State(False) user_id_state = gr.State("") # Consecutive failed User ID attempts, so the second one can point the # participant at where their User ID is shown. uid_attempts = gr.State(0) status_md = gr.Markdown("", elem_id="status-line") switch_btn = gr.Button("Use a different email", size="sm", visible=False, elem_id="switch-btn") with gr.Group(visible=True, elem_classes="email-card") as email_panel: gr.Markdown( "**Enter your User ID and email address to begin.**\n\n" "Your answers and your conversation are saved against these, so you " "can close this page and come back later to pick up exactly where you left off." ) user_id_box = gr.Textbox(label="User ID", placeholder="e.g. P3-014", max_lines=1, autofocus=True) email_box = gr.Textbox(label="Email address", placeholder="name@example.com", max_lines=1) email_btn = gr.Button("Continue", variant="primary") with gr.Group(visible=False) as intake_panel: gr.Markdown("**Answer all 16 questions, then click Start Follow-up.**") radios = [] for i, q in enumerate(QUESTIONS): with gr.Row(elem_classes="q-card"): r = gr.Radio(["Yes", "No"], label=f"{i+1}. {q}") radios.append(r) start_btn = gr.Button("Start Follow-up", variant="primary") with gr.Group(visible=False) as chat_panel: chatbot = gr.Chatbot(label="Follow-up Conversation", type="messages", height=460) gr.HTML( "
" "Sample question — copy and paste it into the box below to begin:" f"{FIRST_QUESTION}" "
" ) with gr.Row(): msg = gr.Textbox(placeholder="Type or paste your question here...", show_label=False, scale=8) send_btn = gr.Button("Send", variant="primary", scale=1) restart_btn = gr.Button("Start Over") with gr.Group(visible=False, elem_classes="reset-warning") as reset_confirm: gr.Markdown( "**Are you sure you want to start over?**\n\n" "This erases your current chat history and begins a new session. " "You will answer the 16 questions again." ) with gr.Row(): confirm_reset_btn = gr.Button("Yes, erase and start over", variant="stop") cancel_reset_btn = gr.Button("Cancel") # Sign-in: one shared output list for every outcome (new / returning / error). gate_outputs = [ user_id_state, email_state, chatbot, display_state, convo_state, followups, can_save, revision, uid_attempts, user_badge, email_panel, intake_panel, chat_panel, status_md, switch_btn, msg, reset_confirm, restart_btn, ] + radios gate_inputs = [user_id_box, email_box, uid_attempts] email_btn.click(sign_in, inputs=gate_inputs, outputs=gate_outputs) user_id_box.submit(sign_in, inputs=gate_inputs, outputs=gate_outputs) email_box.submit(sign_in, inputs=gate_inputs, outputs=gate_outputs) switch_btn.click(switch_email, inputs=None, outputs=gate_outputs + [user_id_box, email_box]) # Save the questionnaire as it is filled in, so a half-finished intake # survives a closed tab. # `.input` rather than `.change`: fires only on a real click, so restoring # or clearing the form does not stampede 16 writes back at Supabase. for r in radios: r.input( save_partial_answers, inputs=[email_state, can_save, answers_warned] + radios, outputs=[answers_warned], ) start_btn.click( start_chat, inputs=[api_key, email_state, can_save, revision, display_state, convo_state, followups] + radios, outputs=[chatbot, display_state, convo_state, followups, revision, intake_panel, chat_panel], ) send_btn.click( respond, inputs=[msg, display_state, convo_state, followups, api_key, email_state, can_save, revision], outputs=[chatbot, display_state, convo_state, followups, revision, msg, reset_confirm, restart_btn], ) msg.submit( respond, inputs=[msg, display_state, convo_state, followups, api_key, email_state, can_save, revision], outputs=[chatbot, display_state, convo_state, followups, revision, msg, reset_confirm, restart_btn], ) # Start Over warns first; only the confirmation actually erases anything. restart_btn.click( show_reset_warning, inputs=None, outputs=[reset_confirm, restart_btn], ) cancel_reset_btn.click( cancel_reset, inputs=None, outputs=[reset_confirm, restart_btn], ) confirm_reset_btn.click( reset_all, inputs=[email_state, can_save, revision], outputs=[chatbot, display_state, convo_state, followups, revision, intake_panel, chat_panel, msg, reset_confirm, restart_btn] + radios, ) if __name__ == "__main__": demo.launch() # ---------------------------------------------------------------------- # HUGGING FACE SPACE SETUP NOTES # ---------------------------------------------------------------------- # 1) Files in the Space repo: # app.py # knowledge_base.py # Archive_2.zip <- the uploaded corpus (repo root) # requirements.txt <- google-genai, gradio, numpy, pypdf, pdf2image, pytesseract, pillow # packages.txt <- poppler-utils, tesseract-ocr (system deps for PDF text + OCR) # 2) Space Secret: # GEMINI_API_KEY = # 3) Hardware: CPU basic is sufficient (no GPU needed). # 4) On first launch the app extracts + embeds the corpus once and caches the # index to _kb_index.npz, so subsequent restarts are fast.