stevafernandes commited on
Commit
f036e7a
·
verified ·
1 Parent(s): 0748ef8

Upload 5 files

Browse files
Files changed (5) hide show
  1. README.md +29 -11
  2. app.py +286 -107
  3. supabase_schema.sql +10 -0
  4. supabase_store.py +51 -14
  5. 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 are identified by their email address. Everything they do is saved
38
- against that address, so closing the page and returning later — even on another
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 16 answers, the conversation
76
- the participant sees, the full model-facing conversation, and the follow-up
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 email before anything else.
88
- - A returning participant with a saved conversation is taken straight back into
89
- it; **Review or change my answers** shows their restored questionnaire.
 
 
 
 
 
 
90
  - The questionnaire auto-saves as it is filled in, so a half-finished intake
91
  survives a closed tab.
92
- - **Start Over** clears both the page and the stored session (the transcript log
93
- is kept). **Use a different email** just signs out; nothing is deleted.
 
 
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
- CONFLICT)
 
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 _gate_view(email, panel, status, radio_updates, chat_display=None,
229
- convo=None, n_followups=0, can_save=True, revision=0):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  """Build the output tuple shared by every sign-in outcome."""
231
  chat_display = chat_display or []
232
  convo = convo or []
233
  return (
234
- email,
 
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
- return (resp.text or "").strip()
 
 
 
 
 
 
 
 
 
 
 
430
 
431
 
432
  # ----------------------------------------------------------------------
433
  # Gradio app
434
  # ----------------------------------------------------------------------
435
- def enter_email(email_raw):
436
- """Sign in with an email address, restoring a previous session if one exists."""
 
 
 
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", "", [gr.update() for _ in QUESTIONS])
 
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", _status_line(email, saving=False),
448
- blank_radios, can_save=False)
 
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", _status_line(email, saving=False),
457
- blank_radios, can_save=False)
 
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", _status_line(email, saving=False),
469
- blank_radios, can_save=False)
 
470
  revision = int((created or {}).get("revision") or 0)
471
- return _gate_view(email, "intake", _status_line(email, saving=True),
472
- blank_radios, revision=revision)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 email screen.
507
 
508
  Nothing is deleted from Supabase — signing back in restores it.
509
  """
510
- return _gate_view("", "email", "", [gr.update(value=None) for _ in QUESTIONS]) + (
511
- gr.update(value=""), # clear the email textbox
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. (A
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), gr.update())
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), 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), gr.update(visible=False))
612
 
613
  convo.append({"role": "assistant", "content": reply})
614
- chat_display = [{"role": "assistant", "content": reply}]
 
 
 
 
 
 
 
 
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
- [{"role": "assistant", "content": reply, "turn": 0}])
 
 
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, user_msg
 
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 participant who
679
- restarts and leaves is not dropped back into the conversation they
680
- abandoned. The p3_messages transcript log is deliberately left intact."""
 
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.Column(elem_id="app-header"):
828
- gr.HTML(
829
- "<h1 id='app-title'>MyeAI (Myeloma AI)</h1>"
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 this address, so you "
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, autofocus=True)
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
- with gr.Row():
881
- review_btn = gr.Button("Review or change my answers")
882
- restart_btn = gr.Button("Start Over")
 
 
 
 
 
 
 
 
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, followups,
887
- can_save, revision,
888
- email_panel, intake_panel, chat_panel, status_md, switch_btn, back_btn,
889
- msg,
890
  ] + radios
 
891
 
892
- email_btn.click(enter_email, inputs=[email_box], outputs=gate_outputs)
893
- email_box.submit(enter_email, inputs=[email_box], outputs=gate_outputs)
894
- switch_btn.click(switch_email, inputs=None, outputs=gate_outputs + [email_box])
 
 
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, back_btn],
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
- review_btn.click(
927
- review_answers,
928
- inputs=[email_state, can_save] + radios,
929
- outputs=[intake_panel, chat_panel, back_btn] + radios,
 
930
  )
931
- back_btn.click(
932
- back_to_chat,
933
  inputs=None,
934
- outputs=[intake_panel, chat_panel, back_btn],
935
  )
936
- restart_btn.click(
937
  reset_all,
938
  inputs=[email_state, can_save, revision],
939
  outputs=[chatbot, display_state, convo_state, followups, revision,
940
- intake_panel, chat_panel, back_btn, msg] + radios,
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
- prefer="return=representation,resolution=merge-duplicates",
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
- if _missing_column(err, "answers_count"):
309
- payload.pop("answers_count", None)
310
- ok, data, err = self._request(
311
- "PATCH", self.sessions_table, params=params, body=payload,
312
- prefer="return=representation")
 
313
  if not ok and guarded and _missing_column(err, "revision"):
314
- return self.save_session(
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