"""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 (`answer_ca_session_v1` / `answer_ca_tab_id`) so other
# A_Ethic Spaces opened in the same browser do not collide. The touch-observer
# watches for clicks anywhere on the chatbot (covers aha/ugh reactions) and
# arg_input edits and send-button clicks 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
from core.config_loader import client as OAI_CLIENT, MODEL as OAI_MODEL, GENERAL_CHAT_PROMPT
# 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.** You may optionally continue "
"chatting with the AI assistant if you want to ask about the generated "
"version or explore the issue further.\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:'
'
'
'
Click ' + _AHA_INLINE_SVG + ' if a passage helped'
' you notice or rethink something new.
'
'
Click ' + _UGH_INLINE_SVG + ' if a passage felt '
'frustrating or off.
'
'
If you do not feel either reaction, you can also leave it '
'unclicked.
'
'
'
'
'
'
'
)
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="B", 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. After that, you may chat with the AI assistant "
"if you wish.\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):
# Chat input + Send button. Initially disabled; enabled after the
# perfect-answer essay is shown so the participant can chat with
# the AI assistant about the generated version.
with gr.Row(elem_id="chat-row"):
msg_input = gr.Textbox(
placeholder="Submit your initial argument first to enable chat",
show_label=False,
interactive=False,
scale=6,
lines=1,
max_lines=6,
elem_id="chat-input",
)
send_btn = gr.Button(
"Send", interactive=False, scale=1, variant="primary",
elem_id="send-btn",
)
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. Stored on
# the logger so the same key is used for essay + chat across
# the whole session, and reproducible from the JSON log via
# the `assigned_key_idx` field.
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,
)
# Trigger ③: persist initial argument + walkthrough timing to HF.
# Protects users who submit initial then close tab before any
# chat turn happens (otherwise the only push would be at first
# turn completion).
logger.checkpoint()
# 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),
gr.update(interactive=False,
placeholder="Preparing the discussion…"),
gr.update(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 = ""
finally:
# Trigger ① for artifact-gen step: persist PA + plan + framework
# together. Without this, the next push would not happen until
# the user's first chat turn — losing artifacts if user closes
# tab between submit and first turn.
try:
logger.checkpoint()
except Exception as e:
print(f"[session_artifacts] checkpoint failed: {e}", flush=True)
# 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(interactive=True,
placeholder="Type your message to the AI assistant…"),
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(),
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(),
gr.update(),
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 (10): arg_input, end_session_btn, done_panel,
arm_state, revert_timer, snapshot_timer, arg_label_row,
recovery_payload, msg_input, send_btn
"""
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)
gr.update(), # msg_input
gr.update(), # send_btn
)
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)
gr.update(interactive=False), # msg_input
gr.update(interactive=False), # send_btn
)
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"})),
gr.update(), # msg_input
gr.update(), # send_btn
)
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)
gr.update(), # msg_input
gr.update(), # send_btn
)
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(11))
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 against the current pool. If the index is unknown, fall
# back to the round-robin counter (logged on the new logger so the
# next upload reflects reality).
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)
# Trigger ⑤: persist the 'session_resumed' snapshot that from_dict()
# appended in-memory. Without this, a second tab-close before the
# next chat turn would lose the resume marker.
try:
new_logger.checkpoint()
except Exception as e:
print(f"[recover] checkpoint failed: {e}", 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),
gr.update(interactive=True,
placeholder="Type your message to the AI assistant…"),
gr.update(interactive=True),
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),
)
# ============================================================
# Post-essay general chat (B condition)
# ============================================================
#
# After perfect_answer is shown, msg_input + send_btn become interactive.
# The participant can chat freely with an AI assistant about the case
# and the developed version. Each turn:
# 1. Build a system prompt with case_text + initial_argument +
# perfect_answer substituted in. The discussion_plan is NEVER
# exposed.
# 2. Append the full prior chat history (user/assistant alternation)
# from the chatbot display.
# 3. Append the participant's new message.
# 4. Call the LLM; append the assistant's reply to the chatbot.
# 5. Log the turn via SessionLogger.add_turn (manager_verdict=None,
# plan_control=None, since there is no manager in this condition).
def _build_general_chat_messages(logger, history, user_msg):
"""Assemble OpenAI chat messages for the post-essay chat turn.
history is the gradio Chatbot value (list of {role, content} dicts).
It already includes the case display, the user's initial argument,
INTRO_MESSAGE, and the essay. We slice that history into a system
prompt + role-alternated messages.
"""
# The initial argument and perfect_answer are stored on the logger
# at submit time; fall back to scanning history if missing.
initial_arg = ""
perfect_answer = ""
try:
initial_arg = (logger.essays or {}).get("argument", {}).get("text", "")
except Exception:
initial_arg = ""
try:
pa = getattr(logger, "perfect_answer", None) or {}
perfect_answer = pa.get("text", "") if isinstance(pa, dict) else (pa or "")
except Exception:
perfect_answer = ""
system_text = GENERAL_CHAT_PROMPT.format(
case_text=CASE_TEXT,
initial_argument=initial_arg,
perfect_answer=perfect_answer,
)
# Convert the displayed chat into the OpenAI alternation. Skip the
# case-display block (the first INITIAL_CHATBOT_VALUE entries),
# the user's initial argument, the INTRO_MESSAGE, and the essay
# itself — they are already baked into the system prompt.
#
# We keep only assistant/user pairs that came AFTER the essay was
# rendered. Heuristic: the essay assistant message is the one whose
# content equals the perfect_answer text. Drop everything up to and
# including it.
chat_msgs = []
post_essay = False
for m in history:
if not isinstance(m, dict):
continue
role = m.get("role")
content = m.get("content", "")
if not post_essay:
if role == "assistant" and content == perfect_answer:
post_essay = True
continue
if role in ("user", "assistant") and content:
chat_msgs.append({"role": role, "content": content})
messages = [{"role": "system", "content": system_text}]
messages.extend(chat_msgs)
messages.append({"role": "user", "content": user_msg})
return messages
def on_send(msg, history, logger, arg_text):
"""Post-essay chat turn. msg is the participant's new message; history
is the current chatbot list (already includes prior turns). arg_text
snapshots the current argument textbox for the logger."""
if not msg.strip():
yield history, "", logger
return
if logger is None:
logger = SessionLogger()
# Snapshot the argument on chat_send (lets us track revision activity
# at message-level granularity, complementing the 60s timer).
try:
logger.add_argument_snapshot(
trigger="chat_send",
text=arg_text or "",
context={
"next_turn_n": len(logger.turns) + 1,
"user_msg_excerpt": msg[:120],
},
)
except Exception as e:
print(f"[on_send] snapshot failed: {e}", flush=True)
# Show the user message immediately.
history = history + [{"role": "user", "content": msg}]
yield history, "", logger
# Build and dispatch. Use the per-session OpenAI client (assigned
# at session start via get_next_client()) if available; otherwise
# fall back to the module-level default.
_api = getattr(logger, "_session_oai_client", None) or OAI_CLIENT
t0 = time.perf_counter()
try:
try:
messages = _build_general_chat_messages(logger, history[:-1], msg)
resp = _api.chat.completions.create(
model=OAI_MODEL,
messages=messages,
reasoning_effort=CONFIG.get("reasoning_effort", "medium"),
)
reply = resp.choices[0].message.content or ""
except Exception as e:
reply = ("*Sorry — I couldn't generate a reply this time. "
f"({type(e).__name__})*")
print(f"[on_send] LLM call failed: {e}", flush=True)
history = history + [{"role": "assistant", "content": reply}]
yield history, "", logger
# Log the turn (manager_verdict + plan_control are None for this
# condition — there is no manager in post-essay chat).
try:
logger.add_turn(
user_message=msg,
manager_verdict=None,
plan_control=None,
assistant_response=reply,
latency_s=round(time.perf_counter() - t0, 3),
)
except Exception as e:
print(f"[on_send] log_turn failed: {e}", flush=True)
finally:
# Trigger ①: persist this turn + interim events (chat_send
# snapshot, any feedback events between turns). Runs on both
# success and failure paths so we never lose the user's message
# even if the LLM call or log_turn raises.
if logger:
try:
logger.checkpoint()
except Exception as e:
print(f"[on_send] checkpoint failed: {e}", flush=True)
# ============================================================
# Wire events
# ============================================================
initial_outputs = [
chatbot,
arg_input,
submit_initial_btn,
end_session_btn,
msg_input,
send_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,
msg_input, send_btn],
).then(
fn=None,
inputs=[recovery_payload],
outputs=[recovery_payload],
js=_RECOVERY_CLEAR_JS,
)
# Post-essay chat: send by button click or Enter on msg_input.
send_btn.click(
on_send,
inputs=[msg_input, chatbot, logger_state, arg_input],
outputs=[chatbot, msg_input, logger_state],
)
msg_input.submit(
on_send,
inputs=[msg_input, chatbot, logger_state, arg_input],
outputs=[chatbot, msg_input, logger_state],
)
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,
)