"""Gradio for the HOT No-AI Control condition. Same UI shape as socratic / explanatory (chatbot panel left, argument textbox right, end-session button) so the visual confound across the three conditions is minimised. The difference is delivery: this condition has no chat. After the participant submits their initial argument, the host code calls the same `core.perfect_answer.generate_session_artifacts` generator the bot conditions use, then renders the resulting essay as a single piece of reading material in the chatbot panel. Phases (implicit, driven by button clicks): Phase 1 (entry): chatbot shows the case; arg_input writes initial argument. Phase 2 (after Submit Initial confirm): host code runs the per-session generator (around 15-30s blocking call, status message shown). On success, two assistant messages appear: a short intro line and the ~600-word perfect-answer essay. arg_input becomes a notepad. Phase 3 (after End Session): notepad locked, done panel shown. The discussion plan is generated as a side-output of the same generator call and is logged at the top level of the session JSON for analyst use. The participant never sees it; there is no bot agenda in this condition. Schema 1.0 envelope is identical to socratic / explanatory: `condition`, `run_mode`, `case_id`, `perfect_answer`, `discussion_plan`, `feedback`, `essays`, `turns`. `turns` is always empty for this condition. """ import time import gradio as gr from core import SessionLogger, now_aest, get_next_client from core.config_loader import BASE_DIR, CONFIG # ============================================================ # Lockdown config (revise-check gate) # ============================================================ _LOCKDOWN_CFG = (CONFIG.get("lockdown") if isinstance(CONFIG, dict) else None) or {} LOCKDOWN_REVISE_CHECK = bool(_LOCKDOWN_CFG.get("revise_check", False)) print(f"[lockdown] revise_check={LOCKDOWN_REVISE_CHECK}", flush=True) # Revise-check gate: word-level similarity ratio between initial argument # and current argument. > REVISE_SIMILARITY_THRESHOLD means the participant # has not revised enough (changed less than 10% of words) — block End # Session with a modal asking them to revise more. REVISE_SIMILARITY_THRESHOLD = 0.9 import difflib as _difflib def _arg_word_similarity(initial_text, current_text): """Word-level SequenceMatcher ratio. 1.0 = identical, 0.0 = no overlap. Lowercased, whitespace-tokenised; punctuation kept on tokens.""" a = (initial_text or "").lower().split() b = (current_text or "").lower().split() if not a or not b: return 0.0 return _difflib.SequenceMatcher(None, a, b).ratio() def _revise_gate_trigger_html(): """Hidden element whose id triggers the revise-gate modal in JS.""" nonce = str(int(time.time() * 1000)) return f'' # ============================================================ # Tutorial image carousel — appears as a full-screen overlay on # participant entry. Images live in `tutorial_images/` next to this app.py, # named 01.png / 02.png / ... so they sort in display order. The folder # scan is done once at startup; each image is base64-encoded and injected # into the head as a JS array, so no runtime file serving is needed. # ============================================================ import base64 as _b64 from pathlib import Path as _Path _TUTORIAL_DIR = BASE_DIR / "tutorial_images" def _load_tutorial_images_b64(): """Scan tutorial_images/, sort by filename, return list of data URLs. Returns [] if folder is missing or empty (carousel won't render).""" if not _TUTORIAL_DIR.exists(): return [] out = [] # ONLY .png accepted — earlier deploys left stale .webp on remote that # upload_folder won't auto-clean, so narrow the loader to ignore them. exts = {".png": "image/png"} for f in sorted(_TUTORIAL_DIR.iterdir()): if not f.is_file(): continue mime = exts.get(f.suffix.lower()) if not mime: continue try: raw = f.read_bytes() enc = _b64.b64encode(raw).decode("ascii") out.append(f"data:{mime};base64,{enc}") except Exception as e: print(f"[tutorial] skipping {f.name}: {e}", flush=True) return out _TUTORIAL_IMAGES_B64 = _load_tutorial_images_b64() print(f"[tutorial] loaded {len(_TUTORIAL_IMAGES_B64)} image(s)", flush=True) def _build_tutorial_head(): """Build the """ TUTORIAL_HEAD = _build_tutorial_head() # ============================================================ # Session-recovery layer (browser-side). localStorage keys are namespaced # per condition (`non_ca_session_v1` / `non_ca_tab_id`) so other A_Ethic # Spaces opened in the same browser do not collide. Since non-ca has no chat # (the participant reads the AI-generated essay and revises their argument), # the touch-observer watches for clicks anywhere on the chatbot (covers # aha/ugh reactions) and arg_input edits to keep `last_touched` fresh. # ============================================================ RECOVERY_HEAD = """ """ # ============================================================ # Revise-check gate: modal styling + JS watcher + a flags block so the # watcher can early-return when revise_check is OFF in config. # ============================================================ _LOCKDOWN_FLAGS_JS = f"""""" REVISE_GATE_HEAD = """ """ from core.content_sync import sync_chapters_from_ssot from core.perfect_answer import generate_session_artifacts # Mirror generator inputs (case_analysis.md, ethics-frameworks.md) from # shared_content/ into local data/. Local-only, no-op on HF Space. sync_chapters_from_ssot() # ============================================================ # Content # ============================================================ CONTENT_DIR = BASE_DIR / "content" def _load(name): p = CONTENT_DIR / name if p.exists(): return p.read_text(encoding="utf-8") return f"*[Missing: content/{name}]*" CASE_TEXT = _load("case_1.md") # Display version of the case strips the leading H1 ("# Case: ...") so the # title is not shown in the chatbot panel. The LLM still receives the full # CASE_TEXT if any future flow needs it. def _strip_leading_h1(md: str) -> str: lines = md.lstrip("\n").split("\n") if lines and lines[0].lstrip().startswith("# "): return "\n".join(lines[1:]).lstrip("\n") return md CASE_TEXT_DISPLAY = _strip_leading_h1(CASE_TEXT) INTRO_MESSAGE = ( "**Thanks for submitting your initial argument.**\n\n" "Below is a more developed and refined version based on your argument. " "Read it carefully, and decide how, or whether, you want to use it " "when revising your argument on the right.\n\n" "When you are happy with your revised version, click " "**'End session & finalize'** on the right to lock your submission " "and end the session." ) # ============================================================ # UI constants # ============================================================ INITIAL_CHATBOT_VALUE = [{"role": "assistant", "content": CASE_TEXT_DISPLAY}] SUBMIT_INITIAL_LABEL = "Submit initial argument" SUBMIT_INITIAL_CONFIRM_LABEL = "✓ Click again to confirm submission" END_SESSION_LABEL = "End session & finalize · no return" END_SESSION_CONFIRM_LABEL = "✓ Click again to confirm end session" ARM_TTL = 10.0 # ============================================================ # End-of-session "completion code" modal — shown when the participant # confirms End Session. The code (5-char alphanumeric, generated in # SessionLogger.start_session) is what participants paste into the # Qualtrics survey page so the researcher can join the dialogue log to # the rest of the survey response. Same modal also appears on upload # failure (with a warning tone) so the participant can still complete # Qualtrics and so the researcher gets the code to recover manually. # ============================================================ SAVING_PANEL_HTML = ( '
' '

