Spaces:
Running on Zero
Running on Zero
Upload 5 files
Browse files- README.md +29 -11
- app.py +286 -107
- supabase_schema.sql +10 -0
- supabase_store.py +51 -14
- verify_supabase.py +12 -2
README.md
CHANGED
|
@@ -34,9 +34,10 @@ helps the patient articulate their own values and concerns.
|
|
| 34 |
|
| 35 |
## Saving and resuming sessions (Supabase)
|
| 36 |
|
| 37 |
-
Participants
|
| 38 |
-
against that
|
| 39 |
-
device — picks up exactly where they left off.
|
|
|
|
| 40 |
|
| 41 |
### One-time setup
|
| 42 |
|
|
@@ -72,9 +73,9 @@ and it will not overwrite anything already stored for them.
|
|
| 72 |
|
| 73 |
### What gets stored
|
| 74 |
|
| 75 |
-
`p3_sessions` — one resumable row per email: the
|
| 76 |
-
the
|
| 77 |
-
counter.
|
| 78 |
|
| 79 |
`p3_messages` — an append-only log of every turn. Session rows are replaced when
|
| 80 |
a participant starts over; this log never is, so no transcript is lost.
|
|
@@ -84,16 +85,33 @@ in the Table Editor.
|
|
| 84 |
|
| 85 |
### What participants see
|
| 86 |
|
| 87 |
-
- They are asked for their
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
- The questionnaire auto-saves as it is filled in, so a half-finished intake
|
| 91 |
survives a closed tab.
|
| 92 |
-
- **Start Over**
|
| 93 |
-
|
|
|
|
|
|
|
| 94 |
- If the same session is open in two places, the older one is told to reload
|
| 95 |
rather than being allowed to overwrite the newer conversation.
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
### Deleting a participant's data
|
| 98 |
|
| 99 |
```sql
|
|
|
|
| 34 |
|
| 35 |
## Saving and resuming sessions (Supabase)
|
| 36 |
|
| 37 |
+
Participants sign in with a **User ID** and an **email address**. Everything they
|
| 38 |
+
do is saved against that email, so closing the page and returning later — even on
|
| 39 |
+
another device — picks up exactly where they left off. The User ID is checked on
|
| 40 |
+
every return visit, so entering someone else's email does not open their session.
|
| 41 |
|
| 42 |
### One-time setup
|
| 43 |
|
|
|
|
| 73 |
|
| 74 |
### What gets stored
|
| 75 |
|
| 76 |
+
`p3_sessions` — one resumable row per email: the participant's User ID, the 16
|
| 77 |
+
answers, the conversation they see, the full model-facing conversation, and the
|
| 78 |
+
follow-up counter.
|
| 79 |
|
| 80 |
`p3_messages` — an append-only log of every turn. Session rows are replaced when
|
| 81 |
a participant starts over; this log never is, so no transcript is lost.
|
|
|
|
| 85 |
|
| 86 |
### What participants see
|
| 87 |
|
| 88 |
+
- They are asked for their **User ID first, then their email address**, before
|
| 89 |
+
anything else. Both are stored in Supabase.
|
| 90 |
+
- Their User ID is shown in the **top right of the page** for as long as they are
|
| 91 |
+
signed in, so they can note it for next time.
|
| 92 |
+
- If a returning participant enters the wrong User ID for an email, they are
|
| 93 |
+
refused. The **second** wrong attempt tells them the User ID is displayed at
|
| 94 |
+
the top right of the page.
|
| 95 |
+
- The conversation **opens with all 16 questions and their own answers**, so the
|
| 96 |
+
profile the follow-up is built on is visible without leaving the chat.
|
| 97 |
- The questionnaire auto-saves as it is filled in, so a half-finished intake
|
| 98 |
survives a closed tab.
|
| 99 |
+
- **Start Over** first shows a warning that it erases the current chat history and
|
| 100 |
+
starts a new session, and only erases anything once confirmed. The transcript
|
| 101 |
+
log in `p3_messages` is kept either way. **Use a different email** just signs
|
| 102 |
+
out; nothing is deleted.
|
| 103 |
- If the same session is open in two places, the older one is told to reload
|
| 104 |
rather than being allowed to overwrite the newer conversation.
|
| 105 |
|
| 106 |
+
### Changing a participant's User ID
|
| 107 |
+
|
| 108 |
+
```sql
|
| 109 |
+
update public.p3_sessions set user_id = 'P3-NEW' where email = 'someone@example.com';
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
A row created before User IDs existed has none stored; the first sign-in after
|
| 113 |
+
that adopts whatever User ID is entered, and it is enforced from then on.
|
| 114 |
+
|
| 115 |
### Deleting a participant's data
|
| 116 |
|
| 117 |
```sql
|
app.py
CHANGED
|
@@ -20,6 +20,7 @@ Deploy on Hugging Face Spaces (SDK: Gradio).
|
|
| 20 |
|
| 21 |
import os
|
| 22 |
import json
|
|
|
|
| 23 |
|
| 24 |
# ----------------------------------------------------------------------
|
| 25 |
# ZeroGPU compatibility shim (harmless on CPU basic; skipped if not present).
|
|
@@ -41,7 +42,8 @@ import gradio as gr
|
|
| 41 |
|
| 42 |
from knowledge_base import KnowledgeBase
|
| 43 |
from supabase_store import (SupabaseStore, normalize_email, is_valid_email,
|
| 44 |
-
|
|
|
|
| 45 |
|
| 46 |
# ----------------------------------------------------------------------
|
| 47 |
# The 16 profile questions (verbatim from the intake form)
|
|
@@ -225,27 +227,59 @@ def _status_line(email, saving, restored=False):
|
|
| 225 |
return f"Signed in as **{email}**. {note}"
|
| 226 |
|
| 227 |
|
| 228 |
-
def
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
"""Build the output tuple shared by every sign-in outcome."""
|
| 231 |
chat_display = chat_display or []
|
| 232 |
convo = convo or []
|
| 233 |
return (
|
| 234 |
-
|
|
|
|
| 235 |
chat_display, # chatbot
|
| 236 |
chat_display, # display_state
|
| 237 |
convo, # convo_state
|
| 238 |
n_followups,
|
| 239 |
can_save, # can_save state
|
| 240 |
revision, # revision state
|
|
|
|
|
|
|
| 241 |
gr.update(visible=(panel == "email")), # email_panel
|
| 242 |
gr.update(visible=(panel == "intake")), # intake_panel
|
| 243 |
gr.update(visible=(panel == "chat")), # chat_panel
|
| 244 |
gr.update(value=status), # status_md
|
| 245 |
gr.update(visible=(panel != "email")), # switch_btn
|
| 246 |
-
gr.update(visible=False), # back_btn (review only)
|
| 247 |
gr.update(value=""), # msg — never carry a
|
| 248 |
# draft across sign-ins
|
|
|
|
|
|
|
| 249 |
*radio_updates,
|
| 250 |
)
|
| 251 |
|
|
@@ -376,6 +410,36 @@ def _latest_user_query(convo):
|
|
| 376 |
return ""
|
| 377 |
|
| 378 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
def gemini_reply(client, convo, n_followups):
|
| 380 |
"""convo: internal history list. Retrieves KB context for the latest user
|
| 381 |
turn and injects it, then asks Gemini to answer grounded in that context."""
|
|
@@ -426,26 +490,52 @@ def gemini_reply(client, convo, n_followups):
|
|
| 426 |
thinking_config=types.ThinkingConfig(thinking_budget=0),
|
| 427 |
)
|
| 428 |
resp = client.models.generate_content(model=MODEL, contents=contents, config=cfg)
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
|
| 431 |
|
| 432 |
# ----------------------------------------------------------------------
|
| 433 |
# Gradio app
|
| 434 |
# ----------------------------------------------------------------------
|
| 435 |
-
def
|
| 436 |
-
"""Sign in with
|
|
|
|
|
|
|
|
|
|
| 437 |
email = normalize_email(email_raw)
|
| 438 |
blank_radios = [gr.update(value=None) for _ in QUESTIONS]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
|
| 440 |
if not is_valid_email(email):
|
| 441 |
gr.Warning("Please enter a valid email address, for example name@example.com.")
|
| 442 |
-
return _gate_view("", "email", "",
|
|
|
|
| 443 |
|
| 444 |
# Persistence not configured — the chatbot still works, it just won't save.
|
| 445 |
if not STORE.enabled:
|
| 446 |
gr.Warning(STORE.config_hint() + ". You can continue, but nothing will be saved.")
|
| 447 |
-
return _gate_view(email, "intake",
|
| 448 |
-
|
|
|
|
| 449 |
|
| 450 |
row, err = STORE.load_session(email)
|
| 451 |
if err:
|
|
@@ -453,25 +543,43 @@ def enter_email(email_raw):
|
|
| 453 |
# a blank new session saved on top would destroy a real transcript.
|
| 454 |
gr.Warning(f"Could not reach Supabase ({err}). You can continue, but "
|
| 455 |
"nothing from this visit will be saved.")
|
| 456 |
-
return _gate_view(email, "intake",
|
| 457 |
-
|
|
|
|
| 458 |
|
| 459 |
# First time we've seen this address.
|
| 460 |
if row is None:
|
| 461 |
-
created, err = STORE.ensure_session(email)
|
| 462 |
if err:
|
| 463 |
# Without a row the questionnaire's auto-save has nothing to
|
| 464 |
# update, so it would quietly do nothing while the status line
|
| 465 |
# promised otherwise. Say so instead.
|
| 466 |
gr.Warning(f"Could not start your session in Supabase ({err}). You "
|
| 467 |
"can continue, but nothing from this visit will be saved.")
|
| 468 |
-
return _gate_view(email, "intake",
|
| 469 |
-
|
|
|
|
| 470 |
revision = int((created or {}).get("revision") or 0)
|
| 471 |
-
return _gate_view(email, "intake",
|
| 472 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
|
| 474 |
-
# Returning participant — restore whatever they had.
|
| 475 |
answers = _coerce_answers(row.get("answers"))
|
| 476 |
chat_display = _coerce_messages(row.get("chat_display"))
|
| 477 |
convo = _coerce_messages(row.get("convo"))
|
|
@@ -485,64 +593,47 @@ def enter_email(email_raw):
|
|
| 485 |
revision = 0
|
| 486 |
radio_updates = [gr.update(value=a) for a in answers]
|
| 487 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
if chat_display and convo:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
gr.Info("Welcome back — your answers and conversation have been restored.")
|
| 490 |
-
return _gate_view(email, "chat",
|
| 491 |
_status_line(email, saving=True, restored=True), radio_updates,
|
| 492 |
chat_display=chat_display, convo=convo,
|
| 493 |
n_followups=n_followups, revision=revision)
|
| 494 |
|
| 495 |
if any(a is not None for a in answers):
|
| 496 |
gr.Info("Welcome back — your saved answers have been filled in.")
|
| 497 |
-
return _gate_view(email, "intake",
|
| 498 |
_status_line(email, saving=True, restored=True),
|
| 499 |
radio_updates, revision=revision)
|
| 500 |
|
| 501 |
-
return _gate_view(email, "intake", _status_line(email, saving=True),
|
| 502 |
blank_radios, revision=revision)
|
| 503 |
|
| 504 |
|
| 505 |
def switch_email():
|
| 506 |
-
"""Sign out: clear the in-page session and go back to the
|
| 507 |
|
| 508 |
Nothing is deleted from Supabase — signing back in restores it.
|
| 509 |
"""
|
| 510 |
-
return _gate_view("", "
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
def review_answers(email, can_save, *current_values):
|
| 516 |
-
"""Show the intake form again, prefilled, without disturbing the
|
| 517 |
-
conversation. Lets a returning participant actually see (and change) the
|
| 518 |
-
answers that were restored for them.
|
| 519 |
-
|
| 520 |
-
Starts from whatever is already on the form, so a failed (or disabled)
|
| 521 |
-
Supabase read reveals the questionnaire untouched instead of wiping it.
|
| 522 |
-
"""
|
| 523 |
-
answers = [v if v in ("Yes", "No") else None for v in current_values]
|
| 524 |
-
if email and can_save and STORE.enabled:
|
| 525 |
-
row, err = STORE.load_session(email)
|
| 526 |
-
if err:
|
| 527 |
-
gr.Warning(f"Could not load your saved answers: {err}")
|
| 528 |
-
elif row:
|
| 529 |
-
stored = _coerce_answers(row.get("answers"))
|
| 530 |
-
if any(a is not None for a in stored):
|
| 531 |
-
answers = stored
|
| 532 |
-
return (
|
| 533 |
-
gr.update(visible=True), # intake_panel
|
| 534 |
-
gr.update(visible=False), # chat_panel
|
| 535 |
-
gr.update(visible=True), # back_btn
|
| 536 |
-
*[gr.update(value=a) for a in answers],
|
| 537 |
-
)
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
def back_to_chat():
|
| 541 |
-
"""Return to the conversation, which was never cleared."""
|
| 542 |
-
return (
|
| 543 |
-
gr.update(visible=False), # intake_panel
|
| 544 |
-
gr.update(visible=True), # chat_panel
|
| 545 |
-
gr.update(visible=False), # back_btn
|
| 546 |
)
|
| 547 |
|
| 548 |
|
|
@@ -562,11 +653,9 @@ def start_chat(api_key, email, can_save, revision, chat_display, convo,
|
|
| 562 |
missing = [i + 1 for i, v in enumerate(answers) if v is None]
|
| 563 |
gr.Warning(f"Please answer all 16 questions. Missing: {missing}")
|
| 564 |
# Nothing has been written yet, so a rejected submission must change
|
| 565 |
-
# nothing: hand the existing conversation state straight back.
|
| 566 |
-
# participant can reach this from "Review or change my answers" with a
|
| 567 |
-
# live conversation open.)
|
| 568 |
return (gr.update(), chat_display, convo, n_followups, revision,
|
| 569 |
-
gr.update(visible=True), gr.update(visible=False)
|
| 570 |
|
| 571 |
# Starting a follow-up replaces any previous conversation. Write the new
|
| 572 |
# answers and the cleared conversation together, before the slow model
|
|
@@ -581,7 +670,7 @@ def start_chat(api_key, email, can_save, revision, chat_display, convo,
|
|
| 581 |
if err:
|
| 582 |
gr.Warning(err)
|
| 583 |
return ([], [], [], 0, revision, gr.update(visible=True),
|
| 584 |
-
gr.update(visible=False)
|
| 585 |
|
| 586 |
# Build the knowledge base on first use (may take a bit on cold start).
|
| 587 |
ready, msg = ensure_kb(client)
|
|
@@ -608,15 +697,25 @@ def start_chat(api_key, email, can_save, revision, chat_display, convo,
|
|
| 608 |
except Exception as e:
|
| 609 |
gr.Warning(f"Gemini error: {e}")
|
| 610 |
return ([], [], [], 0, revision, gr.update(visible=True),
|
| 611 |
-
gr.update(visible=False)
|
| 612 |
|
| 613 |
convo.append({"role": "assistant", "content": reply})
|
| 614 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
|
| 616 |
revision = _persist(email, can_save, revision, answers=answers, convo=convo,
|
| 617 |
chat_display=chat_display, followups=0)
|
| 618 |
-
_log_turns(email, can_save,
|
| 619 |
-
|
|
|
|
|
|
|
| 620 |
|
| 621 |
return (
|
| 622 |
chat_display,
|
|
@@ -626,20 +725,23 @@ def start_chat(api_key, email, can_save, revision, chat_display, convo,
|
|
| 626 |
revision,
|
| 627 |
gr.update(visible=False), # intake_panel
|
| 628 |
gr.update(visible=True), # chat_panel
|
| 629 |
-
gr.update(visible=False), # back_btn — this IS the conversation now
|
| 630 |
)
|
| 631 |
|
| 632 |
|
| 633 |
def respond(user_msg, chat_display, convo, n_followups, api_key, email,
|
| 634 |
can_save, revision):
|
|
|
|
|
|
|
|
|
|
| 635 |
user_msg = (user_msg or "").strip()
|
| 636 |
if not user_msg:
|
| 637 |
-
return chat_display, chat_display, convo, n_followups, revision, ""
|
| 638 |
|
| 639 |
client, err = get_client(api_key)
|
| 640 |
if err:
|
| 641 |
gr.Warning(err)
|
| 642 |
-
return chat_display, chat_display, convo, n_followups, revision,
|
|
|
|
| 643 |
|
| 644 |
ensure_kb(client) # no-op if already built
|
| 645 |
|
|
@@ -650,7 +752,7 @@ def respond(user_msg, chat_display, convo, n_followups, api_key, email,
|
|
| 650 |
reply = gemini_reply(client, convo, n_followups)
|
| 651 |
except Exception as e:
|
| 652 |
gr.Warning(f"Gemini error: {e}")
|
| 653 |
-
return chat_display, chat_display, convo, n_followups, revision, ""
|
| 654 |
|
| 655 |
convo = convo + [{"role": "assistant", "content": reply}]
|
| 656 |
|
|
@@ -671,13 +773,32 @@ def respond(user_msg, chat_display, convo, n_followups, api_key, email,
|
|
| 671 |
{"role": "assistant", "content": display_reply, "turn": n_followups + 1},
|
| 672 |
])
|
| 673 |
|
| 674 |
-
return chat_display, chat_display, convo, n_followups + 1, revision, ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
|
| 676 |
|
| 677 |
def reset_all(email, can_save, revision):
|
| 678 |
-
"""Start Over: clear this page AND the stored session, so a
|
| 679 |
-
restarts and leaves is not dropped back into the
|
| 680 |
-
abandoned. The p3_messages transcript log is deliberately
|
|
|
|
| 681 |
revision = _persist(email, can_save, revision, answers=[None] * len(QUESTIONS),
|
| 682 |
convo=[], chat_display=[], followups=0)
|
| 683 |
radio_resets = [gr.update(value=None) for _ in QUESTIONS]
|
|
@@ -689,8 +810,9 @@ def reset_all(email, can_save, revision):
|
|
| 689 |
revision,
|
| 690 |
gr.update(visible=True), # intake_panel
|
| 691 |
gr.update(visible=False), # chat_panel
|
| 692 |
-
gr.update(visible=False), # back_btn — there is no chat to go back to
|
| 693 |
gr.update(value=""), # msg — drop any unsent draft
|
|
|
|
|
|
|
| 694 |
*radio_resets,
|
| 695 |
)
|
| 696 |
|
|
@@ -815,6 +937,43 @@ button.secondary, .gradio-container button.secondary {
|
|
| 815 |
background: #ffffff !important;
|
| 816 |
box-shadow: 0 1px 2px rgba(0,0,0,0.04) !important;
|
| 817 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
footer {visibility: hidden;}
|
| 819 |
"""
|
| 820 |
|
|
@@ -824,10 +983,11 @@ with gr.Blocks(title="MyeAI (Myeloma AI)", css=CSS,
|
|
| 824 |
neutral_hue=gr.themes.colors.gray,
|
| 825 |
font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
|
| 826 |
)) as demo:
|
| 827 |
-
with gr.
|
| 828 |
-
gr.HTML(
|
| 829 |
-
|
| 830 |
-
|
|
|
|
| 831 |
|
| 832 |
api_key = gr.State("")
|
| 833 |
convo_state = gr.State([])
|
|
@@ -841,6 +1001,10 @@ with gr.Blocks(title="MyeAI (Myeloma AI)", css=CSS,
|
|
| 841 |
revision = gr.State(0)
|
| 842 |
# Throttles the "your answers are not being saved" warning to once.
|
| 843 |
answers_warned = gr.State(False)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
|
| 845 |
status_md = gr.Markdown("", elem_id="status-line")
|
| 846 |
switch_btn = gr.Button("Use a different email", size="sm",
|
|
@@ -848,17 +1012,18 @@ with gr.Blocks(title="MyeAI (Myeloma AI)", css=CSS,
|
|
| 848 |
|
| 849 |
with gr.Group(visible=True, elem_classes="email-card") as email_panel:
|
| 850 |
gr.Markdown(
|
| 851 |
-
"**Enter your email address to begin.**\n\n"
|
| 852 |
-
"Your answers and your conversation are saved against
|
| 853 |
"can close this page and come back later to pick up exactly where you left off."
|
| 854 |
)
|
|
|
|
|
|
|
| 855 |
email_box = gr.Textbox(label="Email address", placeholder="name@example.com",
|
| 856 |
-
max_lines=1
|
| 857 |
email_btn = gr.Button("Continue", variant="primary")
|
| 858 |
|
| 859 |
with gr.Group(visible=False) as intake_panel:
|
| 860 |
gr.Markdown("**Answer all 16 questions, then click Start Follow-up.**")
|
| 861 |
-
back_btn = gr.Button("Back to my conversation", size="sm", visible=False)
|
| 862 |
radios = []
|
| 863 |
for i, q in enumerate(QUESTIONS):
|
| 864 |
with gr.Row(elem_classes="q-card"):
|
|
@@ -877,21 +1042,32 @@ with gr.Blocks(title="MyeAI (Myeloma AI)", css=CSS,
|
|
| 877 |
with gr.Row():
|
| 878 |
msg = gr.Textbox(placeholder="Type or paste your question here...", show_label=False, scale=8)
|
| 879 |
send_btn = gr.Button("Send", variant="primary", scale=1)
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 883 |
|
| 884 |
# Sign-in: one shared output list for every outcome (new / returning / error).
|
| 885 |
gate_outputs = [
|
| 886 |
-
email_state, chatbot, display_state, convo_state,
|
| 887 |
-
can_save, revision,
|
| 888 |
-
email_panel, intake_panel, chat_panel, status_md,
|
| 889 |
-
msg,
|
| 890 |
] + radios
|
|
|
|
| 891 |
|
| 892 |
-
email_btn.click(
|
| 893 |
-
|
| 894 |
-
|
|
|
|
|
|
|
| 895 |
|
| 896 |
# Save the questionnaire as it is filled in, so a half-finished intake
|
| 897 |
# survives a closed tab.
|
|
@@ -909,35 +1085,38 @@ with gr.Blocks(title="MyeAI (Myeloma AI)", css=CSS,
|
|
| 909 |
inputs=[api_key, email_state, can_save, revision,
|
| 910 |
display_state, convo_state, followups] + radios,
|
| 911 |
outputs=[chatbot, display_state, convo_state, followups, revision,
|
| 912 |
-
intake_panel, chat_panel
|
| 913 |
)
|
| 914 |
send_btn.click(
|
| 915 |
respond,
|
| 916 |
inputs=[msg, display_state, convo_state, followups, api_key, email_state,
|
| 917 |
can_save, revision],
|
| 918 |
-
outputs=[chatbot, display_state, convo_state, followups, revision, msg
|
|
|
|
| 919 |
)
|
| 920 |
msg.submit(
|
| 921 |
respond,
|
| 922 |
inputs=[msg, display_state, convo_state, followups, api_key, email_state,
|
| 923 |
can_save, revision],
|
| 924 |
-
outputs=[chatbot, display_state, convo_state, followups, revision, msg
|
|
|
|
| 925 |
)
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
|
|
|
| 930 |
)
|
| 931 |
-
|
| 932 |
-
|
| 933 |
inputs=None,
|
| 934 |
-
outputs=[
|
| 935 |
)
|
| 936 |
-
|
| 937 |
reset_all,
|
| 938 |
inputs=[email_state, can_save, revision],
|
| 939 |
outputs=[chatbot, display_state, convo_state, followups, revision,
|
| 940 |
-
intake_panel, chat_panel,
|
| 941 |
)
|
| 942 |
|
| 943 |
if __name__ == "__main__":
|
|
|
|
| 20 |
|
| 21 |
import os
|
| 22 |
import json
|
| 23 |
+
import html
|
| 24 |
|
| 25 |
# ----------------------------------------------------------------------
|
| 26 |
# ZeroGPU compatibility shim (harmless on CPU basic; skipped if not present).
|
|
|
|
| 42 |
|
| 43 |
from knowledge_base import KnowledgeBase
|
| 44 |
from supabase_store import (SupabaseStore, normalize_email, is_valid_email,
|
| 45 |
+
normalize_user_id, is_valid_user_id,
|
| 46 |
+
user_ids_match, CONFLICT)
|
| 47 |
|
| 48 |
# ----------------------------------------------------------------------
|
| 49 |
# The 16 profile questions (verbatim from the intake form)
|
|
|
|
| 227 |
return f"Signed in as **{email}**. {note}"
|
| 228 |
|
| 229 |
|
| 230 |
+
def _user_badge(user_id):
|
| 231 |
+
"""The User ID shown in the top-right corner while signed in.
|
| 232 |
+
|
| 233 |
+
The sign-in error message points participants here, so this has to be
|
| 234 |
+
present on every screen after sign-in.
|
| 235 |
+
"""
|
| 236 |
+
if not user_id:
|
| 237 |
+
return ""
|
| 238 |
+
# is_valid_user_id already rejects angle brackets and ampersands, but the
|
| 239 |
+
# badge is rendered as HTML, so escape anyway rather than depend on that.
|
| 240 |
+
return f"User ID<br><strong>{html.escape(user_id)}</strong>"
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
ANSWERS_SUMMARY_HEADING = "**Your responses to the 16 questions**"
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def build_answers_summary(answers):
|
| 247 |
+
"""The 16 questions and this participant's answers, shown as the opening
|
| 248 |
+
message of the chat so they can see the profile the conversation is built
|
| 249 |
+
on without leaving the conversation."""
|
| 250 |
+
lines = [ANSWERS_SUMMARY_HEADING, ""]
|
| 251 |
+
for i, (question, answer) in enumerate(zip(QUESTIONS, answers), start=1):
|
| 252 |
+
shown = answer if answer in ("Yes", "No") else "Not answered"
|
| 253 |
+
lines.append(f"{i}. **{shown}** — {question}")
|
| 254 |
+
return "\n".join(lines)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def _gate_view(user_id, email, panel, status, radio_updates, chat_display=None,
|
| 258 |
+
convo=None, n_followups=0, can_save=True, revision=0,
|
| 259 |
+
uid_attempts=0):
|
| 260 |
"""Build the output tuple shared by every sign-in outcome."""
|
| 261 |
chat_display = chat_display or []
|
| 262 |
convo = convo or []
|
| 263 |
return (
|
| 264 |
+
user_id, # user_id_state
|
| 265 |
+
email, # email_state
|
| 266 |
chat_display, # chatbot
|
| 267 |
chat_display, # display_state
|
| 268 |
convo, # convo_state
|
| 269 |
n_followups,
|
| 270 |
can_save, # can_save state
|
| 271 |
revision, # revision state
|
| 272 |
+
uid_attempts, # uid_attempts state
|
| 273 |
+
gr.update(value=_user_badge(user_id)), # user_badge
|
| 274 |
gr.update(visible=(panel == "email")), # email_panel
|
| 275 |
gr.update(visible=(panel == "intake")), # intake_panel
|
| 276 |
gr.update(visible=(panel == "chat")), # chat_panel
|
| 277 |
gr.update(value=status), # status_md
|
| 278 |
gr.update(visible=(panel != "email")), # switch_btn
|
|
|
|
| 279 |
gr.update(value=""), # msg — never carry a
|
| 280 |
# draft across sign-ins
|
| 281 |
+
gr.update(visible=False), # reset_confirm
|
| 282 |
+
gr.update(visible=True), # restart_btn
|
| 283 |
*radio_updates,
|
| 284 |
)
|
| 285 |
|
|
|
|
| 410 |
return ""
|
| 411 |
|
| 412 |
|
| 413 |
+
def _empty_reply_reason(resp):
|
| 414 |
+
"""Why the model returned nothing, e.g. ' (stopped early: RECITATION)'.
|
| 415 |
+
|
| 416 |
+
Best effort and never raises: it only exists to make the warning the
|
| 417 |
+
participant sees, and the Space logs, more diagnosable.
|
| 418 |
+
"""
|
| 419 |
+
def _name(value):
|
| 420 |
+
return (getattr(value, "name", None) or str(value or "")).strip()
|
| 421 |
+
|
| 422 |
+
try:
|
| 423 |
+
blocked = _name(getattr(getattr(resp, "prompt_feedback", None),
|
| 424 |
+
"block_reason", None))
|
| 425 |
+
# google-genai spells it BLOCKED_REASON_UNSPECIFIED; the older
|
| 426 |
+
# generativelanguage SDK spelled it BLOCK_REASON_UNSPECIFIED. Accept
|
| 427 |
+
# both so the "no real reason given" case always falls through to the
|
| 428 |
+
# finish_reason below rather than being reported as a block.
|
| 429 |
+
if blocked and blocked.upper() not in (
|
| 430 |
+
"BLOCKED_REASON_UNSPECIFIED", "BLOCK_REASON_UNSPECIFIED", "NONE"):
|
| 431 |
+
return f" (the question was blocked: {blocked})"
|
| 432 |
+
except Exception:
|
| 433 |
+
pass
|
| 434 |
+
try:
|
| 435 |
+
reason = _name(resp.candidates[0].finish_reason)
|
| 436 |
+
if reason and reason.upper() not in ("STOP", "FINISH_REASON_UNSPECIFIED"):
|
| 437 |
+
return f" (stopped early: {reason})"
|
| 438 |
+
except Exception:
|
| 439 |
+
pass
|
| 440 |
+
return ""
|
| 441 |
+
|
| 442 |
+
|
| 443 |
def gemini_reply(client, convo, n_followups):
|
| 444 |
"""convo: internal history list. Retrieves KB context for the latest user
|
| 445 |
turn and injects it, then asks Gemini to answer grounded in that context."""
|
|
|
|
| 490 |
thinking_config=types.ThinkingConfig(thinking_budget=0),
|
| 491 |
)
|
| 492 |
resp = client.models.generate_content(model=MODEL, contents=contents, config=cfg)
|
| 493 |
+
text = (resp.text or "").strip()
|
| 494 |
+
if not text:
|
| 495 |
+
# A response with no text part — a safety or recitation block, or a
|
| 496 |
+
# truncated candidate. `resp.text` is None here and the SDK does not
|
| 497 |
+
# raise, so without this the empty string would be committed as a blank
|
| 498 |
+
# assistant bubble and written to Supabase. Raise instead, so the
|
| 499 |
+
# callers' existing error handling rolls the turn back and tells the
|
| 500 |
+
# participant something went wrong.
|
| 501 |
+
raise RuntimeError(
|
| 502 |
+
"the model returned an empty response" + _empty_reply_reason(resp)
|
| 503 |
+
+ ". Please rephrase your question and try again.")
|
| 504 |
+
return text
|
| 505 |
|
| 506 |
|
| 507 |
# ----------------------------------------------------------------------
|
| 508 |
# Gradio app
|
| 509 |
# ----------------------------------------------------------------------
|
| 510 |
+
def sign_in(user_id_raw, email_raw, uid_attempts):
|
| 511 |
+
"""Sign in with a User ID and email, restoring a previous session if one
|
| 512 |
+
exists. The User ID is checked against the one stored for that email, so a
|
| 513 |
+
participant cannot open someone else's session by guessing an address."""
|
| 514 |
+
user_id = normalize_user_id(user_id_raw)
|
| 515 |
email = normalize_email(email_raw)
|
| 516 |
blank_radios = [gr.update(value=None) for _ in QUESTIONS]
|
| 517 |
+
keep_radios = [gr.update() for _ in QUESTIONS]
|
| 518 |
+
try:
|
| 519 |
+
uid_attempts = int(uid_attempts or 0)
|
| 520 |
+
except (TypeError, ValueError):
|
| 521 |
+
uid_attempts = 0
|
| 522 |
+
|
| 523 |
+
if not is_valid_user_id(user_id):
|
| 524 |
+
gr.Warning("Please enter your User ID to begin.")
|
| 525 |
+
return _gate_view("", "", "email", "", keep_radios,
|
| 526 |
+
uid_attempts=uid_attempts)
|
| 527 |
|
| 528 |
if not is_valid_email(email):
|
| 529 |
gr.Warning("Please enter a valid email address, for example name@example.com.")
|
| 530 |
+
return _gate_view("", "", "email", "", keep_radios,
|
| 531 |
+
uid_attempts=uid_attempts)
|
| 532 |
|
| 533 |
# Persistence not configured — the chatbot still works, it just won't save.
|
| 534 |
if not STORE.enabled:
|
| 535 |
gr.Warning(STORE.config_hint() + ". You can continue, but nothing will be saved.")
|
| 536 |
+
return _gate_view(user_id, email, "intake",
|
| 537 |
+
_status_line(email, saving=False), blank_radios,
|
| 538 |
+
can_save=False)
|
| 539 |
|
| 540 |
row, err = STORE.load_session(email)
|
| 541 |
if err:
|
|
|
|
| 543 |
# a blank new session saved on top would destroy a real transcript.
|
| 544 |
gr.Warning(f"Could not reach Supabase ({err}). You can continue, but "
|
| 545 |
"nothing from this visit will be saved.")
|
| 546 |
+
return _gate_view(user_id, email, "intake",
|
| 547 |
+
_status_line(email, saving=False), blank_radios,
|
| 548 |
+
can_save=False)
|
| 549 |
|
| 550 |
# First time we've seen this address.
|
| 551 |
if row is None:
|
| 552 |
+
created, err = STORE.ensure_session(email, user_id=user_id)
|
| 553 |
if err:
|
| 554 |
# Without a row the questionnaire's auto-save has nothing to
|
| 555 |
# update, so it would quietly do nothing while the status line
|
| 556 |
# promised otherwise. Say so instead.
|
| 557 |
gr.Warning(f"Could not start your session in Supabase ({err}). You "
|
| 558 |
"can continue, but nothing from this visit will be saved.")
|
| 559 |
+
return _gate_view(user_id, email, "intake",
|
| 560 |
+
_status_line(email, saving=False), blank_radios,
|
| 561 |
+
can_save=False)
|
| 562 |
revision = int((created or {}).get("revision") or 0)
|
| 563 |
+
return _gate_view(user_id, email, "intake",
|
| 564 |
+
_status_line(email, saving=True), blank_radios,
|
| 565 |
+
revision=revision)
|
| 566 |
+
|
| 567 |
+
# Returning participant — the User ID has to match the stored one.
|
| 568 |
+
stored_uid = normalize_user_id(row.get("user_id"))
|
| 569 |
+
if stored_uid and not user_ids_match(stored_uid, user_id):
|
| 570 |
+
attempts = uid_attempts + 1
|
| 571 |
+
if attempts >= 2:
|
| 572 |
+
gr.Warning(
|
| 573 |
+
"That User ID still does not match the one saved for this email "
|
| 574 |
+
"address. Your User ID is displayed at the top right of the "
|
| 575 |
+
"page while you are signed in — please check it there and try "
|
| 576 |
+
"again."
|
| 577 |
+
)
|
| 578 |
+
else:
|
| 579 |
+
gr.Warning("That User ID does not match the one saved for this "
|
| 580 |
+
"email address. Please check it and try again.")
|
| 581 |
+
return _gate_view("", "", "email", "", keep_radios, uid_attempts=attempts)
|
| 582 |
|
|
|
|
| 583 |
answers = _coerce_answers(row.get("answers"))
|
| 584 |
chat_display = _coerce_messages(row.get("chat_display"))
|
| 585 |
convo = _coerce_messages(row.get("convo"))
|
|
|
|
| 593 |
revision = 0
|
| 594 |
radio_updates = [gr.update(value=a) for a in answers]
|
| 595 |
|
| 596 |
+
if not stored_uid:
|
| 597 |
+
# A row created before User IDs existed, or one whose sign-in never
|
| 598 |
+
# managed to write it. Adopt the ID they just gave us.
|
| 599 |
+
revision = _persist(email, True, revision, user_id=user_id)
|
| 600 |
+
|
| 601 |
+
# Show the User ID the way it is stored, so what the badge displays is the
|
| 602 |
+
# canonical study ID rather than whatever casing was typed this time.
|
| 603 |
+
display_uid = stored_uid or user_id
|
| 604 |
+
|
| 605 |
if chat_display and convo:
|
| 606 |
+
# A conversation saved before the opening summary existed has none, so
|
| 607 |
+
# add it here: every chat should begin with the 16 questions.
|
| 608 |
+
if (any(a is not None for a in answers)
|
| 609 |
+
and not chat_display[0]["content"].startswith(ANSWERS_SUMMARY_HEADING)):
|
| 610 |
+
chat_display = [{"role": "assistant",
|
| 611 |
+
"content": build_answers_summary(answers)}] + chat_display
|
| 612 |
gr.Info("Welcome back — your answers and conversation have been restored.")
|
| 613 |
+
return _gate_view(display_uid, email, "chat",
|
| 614 |
_status_line(email, saving=True, restored=True), radio_updates,
|
| 615 |
chat_display=chat_display, convo=convo,
|
| 616 |
n_followups=n_followups, revision=revision)
|
| 617 |
|
| 618 |
if any(a is not None for a in answers):
|
| 619 |
gr.Info("Welcome back — your saved answers have been filled in.")
|
| 620 |
+
return _gate_view(display_uid, email, "intake",
|
| 621 |
_status_line(email, saving=True, restored=True),
|
| 622 |
radio_updates, revision=revision)
|
| 623 |
|
| 624 |
+
return _gate_view(display_uid, email, "intake", _status_line(email, saving=True),
|
| 625 |
blank_radios, revision=revision)
|
| 626 |
|
| 627 |
|
| 628 |
def switch_email():
|
| 629 |
+
"""Sign out: clear the in-page session and go back to the sign-in screen.
|
| 630 |
|
| 631 |
Nothing is deleted from Supabase — signing back in restores it.
|
| 632 |
"""
|
| 633 |
+
return _gate_view("", "", "email", "",
|
| 634 |
+
[gr.update(value=None) for _ in QUESTIONS]) + (
|
| 635 |
+
gr.update(value=""), # clear the User ID box
|
| 636 |
+
gr.update(value=""), # clear the email box
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
)
|
| 638 |
|
| 639 |
|
|
|
|
| 653 |
missing = [i + 1 for i, v in enumerate(answers) if v is None]
|
| 654 |
gr.Warning(f"Please answer all 16 questions. Missing: {missing}")
|
| 655 |
# Nothing has been written yet, so a rejected submission must change
|
| 656 |
+
# nothing: hand the existing conversation state straight back.
|
|
|
|
|
|
|
| 657 |
return (gr.update(), chat_display, convo, n_followups, revision,
|
| 658 |
+
gr.update(visible=True), gr.update(visible=False))
|
| 659 |
|
| 660 |
# Starting a follow-up replaces any previous conversation. Write the new
|
| 661 |
# answers and the cleared conversation together, before the slow model
|
|
|
|
| 670 |
if err:
|
| 671 |
gr.Warning(err)
|
| 672 |
return ([], [], [], 0, revision, gr.update(visible=True),
|
| 673 |
+
gr.update(visible=False))
|
| 674 |
|
| 675 |
# Build the knowledge base on first use (may take a bit on cold start).
|
| 676 |
ready, msg = ensure_kb(client)
|
|
|
|
| 697 |
except Exception as e:
|
| 698 |
gr.Warning(f"Gemini error: {e}")
|
| 699 |
return ([], [], [], 0, revision, gr.update(visible=True),
|
| 700 |
+
gr.update(visible=False))
|
| 701 |
|
| 702 |
convo.append({"role": "assistant", "content": reply})
|
| 703 |
+
# The conversation opens with the participant's own answers, so the profile
|
| 704 |
+
# the follow-up is built on is visible without leaving the chat. It is part
|
| 705 |
+
# of chat_display (what they see) but not of convo (the model already has
|
| 706 |
+
# the profile in its hidden seed turn).
|
| 707 |
+
summary = build_answers_summary(answers)
|
| 708 |
+
chat_display = [
|
| 709 |
+
{"role": "assistant", "content": summary},
|
| 710 |
+
{"role": "assistant", "content": reply},
|
| 711 |
+
]
|
| 712 |
|
| 713 |
revision = _persist(email, can_save, revision, answers=answers, convo=convo,
|
| 714 |
chat_display=chat_display, followups=0)
|
| 715 |
+
_log_turns(email, can_save, [
|
| 716 |
+
{"role": "assistant", "content": summary, "turn": 0},
|
| 717 |
+
{"role": "assistant", "content": reply, "turn": 0},
|
| 718 |
+
])
|
| 719 |
|
| 720 |
return (
|
| 721 |
chat_display,
|
|
|
|
| 725 |
revision,
|
| 726 |
gr.update(visible=False), # intake_panel
|
| 727 |
gr.update(visible=True), # chat_panel
|
|
|
|
| 728 |
)
|
| 729 |
|
| 730 |
|
| 731 |
def respond(user_msg, chat_display, convo, n_followups, api_key, email,
|
| 732 |
can_save, revision):
|
| 733 |
+
# Carrying on with the conversation means the participant is not starting
|
| 734 |
+
# over, so any pending confirmation is dismissed rather than left armed.
|
| 735 |
+
dismiss = (gr.update(visible=False), gr.update(visible=True))
|
| 736 |
user_msg = (user_msg or "").strip()
|
| 737 |
if not user_msg:
|
| 738 |
+
return (chat_display, chat_display, convo, n_followups, revision, "") + dismiss
|
| 739 |
|
| 740 |
client, err = get_client(api_key)
|
| 741 |
if err:
|
| 742 |
gr.Warning(err)
|
| 743 |
+
return (chat_display, chat_display, convo, n_followups, revision,
|
| 744 |
+
user_msg) + dismiss
|
| 745 |
|
| 746 |
ensure_kb(client) # no-op if already built
|
| 747 |
|
|
|
|
| 752 |
reply = gemini_reply(client, convo, n_followups)
|
| 753 |
except Exception as e:
|
| 754 |
gr.Warning(f"Gemini error: {e}")
|
| 755 |
+
return (chat_display, chat_display, convo, n_followups, revision, "") + dismiss
|
| 756 |
|
| 757 |
convo = convo + [{"role": "assistant", "content": reply}]
|
| 758 |
|
|
|
|
| 773 |
{"role": "assistant", "content": display_reply, "turn": n_followups + 1},
|
| 774 |
])
|
| 775 |
|
| 776 |
+
return (chat_display, chat_display, convo, n_followups + 1, revision, "") + dismiss
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
def show_reset_warning():
|
| 780 |
+
"""Start Over is destructive, so it asks first."""
|
| 781 |
+
gr.Warning("Starting over will erase your current chat history and begin a "
|
| 782 |
+
"new session.")
|
| 783 |
+
return (
|
| 784 |
+
gr.update(visible=True), # reset_confirm
|
| 785 |
+
gr.update(visible=False), # restart_btn — replaced by the choice
|
| 786 |
+
)
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
def cancel_reset():
|
| 790 |
+
"""Back out of Start Over, leaving the conversation untouched."""
|
| 791 |
+
return (
|
| 792 |
+
gr.update(visible=False), # reset_confirm
|
| 793 |
+
gr.update(visible=True), # restart_btn
|
| 794 |
+
)
|
| 795 |
|
| 796 |
|
| 797 |
def reset_all(email, can_save, revision):
|
| 798 |
+
"""Start Over, confirmed: clear this page AND the stored session, so a
|
| 799 |
+
participant who restarts and leaves is not dropped back into the
|
| 800 |
+
conversation they abandoned. The p3_messages transcript log is deliberately
|
| 801 |
+
left intact, so nothing is lost for the study."""
|
| 802 |
revision = _persist(email, can_save, revision, answers=[None] * len(QUESTIONS),
|
| 803 |
convo=[], chat_display=[], followups=0)
|
| 804 |
radio_resets = [gr.update(value=None) for _ in QUESTIONS]
|
|
|
|
| 810 |
revision,
|
| 811 |
gr.update(visible=True), # intake_panel
|
| 812 |
gr.update(visible=False), # chat_panel
|
|
|
|
| 813 |
gr.update(value=""), # msg — drop any unsent draft
|
| 814 |
+
gr.update(visible=False), # reset_confirm
|
| 815 |
+
gr.update(visible=True), # restart_btn
|
| 816 |
*radio_resets,
|
| 817 |
)
|
| 818 |
|
|
|
|
| 937 |
background: #ffffff !important;
|
| 938 |
box-shadow: 0 1px 2px rgba(0,0,0,0.04) !important;
|
| 939 |
}
|
| 940 |
+
#app-header {
|
| 941 |
+
align-items: flex-start !important;
|
| 942 |
+
}
|
| 943 |
+
#user-id-badge {
|
| 944 |
+
text-align: right !important;
|
| 945 |
+
font-size: 0.85rem !important;
|
| 946 |
+
color: #5a5a5a !important;
|
| 947 |
+
line-height: 1.3 !important;
|
| 948 |
+
}
|
| 949 |
+
#user-id-badge p {
|
| 950 |
+
color: #5a5a5a !important;
|
| 951 |
+
margin: 0 !important;
|
| 952 |
+
text-align: right !important;
|
| 953 |
+
}
|
| 954 |
+
#user-id-badge strong {
|
| 955 |
+
color: #7a0c2e !important;
|
| 956 |
+
font-size: 1rem !important;
|
| 957 |
+
letter-spacing: 0.02em;
|
| 958 |
+
}
|
| 959 |
+
button.stop, .gradio-container button.stop {
|
| 960 |
+
background: #b3261e !important;
|
| 961 |
+
color: #ffffff !important;
|
| 962 |
+
border: none !important;
|
| 963 |
+
border-radius: 6px !important;
|
| 964 |
+
font-weight: 600 !important;
|
| 965 |
+
}
|
| 966 |
+
button.stop:hover, .gradio-container button.stop:hover {
|
| 967 |
+
background: #8c1d16 !important;
|
| 968 |
+
}
|
| 969 |
+
.reset-warning {
|
| 970 |
+
border: 1px solid #7a0c2e !important;
|
| 971 |
+
border-left: 4px solid #7a0c2e !important;
|
| 972 |
+
border-radius: 8px !important;
|
| 973 |
+
padding: 14px 16px !important;
|
| 974 |
+
margin-top: 8px !important;
|
| 975 |
+
background: #fdf6f8 !important;
|
| 976 |
+
}
|
| 977 |
footer {visibility: hidden;}
|
| 978 |
"""
|
| 979 |
|
|
|
|
| 983 |
neutral_hue=gr.themes.colors.gray,
|
| 984 |
font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
|
| 985 |
)) as demo:
|
| 986 |
+
with gr.Row(elem_id="app-header"):
|
| 987 |
+
gr.HTML("<h1 id='app-title'>MyeAI (Myeloma AI)</h1>")
|
| 988 |
+
# The sign-in error for a wrong User ID points participants here, so it
|
| 989 |
+
# has to be visible on every screen once they are signed in.
|
| 990 |
+
user_badge = gr.Markdown("", elem_id="user-id-badge")
|
| 991 |
|
| 992 |
api_key = gr.State("")
|
| 993 |
convo_state = gr.State([])
|
|
|
|
| 1001 |
revision = gr.State(0)
|
| 1002 |
# Throttles the "your answers are not being saved" warning to once.
|
| 1003 |
answers_warned = gr.State(False)
|
| 1004 |
+
user_id_state = gr.State("")
|
| 1005 |
+
# Consecutive failed User ID attempts, so the second one can point the
|
| 1006 |
+
# participant at where their User ID is shown.
|
| 1007 |
+
uid_attempts = gr.State(0)
|
| 1008 |
|
| 1009 |
status_md = gr.Markdown("", elem_id="status-line")
|
| 1010 |
switch_btn = gr.Button("Use a different email", size="sm",
|
|
|
|
| 1012 |
|
| 1013 |
with gr.Group(visible=True, elem_classes="email-card") as email_panel:
|
| 1014 |
gr.Markdown(
|
| 1015 |
+
"**Enter your User ID and email address to begin.**\n\n"
|
| 1016 |
+
"Your answers and your conversation are saved against these, so you "
|
| 1017 |
"can close this page and come back later to pick up exactly where you left off."
|
| 1018 |
)
|
| 1019 |
+
user_id_box = gr.Textbox(label="User ID", placeholder="e.g. P3-014",
|
| 1020 |
+
max_lines=1, autofocus=True)
|
| 1021 |
email_box = gr.Textbox(label="Email address", placeholder="name@example.com",
|
| 1022 |
+
max_lines=1)
|
| 1023 |
email_btn = gr.Button("Continue", variant="primary")
|
| 1024 |
|
| 1025 |
with gr.Group(visible=False) as intake_panel:
|
| 1026 |
gr.Markdown("**Answer all 16 questions, then click Start Follow-up.**")
|
|
|
|
| 1027 |
radios = []
|
| 1028 |
for i, q in enumerate(QUESTIONS):
|
| 1029 |
with gr.Row(elem_classes="q-card"):
|
|
|
|
| 1042 |
with gr.Row():
|
| 1043 |
msg = gr.Textbox(placeholder="Type or paste your question here...", show_label=False, scale=8)
|
| 1044 |
send_btn = gr.Button("Send", variant="primary", scale=1)
|
| 1045 |
+
restart_btn = gr.Button("Start Over")
|
| 1046 |
+
with gr.Group(visible=False, elem_classes="reset-warning") as reset_confirm:
|
| 1047 |
+
gr.Markdown(
|
| 1048 |
+
"**Are you sure you want to start over?**\n\n"
|
| 1049 |
+
"This erases your current chat history and begins a new session. "
|
| 1050 |
+
"You will answer the 16 questions again."
|
| 1051 |
+
)
|
| 1052 |
+
with gr.Row():
|
| 1053 |
+
confirm_reset_btn = gr.Button("Yes, erase and start over",
|
| 1054 |
+
variant="stop")
|
| 1055 |
+
cancel_reset_btn = gr.Button("Cancel")
|
| 1056 |
|
| 1057 |
# Sign-in: one shared output list for every outcome (new / returning / error).
|
| 1058 |
gate_outputs = [
|
| 1059 |
+
user_id_state, email_state, chatbot, display_state, convo_state,
|
| 1060 |
+
followups, can_save, revision, uid_attempts,
|
| 1061 |
+
user_badge, email_panel, intake_panel, chat_panel, status_md,
|
| 1062 |
+
switch_btn, msg, reset_confirm, restart_btn,
|
| 1063 |
] + radios
|
| 1064 |
+
gate_inputs = [user_id_box, email_box, uid_attempts]
|
| 1065 |
|
| 1066 |
+
email_btn.click(sign_in, inputs=gate_inputs, outputs=gate_outputs)
|
| 1067 |
+
user_id_box.submit(sign_in, inputs=gate_inputs, outputs=gate_outputs)
|
| 1068 |
+
email_box.submit(sign_in, inputs=gate_inputs, outputs=gate_outputs)
|
| 1069 |
+
switch_btn.click(switch_email, inputs=None,
|
| 1070 |
+
outputs=gate_outputs + [user_id_box, email_box])
|
| 1071 |
|
| 1072 |
# Save the questionnaire as it is filled in, so a half-finished intake
|
| 1073 |
# survives a closed tab.
|
|
|
|
| 1085 |
inputs=[api_key, email_state, can_save, revision,
|
| 1086 |
display_state, convo_state, followups] + radios,
|
| 1087 |
outputs=[chatbot, display_state, convo_state, followups, revision,
|
| 1088 |
+
intake_panel, chat_panel],
|
| 1089 |
)
|
| 1090 |
send_btn.click(
|
| 1091 |
respond,
|
| 1092 |
inputs=[msg, display_state, convo_state, followups, api_key, email_state,
|
| 1093 |
can_save, revision],
|
| 1094 |
+
outputs=[chatbot, display_state, convo_state, followups, revision, msg,
|
| 1095 |
+
reset_confirm, restart_btn],
|
| 1096 |
)
|
| 1097 |
msg.submit(
|
| 1098 |
respond,
|
| 1099 |
inputs=[msg, display_state, convo_state, followups, api_key, email_state,
|
| 1100 |
can_save, revision],
|
| 1101 |
+
outputs=[chatbot, display_state, convo_state, followups, revision, msg,
|
| 1102 |
+
reset_confirm, restart_btn],
|
| 1103 |
)
|
| 1104 |
+
# Start Over warns first; only the confirmation actually erases anything.
|
| 1105 |
+
restart_btn.click(
|
| 1106 |
+
show_reset_warning,
|
| 1107 |
+
inputs=None,
|
| 1108 |
+
outputs=[reset_confirm, restart_btn],
|
| 1109 |
)
|
| 1110 |
+
cancel_reset_btn.click(
|
| 1111 |
+
cancel_reset,
|
| 1112 |
inputs=None,
|
| 1113 |
+
outputs=[reset_confirm, restart_btn],
|
| 1114 |
)
|
| 1115 |
+
confirm_reset_btn.click(
|
| 1116 |
reset_all,
|
| 1117 |
inputs=[email_state, can_save, revision],
|
| 1118 |
outputs=[chatbot, display_state, convo_state, followups, revision,
|
| 1119 |
+
intake_panel, chat_panel, msg, reset_confirm, restart_btn] + radios,
|
| 1120 |
)
|
| 1121 |
|
| 1122 |
if __name__ == "__main__":
|
supabase_schema.sql
CHANGED
|
@@ -13,6 +13,10 @@
|
|
| 13 |
-- ----------------------------------------------------------------------------
|
| 14 |
create table if not exists public.p3_sessions (
|
| 15 |
email text primary key,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
-- The 16 intake answers, in question order: ["Yes","No",...]
|
| 17 |
answers jsonb not null default '[]'::jsonb,
|
| 18 |
-- Full model-facing conversation, including the hidden profile seed turn.
|
|
@@ -38,6 +42,11 @@ alter table public.p3_sessions
|
|
| 38 |
add column if not exists revision integer not null default 0;
|
| 39 |
alter table public.p3_sessions
|
| 40 |
add column if not exists answers_count integer not null default 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-- ----------------------------------------------------------------------------
|
| 43 |
-- 2. Append-only log of every chat turn.
|
|
@@ -85,6 +94,7 @@ drop view if exists public.p3_session_overview;
|
|
| 85 |
create view public.p3_session_overview
|
| 86 |
with (security_invoker = on) as
|
| 87 |
select
|
|
|
|
| 88 |
s.email,
|
| 89 |
jsonb_array_length(s.answers) as answers_saved,
|
| 90 |
jsonb_array_length(s.chat_display) as visible_messages,
|
|
|
|
| 13 |
-- ----------------------------------------------------------------------------
|
| 14 |
create table if not exists public.p3_sessions (
|
| 15 |
email text primary key,
|
| 16 |
+
-- The participant's study User ID, entered alongside the email at sign-in.
|
| 17 |
+
-- Checked on every return visit: a mismatch is refused rather than letting
|
| 18 |
+
-- one participant open another's session by guessing an email address.
|
| 19 |
+
user_id text,
|
| 20 |
-- The 16 intake answers, in question order: ["Yes","No",...]
|
| 21 |
answers jsonb not null default '[]'::jsonb,
|
| 22 |
-- Full model-facing conversation, including the hidden profile seed turn.
|
|
|
|
| 42 |
add column if not exists revision integer not null default 0;
|
| 43 |
alter table public.p3_sessions
|
| 44 |
add column if not exists answers_count integer not null default 0;
|
| 45 |
+
alter table public.p3_sessions
|
| 46 |
+
add column if not exists user_id text;
|
| 47 |
+
|
| 48 |
+
create index if not exists p3_sessions_user_id_idx
|
| 49 |
+
on public.p3_sessions (user_id);
|
| 50 |
|
| 51 |
-- ----------------------------------------------------------------------------
|
| 52 |
-- 2. Append-only log of every chat turn.
|
|
|
|
| 94 |
create view public.p3_session_overview
|
| 95 |
with (security_invoker = on) as
|
| 96 |
select
|
| 97 |
+
s.user_id,
|
| 98 |
s.email,
|
| 99 |
jsonb_array_length(s.answers) as answers_saved,
|
| 100 |
jsonb_array_length(s.chat_display) as visible_messages,
|
supabase_store.py
CHANGED
|
@@ -78,6 +78,29 @@ def is_valid_email(value):
|
|
| 78 |
return _EMAIL_RE.match(email) is not None
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
def _now_iso():
|
| 82 |
return datetime.now(timezone.utc).isoformat()
|
| 83 |
|
|
@@ -238,7 +261,7 @@ class SupabaseStore:
|
|
| 238 |
return data[0], None
|
| 239 |
return None, None
|
| 240 |
|
| 241 |
-
def ensure_session(self, email):
|
| 242 |
"""Create the row if this email is new. Returns (row_or_None, error)."""
|
| 243 |
email = normalize_email(email)
|
| 244 |
if not email:
|
|
@@ -249,12 +272,19 @@ class SupabaseStore:
|
|
| 249 |
if row is not None:
|
| 250 |
return row, None
|
| 251 |
now = _now_iso()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
ok, data, err = self._request(
|
| 253 |
-
"POST", self.sessions_table,
|
| 254 |
-
body={"email": email, "created_at": now, "updated_at": now},
|
| 255 |
# merge-duplicates makes a concurrent double-submit harmless.
|
| 256 |
-
|
| 257 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
if not ok:
|
| 259 |
return None, err
|
| 260 |
if isinstance(data, list) and data:
|
|
@@ -262,7 +292,7 @@ class SupabaseStore:
|
|
| 262 |
return None, None
|
| 263 |
|
| 264 |
def save_session(self, email, answers=None, convo=None, chat_display=None,
|
| 265 |
-
followups=None, expected_revision=None):
|
| 266 |
"""Update the supplied fields for this email, inserting if absent.
|
| 267 |
|
| 268 |
Only fields that are not None are written, so a mid-chat save does not
|
|
@@ -291,6 +321,8 @@ class SupabaseStore:
|
|
| 291 |
payload["chat_display"] = chat_display
|
| 292 |
if followups is not None:
|
| 293 |
payload["followups"] = int(followups)
|
|
|
|
|
|
|
| 294 |
|
| 295 |
params = {"email": "eq." + email}
|
| 296 |
guarded = expected_revision is not None
|
|
@@ -305,16 +337,21 @@ class SupabaseStore:
|
|
| 305 |
if not ok:
|
| 306 |
# A project created before the hardening columns existed: drop them
|
| 307 |
# and retry rather than refusing to save at all.
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
|
|
|
| 313 |
if not ok and guarded and _missing_column(err, "revision"):
|
| 314 |
-
|
| 315 |
email, answers=answers, convo=convo,
|
| 316 |
chat_display=chat_display, followups=followups,
|
| 317 |
-
expected_revision=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
if not ok:
|
| 319 |
return False, expected_revision, err
|
| 320 |
if isinstance(data, list) and data:
|
|
@@ -413,7 +450,7 @@ class SupabaseStore:
|
|
| 413 |
return False
|
| 414 |
if stored_revision != int(expected_revision) + 1:
|
| 415 |
return False
|
| 416 |
-
for key in ("answers", "convo", "chat_display", "followups"):
|
| 417 |
if key in payload and existing.get(key) != payload[key]:
|
| 418 |
return False
|
| 419 |
return True
|
|
|
|
| 78 |
return _EMAIL_RE.match(email) is not None
|
| 79 |
|
| 80 |
|
| 81 |
+
# ----------------------------------------------------------------------
|
| 82 |
+
# User ID helpers
|
| 83 |
+
# ----------------------------------------------------------------------
|
| 84 |
+
# Study IDs vary a lot between sites, so this is permissive: it only insists on
|
| 85 |
+
# a leading letter or digit and a sane length, and rejects control characters.
|
| 86 |
+
_USER_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._@#/+-]{0,63}$")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def normalize_user_id(value):
|
| 90 |
+
"""Trimmed form, with runs of internal whitespace collapsed to one space."""
|
| 91 |
+
return re.sub(r"\s+", " ", (value or "").strip())
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def is_valid_user_id(value):
|
| 95 |
+
return _USER_ID_RE.match(normalize_user_id(value)) is not None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def user_ids_match(a, b):
|
| 99 |
+
"""Compare two User IDs. Case- and spacing-insensitive, so a participant is
|
| 100 |
+
not locked out by typing 'p3-014' instead of 'P3-014'."""
|
| 101 |
+
return normalize_user_id(a).casefold() == normalize_user_id(b).casefold()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
def _now_iso():
|
| 105 |
return datetime.now(timezone.utc).isoformat()
|
| 106 |
|
|
|
|
| 261 |
return data[0], None
|
| 262 |
return None, None
|
| 263 |
|
| 264 |
+
def ensure_session(self, email, user_id=None):
|
| 265 |
"""Create the row if this email is new. Returns (row_or_None, error)."""
|
| 266 |
email = normalize_email(email)
|
| 267 |
if not email:
|
|
|
|
| 272 |
if row is not None:
|
| 273 |
return row, None
|
| 274 |
now = _now_iso()
|
| 275 |
+
body = {"email": email, "created_at": now, "updated_at": now}
|
| 276 |
+
if user_id is not None:
|
| 277 |
+
body["user_id"] = user_id
|
| 278 |
+
prefer = "return=representation,resolution=merge-duplicates"
|
| 279 |
ok, data, err = self._request(
|
|
|
|
|
|
|
| 280 |
# merge-duplicates makes a concurrent double-submit harmless.
|
| 281 |
+
"POST", self.sessions_table, body=body, prefer=prefer)
|
| 282 |
+
if not ok and "user_id" in body and _missing_column(err, "user_id"):
|
| 283 |
+
# A project that has not re-run supabase_schema.sql yet. Create the
|
| 284 |
+
# session without the User ID rather than refusing to save at all.
|
| 285 |
+
body.pop("user_id", None)
|
| 286 |
+
ok, data, err = self._request(
|
| 287 |
+
"POST", self.sessions_table, body=body, prefer=prefer)
|
| 288 |
if not ok:
|
| 289 |
return None, err
|
| 290 |
if isinstance(data, list) and data:
|
|
|
|
| 292 |
return None, None
|
| 293 |
|
| 294 |
def save_session(self, email, answers=None, convo=None, chat_display=None,
|
| 295 |
+
followups=None, user_id=None, expected_revision=None):
|
| 296 |
"""Update the supplied fields for this email, inserting if absent.
|
| 297 |
|
| 298 |
Only fields that are not None are written, so a mid-chat save does not
|
|
|
|
| 321 |
payload["chat_display"] = chat_display
|
| 322 |
if followups is not None:
|
| 323 |
payload["followups"] = int(followups)
|
| 324 |
+
if user_id is not None:
|
| 325 |
+
payload["user_id"] = user_id
|
| 326 |
|
| 327 |
params = {"email": "eq." + email}
|
| 328 |
guarded = expected_revision is not None
|
|
|
|
| 337 |
if not ok:
|
| 338 |
# A project created before the hardening columns existed: drop them
|
| 339 |
# and retry rather than refusing to save at all.
|
| 340 |
+
for optional in ("answers_count", "user_id"):
|
| 341 |
+
if not ok and _missing_column(err, optional):
|
| 342 |
+
payload.pop(optional, None)
|
| 343 |
+
ok, data, err = self._request(
|
| 344 |
+
"PATCH", self.sessions_table, params=params, body=payload,
|
| 345 |
+
prefer="return=representation")
|
| 346 |
if not ok and guarded and _missing_column(err, "revision"):
|
| 347 |
+
retry_ok, _unused, retry_err = self.save_session(
|
| 348 |
email, answers=answers, convo=convo,
|
| 349 |
chat_display=chat_display, followups=followups,
|
| 350 |
+
user_id=user_id, expected_revision=None)
|
| 351 |
+
# The column does not exist, so there is no revision to report.
|
| 352 |
+
# Hand back the caller's own value rather than None, which would
|
| 353 |
+
# otherwise end up in the page's revision state.
|
| 354 |
+
return retry_ok, expected_revision, retry_err
|
| 355 |
if not ok:
|
| 356 |
return False, expected_revision, err
|
| 357 |
if isinstance(data, list) and data:
|
|
|
|
| 450 |
return False
|
| 451 |
if stored_revision != int(expected_revision) + 1:
|
| 452 |
return False
|
| 453 |
+
for key in ("answers", "convo", "chat_display", "followups", "user_id"):
|
| 454 |
if key in payload and existing.get(key) != payload[key]:
|
| 455 |
return False
|
| 456 |
return True
|
verify_supabase.py
CHANGED
|
@@ -18,7 +18,7 @@ import sys
|
|
| 18 |
import time
|
| 19 |
|
| 20 |
from supabase_store import (SupabaseStore, is_valid_email, normalize_email,
|
| 21 |
-
CONFLICT)
|
| 22 |
|
| 23 |
PASS, FAIL = " PASS ", " FAIL "
|
| 24 |
failures = []
|
|
@@ -62,8 +62,11 @@ def main():
|
|
| 62 |
row, err = store.load_session(email)
|
| 63 |
check("unseen email reads back as new", row is None and err is None, str(err))
|
| 64 |
|
| 65 |
-
row, err = store.ensure_session(email)
|
| 66 |
check("row can be created", row is not None, str(err))
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
partial = ["Yes", "No", "Yes"] + [None] * 13
|
| 69 |
ok, err = store.save_answers_monotonic(email, partial)
|
|
@@ -115,6 +118,13 @@ def main():
|
|
| 115 |
check("revision column present (run the latest supabase_schema.sql)",
|
| 116 |
"revision" in row, str(sorted(row))[:120])
|
| 117 |
check("answers_count column present", "answers_count" in row, str(sorted(row))[:120])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
msgs, err = store.load_messages(email)
|
| 119 |
check("message log readable", err is None and len(msgs) == 2, f"{len(msgs)} rows, {err}")
|
| 120 |
|
|
|
|
| 18 |
import time
|
| 19 |
|
| 20 |
from supabase_store import (SupabaseStore, is_valid_email, normalize_email,
|
| 21 |
+
is_valid_user_id, user_ids_match, CONFLICT)
|
| 22 |
|
| 23 |
PASS, FAIL = " PASS ", " FAIL "
|
| 24 |
failures = []
|
|
|
|
| 62 |
row, err = store.load_session(email)
|
| 63 |
check("unseen email reads back as new", row is None and err is None, str(err))
|
| 64 |
|
| 65 |
+
row, err = store.ensure_session(email, user_id="P3-VERIFY")
|
| 66 |
check("row can be created", row is not None, str(err))
|
| 67 |
+
check("User ID validator accepts the study ID", is_valid_user_id("P3-VERIFY"))
|
| 68 |
+
check("User ID stored with the session",
|
| 69 |
+
(row or {}).get("user_id") == "P3-VERIFY", str((row or {}).get("user_id")))
|
| 70 |
|
| 71 |
partial = ["Yes", "No", "Yes"] + [None] * 13
|
| 72 |
ok, err = store.save_answers_monotonic(email, partial)
|
|
|
|
| 118 |
check("revision column present (run the latest supabase_schema.sql)",
|
| 119 |
"revision" in row, str(sorted(row))[:120])
|
| 120 |
check("answers_count column present", "answers_count" in row, str(sorted(row))[:120])
|
| 121 |
+
check("user_id column present", "user_id" in row, str(sorted(row))[:120])
|
| 122 |
+
check("User ID survives the round trip", row.get("user_id") == "P3-VERIFY",
|
| 123 |
+
str(row.get("user_id")))
|
| 124 |
+
check("a returning participant's User ID would match",
|
| 125 |
+
user_ids_match(row.get("user_id"), "p3-verify"))
|
| 126 |
+
check("a wrong User ID would not match",
|
| 127 |
+
not user_ids_match(row.get("user_id"), "P3-SOMEONE-ELSE"))
|
| 128 |
msgs, err = store.load_messages(email)
|
| 129 |
check("message log readable", err is None and len(msgs) == 2, f"{len(msgs)} rows, {err}")
|
| 130 |
|