⏳ Submitting your session to our records.

' '

This can take up to a minute under heavy server load. ' "Please don't close the page.

" '
' ) def _completion_modal_html(flush_result): """Build the full-screen completion-code modal HTML. The participant-facing display string is `BotThanksU` + the 5-digit code (e.g. `BotThanksU23548`); the log filename and the `completion_code` field stored in the log envelope contain ONLY the 5 digits. The decorative prefix is added here at display time so the Qualtrics paste-back is recognisable as having come from the bot.""" status = (flush_result or {}).get("status", "no_logger") code = (flush_result or {}).get("completion_code") or "" display_code = f"BotThanksU{code}" if code else "UNKNOWN" if status in ("uploaded_primary", "uploaded_recovery"): title = "✅ Session complete" message = ( "Your session has been saved. Copy the completion code below " "and paste it into the Qualtrics survey page to continue." ) warning_cls = "" elif status == "all_failed": title = "⚠ Session ended" message = ( "Your session is finished, but the upload could not be confirmed. " "Copy the completion code below — paste it into the Qualtrics " "page and share it with the researcher so they can " "recover your data." ) warning_cls = " completion-warning" else: # no_logger / unexpected title = "Session ended" message = ( "Copy the code below and paste it into the Qualtrics survey " "page to continue." ) warning_cls = "" js_close = ("document.getElementById('completion-overlay-root')" ".style.display='none';") # Inline copy-to-clipboard: replace the button label with 'Copied ✓' # for 2 seconds, then restore. js_copy = ( f"var b=this;navigator.clipboard.writeText('{display_code}').then(" "function(){b.textContent='Copied \\u2713';" "setTimeout(function(){b.textContent='Copy';},2000);});" ) return ( f'
' f'
' f'' f'
{title}
' f'
{message}
' f'
' f'
{display_code}
' f'' f'
' f'
' f'
' ) COMPLETION_MODAL_CSS = """ /* End-of-session completion-code modal overlay (full-screen). */ .completion-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.55); z-index: 99999; display: flex; align-items: center; justify-content: center; padding: 20px; } .completion-modal-box { position: relative; background: white; border-radius: 12px; padding: 36px 40px 28px; max-width: 560px; width: 100%; box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35); font-size: 1.05em; line-height: 1.55; } .completion-overlay.completion-warning .completion-modal-box { border-top: 4px solid #f59e0b; } .completion-close { position: absolute; top: 8px; right: 12px; background: transparent; border: none; font-size: 1.5em; color: #6b7280; cursor: pointer; line-height: 1; padding: 4px 10px; border-radius: 4px; } .completion-close:hover { color: #111827; background: #f3f4f6; } .completion-title { font-size: 1.4em; font-weight: 600; margin-bottom: 14px; color: #16a34a; } .completion-overlay.completion-warning .completion-title { color: #b45309; } .completion-instructions { margin-bottom: 22px; color: #1f2937; } .completion-code-row { display: flex; align-items: stretch; gap: 10px; margin-bottom: 14px; } .completion-code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 1.7em; font-weight: 700; letter-spacing: 0.18em; padding: 14px 18px; background: #f3f4f6; border-radius: 8px; flex: 1; user-select: all; text-align: center; color: #111827; } .completion-copy { padding: 0 24px; background: #2563eb; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 1em; font-weight: 500; white-space: nowrap; } .completion-copy:hover { background: #1d4ed8; } .completion-note { font-size: 0.9em; color: #6b7280; } /* Inline saving panel (shown while flush is running, before the modal). */ .saving-panel { padding: 16px 22px; background: #fefce8; border: 1px solid #fde68a; border-radius: 8px; margin-top: 12px; } .saving-panel h3 { margin: 0 0 6px 0; font-size: 1.05em; font-weight: 600; } .saving-panel p { margin: 0; color: #57534e; } """ CUSTOM_CSS = """ /* Hide Gradio's built-in copy button on chatbot messages. Aha/ugh buttons live in custom HTML siblings so this selector only catches the framework's copy icon. */ #chatbot-main button[aria-label*="opy" i], #chatbot-main button[title*="opy" i], #chatbot-main .copy-button, #chatbot-main .message-buttons-copy, #chatbot-main [data-testid*="copy" i] { display: none !important; } /* Hide Gradio Chatbot top-bar chrome: the "Chatbot" label badge in the upper-left and the share / clear (trash) icons in the upper-right. show_label=False on gr.Chatbot covers the label in theory, but Gradio 6.11 still renders a label badge plus share + clear buttons (and the chatbot class has no show_share_button kwarg), so we suppress them here. */ #chatbot-main label, #chatbot-main > .label-wrap, #chatbot-main > div > .label-wrap, #chatbot-main button[aria-label*="hare" i], #chatbot-main button[aria-label*="lear" i], #chatbot-main button[aria-label*="elete" i], #chatbot-main button[aria-label*="rash" i], #chatbot-main button[title*="hare" i], #chatbot-main button[title*="lear" i], #chatbot-main button[title*="elete" i], #chatbot-main button[title*="rash" i] { display: none !important; } /* NOTE: do NOT blanket-hide .icon-button-wrapper — the aha!/ugh. buttons (repurposed Like/Dislike) live in the same wrapper class as the share/clear/delete buttons. */ /* Extend Gradio container to full browser width. */ .gradio-container, .main, .wrap { max-width: 100% !important; } .gradio-container { padding-left: 12px !important; padding-right: 12px !important; } /* Right column: keep it inside the row's equal_height stretch. */ #right-col { gap: 4px !important; } /* Argument textbox: outer block has explicit 65vh height so the inner absolute-positioned textarea has a real container to fill. */ #right-essay { height: 55vh !important; position: relative !important; padding: 0 !important; margin: 0 !important; border: 1px solid var(--border-color-primary, #d1d5db) !important; border-radius: 8px !important; background: var(--background-fill-primary, #ffffff) !important; box-sizing: border-box !important; overflow: hidden !important; } #right-essay > div, #right-essay .container, #right-essay .input-container { position: static !important; transform: none !important; padding: 0 !important; margin: 0 !important; border: none !important; background: transparent !important; height: 100% !important; width: 100% !important; } #right-essay textarea { position: absolute !important; top: 10px !important; right: 10px !important; bottom: 10px !important; left: 10px !important; width: auto !important; height: auto !important; max-height: none !important; min-height: 0 !important; margin: 0 !important; padding: 12px 14px !important; border: 1px solid var(--border-color-primary, #e5e7eb) !important; border-radius: 6px !important; background: var(--background-fill-secondary, #f9fafb) !important; font-size: 1.25em !important; line-height: 1.6 !important; resize: none !important; box-sizing: border-box !important; } /* Custom label row: strip Gradio block padding + clamp with max-height so the row is exactly one line tall. */ #arg-label-row { margin: 0 4px 0 4px !important; padding: 0 !important; border: none !important; background: transparent !important; min-height: 0 !important; max-height: 40px !important; height: auto !important; overflow: visible !important; } #arg-label-row > *, #arg-label-row > * > *, #arg-label-row > * > * > * { margin: 0 !important; padding: 0 !important; border: none !important; background: transparent !important; min-height: 0 !important; height: auto !important; } .arg-label-row { display: flex !important; justify-content: space-between !important; align-items: baseline !important; width: 100%; line-height: 1.0 !important; white-space: nowrap; } #arg-label-row .arg-label-text { font-weight: 700 !important; font-size: 1.7em !important; color: var(--body-text-color, #111827) !important; } #arg-label-row #wc-display { font-weight: 600 !important; font-size: 1.5em !important; color: #b91c1c !important; transition: color 0.15s ease; white-space: nowrap; } #arg-label-row #wc-display.sufficient { color: #15803d !important; } /* Argument-side notice (the "Do not use external AI tools" reminder placed between the Your argument label row and the textbox). Same wrapper-flattening pattern as #arg-label-row so the gr.HTML block doesn't add Gradio padding. */ #arg-notice { margin: 6px 4px 8px 4px !important; padding: 0 !important; border: none !important; background: transparent !important; min-height: 0 !important; height: auto !important; overflow: visible !important; flex: none !important; } #arg-notice > *, #arg-notice > * > *, #arg-notice > * > * > * { margin: 0 !important; padding: 0 !important; border: none !important; background: transparent !important; min-height: 0 !important; height: auto !important; overflow: visible !important; } .arg-notice { background: #fef9c3 !important; border-left: 4px solid #d97706 !important; border-radius: 6px !important; padding: 8px 12px !important; font-size: 1.08em !important; line-height: 1.4 !important; color: #422006 !important; } /* Bottom-row buttons stay one-row tall (msg input is allowed to grow — see next rule) */ #send-btn button, #bottom-action-col button { min-height: 42px !important; max-height: 42px !important; } /* Chat input auto-grows from 1 line up to ~6 lines, then scrolls internally. Pairs with lines=1, max_lines=6 on the gr.Textbox. */ #chat-input textarea { min-height: 42px !important; max-height: 160px !important; } /* Bottom-row symmetry. chat-row uses flex-end so Send stays at the bottom of the (possibly grown) input; action col stays centred. */ #chat-row { align-items: flex-end !important; justify-content: center !important; gap: 8px !important; } #bottom-action-col { align-items: center !important; justify-content: center !important; gap: 8px !important; } #send-btn { align-self: flex-end !important; flex-grow: 0 !important; } #chatbot-main .message, #chatbot-main .message p, #chatbot-main .message li, #chatbot-main .prose p, #chatbot-main .prose li, #chatbot-main .markdown p, #chatbot-main .markdown li { font-size: 1.08em !important; line-height: 1.55 !important; } #feedback-hint { margin: 8px 4px 4px 4px !important; padding: 0 !important; } #guide-col { min-width: 0; } #feedback-hint { margin: 0 !important; padding: 0 !important; } .discussion-intro { background: #fef9c3; border-left: 4px solid #d97706; border-radius: 6px; padding: 10px 14px; font-size: 1.08em; line-height: 1.55; color: #713f12; } .discussion-intro-title { font-weight: 700; font-size: 1.05em; margin-bottom: 6px; color: #422006; } .discussion-intro-line { margin: 5px 0; } .discussion-intro-bullets { margin: 4px 0 0 0; padding-left: 18px; } .discussion-intro-bullets li { margin: 2px 0; } .discussion-intro-line svg, .discussion-intro-bullets svg { vertical-align: -0.3em; margin: 0 2px; } /* Old .discussion-intro-title/body/feedback removed — replaced by compact 2-line .discussion-intro-line layout above. */ /* Force the like/dislike buttons (which we repurpose as ✨/😳) to always be visible — Gradio's default is hover-only. Larger hit area too. */ button[aria-label="Like" i], button[aria-label="Dislike" i], button[aria-label="like" i], button[aria-label="dislike" i] { opacity: 1 !important; visibility: visible !important; width: auto !important; min-width: 28px !important; } /* Highlight the currently-selected ✨/😳 button on a message. The data-emoji-active attribute is toggled by the click handler in EMOJI_SWAP_HEAD; same-emoji second click clears it (cancel). */ button[data-emoji-active="1"] { background-color: rgba(99, 102, 241, 0.18) !important; border: 1px solid rgba(99, 102, 241, 0.7) !important; border-radius: 6px !important; box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15) !important; } """ EMOJI_SWAP_HEAD = """ """ # ============================================================ # Hide HuggingFace Spaces floating widget that appears on .hf.space # direct URLs. The widget shows the repo name, a like button, and an # X to dismiss. We want it to never appear for participants. # CSS targets HF's common class patterns; JS sweeps top-level body # children that match an HF Spaces signature and removes them. # ============================================================ HIDE_HF_WIDGET_HEAD = """ """ _AHA_INLINE_SVG = '' _UGH_INLINE_SVG = '' FEEDBACK_HINT_HTML = ( '
' '
How to use this reading
' '
' '1. The AI will provide you with a discussion of the case, ' 'generated based on your argument. Read it carefully. While reading, ' 'revise your argument on the right and submit ' 'the final revised version when you\'re ready.' '
' '
' '2. Read it at your own pace; take from ' 'it what\'s useful to you.' '
' '
' '3. The reading takes a moment to generate. Please wait, ' 'it\'s being written specifically for your argument.' '
' '
' '4. While reading, you can tag passages:' '' '
' '
' ) def _label_row_html(text, *, with_counter=True): """Build the HTML for the custom label row above the argument textbox. The word-counter span has id='wc-display' so the JS in EMOJI_SWAP_HEAD can write into it on every keystroke.""" counter = ( '' '0 words (need at least 100)' '' ) if with_counter else '' return ( '
' f'{text}' f'{counter}' '
' ) ARG_NOTICE_HTML = ( '
' 'Please Do not use external AI tools or other online writing ' 'assistants. You may use only the material provided in this ' 'system.' '
' ) ARG_LABEL_PHASE1 = _label_row_html("Your argument", with_counter=True) ARG_LABEL_PHASE2 = _label_row_html( "Your argument and revision", with_counter=False, ) ARG_LABEL_LOCKED = _label_row_html( "Your argument (locked — submitted)", with_counter=False, ) # See socratic/app.py for full commentary. JS that takes over scroll behavior # from Gradio's built-in autoscroll (we set autoscroll=False on the Chatbot): # initial load → top; new content → bottom (via MutationObserver). _CHATBOT_SCROLL_JS = """ () => { const setup = (tries) => { tries = tries || 0; if (tries > 40) return; const cb = document.querySelector('#chatbot-main'); if (!cb) { setTimeout(() => setup(tries+1), 100); return; } const inner = cb.querySelector('.bubble-wrap') || cb.querySelector('[class*="bubble-wrap"]') || cb.querySelector('.message-wrap') || cb.querySelector('[class*="message-wrap"]') || cb; if (!inner || inner.scrollHeight <= inner.clientHeight + 4) { setTimeout(() => setup(tries+1), 100); return; } inner.scrollTop = 0; setTimeout(() => { inner.scrollTop = 0; }, 200); setTimeout(() => { inner.scrollTop = 0; }, 600); setTimeout(() => { inner.scrollTop = 0; }, 1200); setTimeout(() => { const obs = new MutationObserver(() => { inner.scrollTop = inner.scrollHeight; }); obs.observe(inner, { childList: true, subtree: true, characterData: true }); }, 1600); }; setup(); } """ with gr.Blocks(title="C", fill_height=True) as demo: # ====== TOP ROW: reading | argument/essay textbox ====== # guide-col (yellow feedback hint) is visible=False — collapsed by # Gradio so the chatbot panel expands to the left edge. with gr.Row(equal_height=True): with gr.Column(scale=1, elem_id="guide-col", visible=False): gr.HTML( FEEDBACK_HINT_HTML, elem_id="feedback-hint", ) with gr.Column(scale=3): chatbot = gr.Chatbot( value=INITIAL_CHATBOT_VALUE, height="65vh", group_consecutive_messages=False, autoscroll=False, show_label=False, elem_id="chatbot-main", ) with gr.Column(scale=2, elem_id="right-col"): arg_label_row = gr.HTML( ARG_LABEL_PHASE1, elem_id="arg-label-row", ) arg_notice = gr.HTML( ARG_NOTICE_HTML, elem_id="arg-notice", ) arg_input = gr.Textbox( show_label=False, placeholder=( "Type your initial argument here.\n" "You need at least 100 words to receive a more developed " "version of your argument.\n" "Start by saying whether you think the AI system is ethically " "acceptable, ethically unacceptable, or acceptable only under " "certain conditions. Then explain why." ), lines=15, max_lines=20, interactive=True, elem_id="right-essay", ) # ====== BOTTOM ROW: action buttons (right column only) ====== # Top guide-col is visible=False (collapses), so top row is 3:2. Bottom # row drops the scale=1 mirror and keeps scale=3 empty + scale=2 action # col so action buttons stay under the right-col argument textbox. with gr.Row(equal_height=True): with gr.Column(scale=3): pass # left column intentionally empty (mirrors chatbot above) with gr.Column(scale=2, elem_id="bottom-action-col"): submit_initial_btn = gr.Button( SUBMIT_INITIAL_LABEL, variant="primary", visible=True, elem_id="submit-initial-btn", ) end_session_btn = gr.Button( END_SESSION_LABEL, variant="secondary", visible=False, interactive=False, ) # Hidden elements for session-recovery (see RECOVERY_HEAD comment). # visible=True + CSS display:none keeps them in the DOM so the JS layer # can write to the textbox and click the button. recover_session_id_input = gr.Textbox( value="", visible=True, elem_id="recover-session-id-input", elem_classes=["soc-hidden-helper"], ) recover_btn = gr.Button( "recover", visible=True, elem_id="recover-btn", elem_classes=["soc-hidden-helper"], ) recovery_payload = gr.Textbox( value="", visible=True, elem_id="recovery-payload", elem_classes=["soc-hidden-helper"], ) # 2-click confirmation states arm_state = gr.State(0.0) revert_timer = gr.Timer(value=2.5, active=False) submit_initial_arm_state = gr.State(0.0) submit_initial_revert_timer = gr.Timer(value=2.5, active=False) # Periodic notepad snapshot timer (no_ai only). Fires every 60s while # active. Activated when submit_initial confirms, deactivated when # end_session confirms. Captures argument-revision trajectory in the # reading-only condition where there are no per-event triggers like # chat_send. Server-side; doesn't fire when browser tab is closed. snapshot_timer = gr.Timer(value=60.0, active=False) # Per-session SessionLogger. Lazy-init guard for gradio_client (which # bypasses demo.load); pin via the State output every handler call. logger_state = gr.State() # Hidden Textbox carrying walkthrough-finished timestamp from JS to # the on_submit_initial handler via the submit button's js= wrapper. walkthrough_ts_input = gr.Textbox( value="", visible=False, elem_id="walkthrough-ts-input", ) done_panel = gr.HTML(value="", visible=False) # ============================================================ # Handlers # ============================================================ def on_submit_initial(arg_text, history, arm_at, logger, walkthrough_ts): """Phase 1 → Phase 2 with 2-click confirmation. `walkthrough_ts` is the unix-seconds string captured client-side at the walkthrough's "Start" click; injected via the submit button's js= wrapper. Outputs (10): chatbot, arg_input, submit_initial_btn, end_session_btn, submit_initial_arm_state, submit_initial_revert_timer, logger_state, snapshot_timer, arg_label_row, recovery_payload """ if logger is None: logger = SessionLogger() now = time.time() if arm_at and (now - arm_at) <= ARM_TTL: logger.start_session() # Round-robin assign one OpenAI key from the pool for the # essay generation. Stored on the logger so the assignment # is preserved across recovery and persisted in the JSON log. try: _key_idx, _session_client = get_next_client() logger.set_assigned_key(_key_idx) logger._session_oai_client = _session_client print(f"[key_pool] session {logger.session_id} -> key_idx={_key_idx}", flush=True) except Exception as _e: logger._session_oai_client = None print(f"[key_pool] failed to assign key: {_e}", flush=True) try: ts_float = float(walkthrough_ts) if walkthrough_ts else None if ts_float and ts_float > 0: logger.walkthrough_started_at = ts_float except (ValueError, TypeError): pass logger.set_essay("argument", arg_text) logger.add_argument_snapshot( trigger="submit_initial_confirm", text=arg_text, ) # Yield 1: append participant arg + status; flip phase-2 layout; # disable both action buttons during generation. Snapshot timer # stays inactive during the 15-30s generator wait. # Also write a "submitted" JSON event into recovery_payload so # the chained .then(js=...) writes session_id to localStorage. import json as _json_local history_with_status = history + [ {"role": "user", "content": arg_text}, {"role": "assistant", "content": "*Reading your argument and preparing the discussion. " "This takes about 15-30 seconds... " "Please keep this page open and do not refresh it during this time.*"}, ] save_payload = _json_local.dumps({ "event": "submitted", "sid": logger.session_id, "code": logger.completion_code or "", }) yield ( history_with_status, gr.update(value=arg_text, placeholder=""), gr.update(visible=False, value=SUBMIT_INITIAL_LABEL, variant="primary"), gr.update(visible=True, interactive=False), 0.0, gr.update(active=False), logger, gr.update(active=False), gr.update(value=ARG_LABEL_PHASE2), gr.update(value=save_payload), ) # Generate the per-session essay + plan. try: arts = generate_session_artifacts( arg_text, client=getattr(logger, "_session_oai_client", None), ) if arts.get("perfect_answer"): logger.set_perfect_answer(arts["perfect_answer"]) if arts.get("discussion_plan"): logger.set_discussion_plan(arts["discussion_plan"]) if arts.get("dominant_framework"): logger.set_dominant_framework(arts["dominant_framework"]) print(f"[framework] {arts['dominant_framework']}", flush=True) essay = arts.get("perfect_answer") or "" except Exception as e: print(f"[session_artifacts] generation failed: {e}", flush=True) essay = "" # Yield 2: clear status, show two assistant messages (intro + essay), # enable end-session button, START the 60s notepad-snapshot timer. if essay: history_final = history + [ {"role": "user", "content": arg_text}, {"role": "assistant", "content": INTRO_MESSAGE}, {"role": "assistant", "content": essay}, ] else: # Fallback if generation failed: show a graceful note. history_final = history + [ {"role": "user", "content": arg_text}, {"role": "assistant", "content": "*Discussion content could not be prepared. " "Please end the session.*"}, ] yield ( history_final, gr.update(), gr.update(), gr.update(interactive=True), gr.update(), gr.update(), logger, gr.update(active=True), gr.update(), gr.update(), ) return if not arg_text.strip(): gr.Warning("Please write your argument before submitting.") yield ( gr.update(), gr.update(), gr.update(), gr.update(), arm_at, gr.update(), logger, gr.update(), gr.update(), gr.update(), ) return # ARM yield ( gr.update(), gr.update(), gr.update(value=SUBMIT_INITIAL_CONFIRM_LABEL, variant="stop"), gr.update(), now, gr.update(active=True), logger, gr.update(), gr.update(), gr.update(), ) def on_submit_initial_tick(arm_at): if not arm_at: return arm_at, gr.update(active=False), gr.update() if (time.time() - arm_at) > ARM_TTL: return ( 0.0, gr.update(active=False), gr.update(value=SUBMIT_INITIAL_LABEL, variant="primary"), ) return arm_at, gr.update(), gr.update() def on_end_session(arm_at, arg_text, logger): """2-click end-session/finalize. Generator: yields a 'saving…' state first, then a final status reflecting whether the upload succeeded. CONFIRM also stops the 60s periodic snapshot timer. The 8th output (recovery_payload) carries an "ended" event JSON on the CONFIRM second yield, which the chained .then(js=...) reads to clear localStorage. Other yields leave it untouched. Outputs (8): arg_input, end_session_btn, done_panel, arm_state, revert_timer, snapshot_timer, arg_label_row, recovery_payload """ now = time.time() is_first_click = not (arm_at and (now - arm_at) <= ARM_TTL) # Revise-check gate. Run only on first click and only when the # `lockdown.revise_check` config flag is on. Compare current arg_text # against the initial argument; if word-level similarity is > 0.9 # (i.e. the participant changed less than ~10%), show a modal asking # them to revise more before finalising. if LOCKDOWN_REVISE_CHECK and is_first_click and logger is not None: initial_text = ( (logger.essays.get("argument") if logger.essays else None) or {} ).get("text", "") sim = _arg_word_similarity(initial_text, arg_text) print( f"[revise-check] similarity={sim:.3f} " f"threshold={REVISE_SIMILARITY_THRESHOLD}", flush=True, ) if initial_text and sim > REVISE_SIMILARITY_THRESHOLD: yield ( gr.update(), gr.update(), gr.update(value=_revise_gate_trigger_html(), visible=True), arm_at, gr.update(), gr.update(), gr.update(), gr.update(), # recovery_payload (no event) ) return if arm_at and (now - arm_at) <= ARM_TTL: yield ( gr.update(interactive=False), gr.update(visible=False, value=END_SESSION_LABEL, variant="secondary"), gr.update(value=SAVING_PANEL_HTML, visible=True), 0.0, gr.update(active=False), gr.update(active=False), gr.update(value=ARG_LABEL_LOCKED), gr.update(), # recovery_payload (clear happens in next yield) ) flush_result = {"status": "no_logger", "session_id": None, "completion_code": None} if logger is not None: logger.add_argument_snapshot( trigger="end_session_confirm", text=arg_text, ) flush_result = logger.flush() modal_html = _completion_modal_html(flush_result) import json as _json_local yield ( gr.update(), gr.update(), gr.update(value=modal_html, visible=True), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(value=_json_local.dumps({"event": "ended"})), ) return if logger is not None: logger.add_argument_snapshot( trigger="end_session_arm", text=arg_text, ) yield ( gr.update(), gr.update(value=END_SESSION_CONFIRM_LABEL, variant="stop"), gr.update(), now, gr.update(active=True), gr.update(), gr.update(), gr.update(), # recovery_payload (no event) ) def on_timer_tick(arm_at): if not arm_at: return arm_at, gr.update(active=False), gr.update() if (time.time() - arm_at) > ARM_TTL: return ( 0.0, gr.update(active=False), gr.update(value=END_SESSION_LABEL, variant="secondary"), ) return arm_at, gr.update(), gr.update() def on_snapshot_tick(arg_text, logger): """Fires every 60s while snapshot_timer is active (between submit_initial_confirm and end_session_confirm). Records the current notepad state with trigger='periodic_1min'. Pure local + HF persistence; no LLM involvement. Intended for the no_ai control where the participant is reading the perfect-answer essay and may revise their argument over many minutes without producing chat / feedback events that would otherwise trigger snapshots.""" if logger is None or not logger.session_id: return logger.add_argument_snapshot( trigger="periodic_1min", text=arg_text, ) def on_feedback(arg_text, logger, data: gr.LikeData): """Every click is recorded as a timestamped event in the feedback list; clicking the already-active emoji cancels the reaction (logged as kind='none'). Logger derives feedback_current at upload time. Also snapshots the notepad textbox at click time for revision tracking.""" if logger is None: return new_kind = "aha" if data.liked else "stress" idx = data.index if isinstance(data.index, int) else ( data.index[0] if data.index else -1 ) current = logger.get_current_kind(idx) kind = "none" if current == new_kind else new_kind def _flatten(v): if isinstance(v, str): return v if isinstance(v, dict): return v.get("text") or v.get("content") or "" if isinstance(v, list): return " ".join(_flatten(x) for x in v if x) return str(v or "") excerpt = _flatten(data.value).strip() logger.add_feedback(message_index=idx, kind=kind, message_excerpt=excerpt[:200]) logger.add_argument_snapshot( trigger="feedback_click", text=arg_text, context={"message_index": idx, "kind": kind}, ) def on_recover_session(session_id_str): """Session-recovery handler. Fired by the hidden recover_btn that JS clicks programmatically when the participant chooses 'Continue' on the recovery modal. no_ai has no chat turns — sessions consist of the reference essay (perfect_answer) plus the participant's argument-revision history. Recovery rebuilds the chatbot as [case, user_arg, INTRO_MESSAGE, perfect_answer], restores the argument textbox to the latest snapshot, re-enables End Session, and re-starts the 60s notepad-snapshot timer. Outputs (10): same shape as on_submit_initial — chatbot, arg_input, submit_initial_btn, end_session_btn, submit_initial_arm_state, submit_initial_revert_timer, logger_state, snapshot_timer, arg_label_row, recovery_payload """ import json as _json sid = (session_id_str or "").strip() def _err(reason): print(f"[recover] FAIL ({reason}); sid={sid!r}", flush=True) outs = list(gr.update() for _ in range(9)) outs.append(gr.update(value=_json.dumps({ "event": "error", "reason": reason }))) return tuple(outs) if not sid: return _err("empty session_id") try: from core.config_loader import ( hf_api as _hf_api, HF_DATASET, HF_TOKEN, LOG_PATH, ) if not _hf_api or not HF_DATASET: return _err("HF api/dataset not configured") files = _hf_api.list_repo_files(HF_DATASET, repo_type="dataset") mode_prefix = LOG_PATH.rsplit("/", 1)[0] + "/" if "/" in LOG_PATH else "" target = next( (f for f in files if f.endswith(".json") and f"/{sid}_" in f and (not mode_prefix or f.startswith(mode_prefix))), None, ) if not target: print(f"[recover] sid={sid!r} not in {len(files)} files; " f"LOG_PATH={LOG_PATH!r}", flush=True) return _err(f"session file not found (looked for /{sid}_*.json)") print(f"[recover] matched {target}", flush=True) from huggingface_hub import hf_hub_download local = hf_hub_download( repo_id=HF_DATASET, filename=target, repo_type="dataset", token=HF_TOKEN, force_download=True, ) data = _json.loads(_Path(local).read_text(encoding="utf-8")) except Exception as e: return _err(f"{type(e).__name__}: {e}") new_logger = SessionLogger.from_dict(data) if new_logger is None: return _err("from_dict returned None (malformed payload)") # Restore the per-session OpenAI client by looking up the assigned # key index in the current pool. If we can't find a match, get a # fresh round-robin assignment. from core.config_loader import KEY_POOL as _KP _restored_client = None for _idx, _c in _KP: if _idx == new_logger.assigned_key_idx: _restored_client = _c break if _restored_client is None: try: _new_idx, _restored_client = get_next_client() new_logger.set_assigned_key(_new_idx) print(f"[recover] re-assigned key_idx={_new_idx}", flush=True) except Exception as _e: print(f"[recover] could not assign client: {_e}", flush=True) new_logger._session_oai_client = _restored_client initial_arg = (data.get("essays") or {}).get("argument", {}).get("text", "") pa = data.get("perfect_answer") or {} essay = pa.get("text", "") # Rebuild the chatbot: case display + user argument + intro + essay # (mirroring on_submit_initial yield 2). If essay generation had # failed in the original session, fall back to the same note shown # at that time. history = list(INITIAL_CHATBOT_VALUE) + [ {"role": "user", "content": initial_arg}, ] if essay: history.append({"role": "assistant", "content": INTRO_MESSAGE}) history.append({"role": "assistant", "content": essay}) else: history.append({ "role": "assistant", "content": "*Discussion content could not be prepared. " "Please end the session.*", }) snapshots = data.get("argument_snapshots") or [] arg_text = snapshots[-1].get("text") if snapshots else initial_arg print(f"[recover] OK: sid={sid}, " f"essay={'yes' if essay else 'no'}, " f"{len(data.get('feedback') or [])} feedback events, " f"{len(snapshots)} snapshots", flush=True) save_payload = _json.dumps({ "event": "submitted", "sid": new_logger.session_id, "code": new_logger.completion_code or "", }) return ( history, gr.update(value=arg_text, placeholder=""), gr.update(visible=False, value=SUBMIT_INITIAL_LABEL, variant="primary"), gr.update(visible=True, interactive=True, variant="secondary", value=END_SESSION_LABEL), 0.0, gr.update(active=False), new_logger, gr.update(active=True), # restart snapshot_timer gr.update(value=ARG_LABEL_PHASE2), gr.update(value=save_payload), ) # ============================================================ # Wire events # ============================================================ initial_outputs = [ chatbot, arg_input, submit_initial_btn, end_session_btn, ] _RECOVERY_SAVE_JS = """(payload) => { console.log('[recovery] .then(js) received payload:', payload); if (!payload) return payload; try { const obj = JSON.parse(payload); if (obj.event === 'submitted' && obj.sid && window.__RECOVERY) { window.__RECOVERY.saveSession(obj.sid, obj.code || ''); window.__RECOVERY.dismissModalIfLoading(); } else if (obj.event === 'error' && window.__RECOVERY) { console.error('[recovery] server returned error:', obj.reason); window.__RECOVERY.markModalError(obj.reason || 'unknown'); } } catch (e) { console.warn('[recovery] save-js parse failed', e, 'payload:', payload); } return payload; }""" _RECOVERY_CLEAR_JS = """(payload) => { if (!payload) return payload; try { const obj = JSON.parse(payload); if (obj.event === 'ended' && window.__RECOVERY) { window.__RECOVERY.clearState(); } } catch (e) {} return payload; }""" submit_initial_btn.click( on_submit_initial, inputs=[arg_input, chatbot, submit_initial_arm_state, logger_state, walkthrough_ts_input], outputs=initial_outputs + [submit_initial_arm_state, submit_initial_revert_timer, logger_state, snapshot_timer, arg_label_row, recovery_payload], js="""(arg_text, history, arm_at, logger, ts_in) => { var ts = window.__walkthroughStartedAt; return [arg_text, history, arm_at, logger, ts ? String(ts) : '']; }""", ).then( fn=None, inputs=[recovery_payload], outputs=[recovery_payload], js=_RECOVERY_SAVE_JS, ) recover_btn.click( on_recover_session, inputs=[recover_session_id_input], outputs=initial_outputs + [submit_initial_arm_state, submit_initial_revert_timer, logger_state, snapshot_timer, arg_label_row, recovery_payload], ).then( fn=None, inputs=[recovery_payload], outputs=[recovery_payload], js=_RECOVERY_SAVE_JS, ) submit_initial_revert_timer.tick( on_submit_initial_tick, inputs=[submit_initial_arm_state], outputs=[submit_initial_arm_state, submit_initial_revert_timer, submit_initial_btn], ) end_session_btn.click( on_end_session, inputs=[arm_state, arg_input, logger_state], outputs=[arg_input, end_session_btn, done_panel, arm_state, revert_timer, snapshot_timer, arg_label_row, recovery_payload], ).then( fn=None, inputs=[recovery_payload], outputs=[recovery_payload], js=_RECOVERY_CLEAR_JS, ) revert_timer.tick( on_timer_tick, inputs=[arm_state], outputs=[arm_state, revert_timer, end_session_btn], ) # 60s periodic notepad snapshot — only the no_ai condition needs this # because the reading flow has no chat_send / few feedback events to drive # event-based snapshots. Active between submit_initial_confirm (yield 2) # and end_session_confirm. snapshot_timer.tick( on_snapshot_tick, inputs=[arg_input, logger_state], outputs=None, ) # ✨ aha / 😳 stress reactions on the perfect-answer essay (and the # intro message; participants may click on either, both are logged). # arg_input passed so notepad gets snapshotted at click time. chatbot.like(on_feedback, inputs=[arg_input, logger_state], outputs=None) demo.load( fn=lambda: SessionLogger(), inputs=None, outputs=logger_state, js=_CHATBOT_SCROLL_JS, ) def _start_auto_restart(): """Background thread: poll the HF dataset for the latest session log; if the latest is older than `idle_threshold_hours`, restart the Space. Same machinery as socratic / explanatory.""" import os import threading from datetime import datetime from core.config_loader import CONFIG, HF_TOKEN, HF_DATASET, LOG_PATH from core import AEST cfg = CONFIG.get("auto_restart") or {} env_override = os.environ.get("AUTO_RESTART_ENABLED", "").strip().lower() enabled = (env_override in {"1", "true", "yes"}) or (env_override == "" and cfg.get("enabled")) if not enabled: return if not (HF_TOKEN and HF_DATASET): print("[auto-restart] HF token / dataset missing — skipping.", flush=True) return space_id = cfg.get("space_id") if not space_id: return check_hours = cfg.get("check_interval_hours", 12) idle_hours = cfg.get("idle_threshold_hours", 24) from huggingface_hub import HfApi api = HfApi(token=HF_TOKEN) # Thread-uptime fallback: if the dataset has no logs yet (fresh Space, no # participants) we still want to restart once the Space itself has been # running ≥ idle_hours, otherwise zero-log Spaces never auto-recover. started_at = now_aest() def _check_and_restart(): while True: time.sleep(check_hours * 3600) now = now_aest() print(f"[auto-restart] {now.strftime('%Y-%m-%d %H:%M:%S')} — idle check…", flush=True) try: files = api.list_repo_files(HF_DATASET, repo_type="dataset") log_files = sorted( f for f in files if f.startswith(LOG_PATH + "/") and f.endswith(".json") ) if log_files: latest = log_files[-1].rsplit("/", 1)[-1].removesuffix(".json") # Filenames are "__" — strptime the timestamp only. ts = "_".join(latest.split("_")[:2]) latest_dt = datetime.strptime(ts, "%Y%m%d_%H%M%S").replace(tzinfo=AEST) idle_h = (now - latest_dt).total_seconds() / 3600 src = f"latest log {latest}" else: idle_h = (now - started_at).total_seconds() / 3600 src = "no logs yet — using thread uptime" print(f"[auto-restart] Idle source: {src} ({idle_h:.1f}h ago)", flush=True) if idle_h > idle_hours: print(f"[auto-restart] Idle {idle_h:.1f}h > {idle_hours}h — restarting {space_id}.", flush=True) api.restart_space(space_id) else: print(f"[auto-restart] Active within {idle_hours}h — no restart.", flush=True) except Exception as e: print(f"[auto-restart] Check failed: {e}", flush=True) threading.Thread(target=_check_and_restart, daemon=True).start() print(f"[auto-restart] Started — checking every {check_hours}h, " f"restart if idle > {idle_hours}h, watching {LOG_PATH}/", flush=True) if __name__ == "__main__": _start_auto_restart() demo.queue(default_concurrency_limit=None).launch( css=CUSTOM_CSS + COMPLETION_MODAL_CSS, head=_LOCKDOWN_FLAGS_JS + EMOJI_SWAP_HEAD + HIDE_HF_WIDGET_HEAD + RECOVERY_HEAD + TUTORIAL_HEAD + REVISE_GATE_HEAD, )