'
'
How to use this discussion
'
'
'
'1. The chatbot will analyze your argument and discuss some '
'related questions with you. As you go, revise your argument'
' on the right and submit the final revised version when '
'you\'re ready.'
'
'
'
'
'2. During the discussion, you can '
'ask for clarification, disagree, or explain what feels '
'unclear.'
'
'
'
'
'3. The bot may take a few seconds to think before each reply. '
'Please be patient — that\'s normal.'
'
'
'
'
'
4. After each bot reply, you can tag it:'
'
'
'- Click ' + _AHA_INLINE_SVG + ' if it helped'
' you notice or rethink something new.
'
'- Click ' + _UGH_INLINE_SVG + ' if it felt frustrating'
' or off.
'
'- If you do not feel either reaction, you can also leave it '
'unclicked.
'
'
'
'
'
'
'
)
# ============================================================
# Blocks
# ============================================================
# JS that takes over scroll behavior from Gradio's built-in autoscroll
# (we set autoscroll=False on the Chatbot):
# 1. On initial load: scroll to TOP of the chatbot so participants read the
# case from the start (Gradio's default lands at the bottom).
# 2. While the bot is thinking/streaming OR any new message arrives: scroll
# to BOTTOM via MutationObserver on the inner scroll container.
# The observer is armed only AFTER initial render is settled, so it doesn't
# fight the initial scroll-to-top.
_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;
}
// Initial: scroll to top, and re-assert a few times in case any late
// layout pass tries to push us back down.
inner.scrollTop = 0;
setTimeout(() => { inner.scrollTop = 0; }, 200);
setTimeout(() => { inner.scrollTop = 0; }, 600);
setTimeout(() => { inner.scrollTop = 0; }, 1200);
// After initial render is settled, watch for ANY content change and
// pin scroll to bottom. This covers: user message added, bot reply
// streaming token-by-token, final reply settled.
setTimeout(() => {
const obs = new MutationObserver(() => {
inner.scrollTop = inner.scrollHeight;
});
obs.observe(inner, {
childList: true, subtree: true, characterData: true
});
}, 1600);
};
setup();
}
"""
with gr.Blocks(title="A", fill_height=True) as demo:
# Per-session cache dict. Holds parsed plan + manager_history.
cache_state = gr.State({})
# Per-session SessionLogger. Initialized in demo.load (Gradio 6.11
# doesn't call callable defaults of gr.State).
logger_state = gr.State()
# Hidden Textbox carrying the walkthrough-finished timestamp from JS
# (set by the walkthrough's "Start" button at the last slide) to the
# Python on_submit_initial handler. The submit button's js= wrapper
# reads window.__walkthroughStartedAt and overrides this value before
# the Python handler runs.
walkthrough_ts_input = gr.Textbox(
value="",
visible=False,
elem_id="walkthrough-ts-input",
)
# ====== TOP ROW (tall): guide | chatbot | argument textbox ======
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 start the discussion with the AI assistant.\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: chat input + send | action buttons ======
# The top row's guide column is visible=False (collapsed by Gradio),
# so the chatbot column expands left. The bottom row matches: no mirror
# column, just chat-row (scale=3) + action-col (scale=2) aligned under
# the chatbot (scale=3) and the argument textbox (scale=2) above.
with gr.Row(equal_height=True):
with gr.Column(scale=3):
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 state for end_session_btn (Phase 2 to 3):
arm_state = gr.State(0.0)
revert_timer = gr.Timer(value=2.5, active=False)
# 2-click confirmation state for submit_initial_btn (Phase 1 to 2):
submit_initial_arm_state = gr.State(0.0)
submit_initial_revert_timer = gr.Timer(value=2.5, active=False)
done_panel = gr.HTML(value="", visible=False)
# ============================================================
# Handlers
# ============================================================
def on_submit_initial(arg_text, history, cache, arm_at, logger, walkthrough_ts):
"""Phase 1 to Phase 2 with 2-click confirmation.
`walkthrough_ts` is a string carrying the unix-seconds timestamp
the participant clicked "Start" at the end of the walkthrough. It
is injected by the js= wrapper on submit_initial_btn.click, which
reads `window.__walkthroughStartedAt` (set client-side at the
moment of the walkthrough Start click).
Outputs (12): chatbot, arg_input, submit_initial_btn, end_session_btn,
msg_input, send_btn, cache_state,
submit_initial_arm_state, submit_initial_revert_timer,
logger_state, arg_label_row, recovery_payload
"""
if logger is None:
logger = SessionLogger()
now = time.time()
if arm_at and (now - arm_at) <= ARM_TTL:
# CONFIRMED
logger.start_session()
# Assign one OpenAI key from the pool for this whole session
# (round-robin across OPENAI_KEY_01..10). The same client is
# then threaded through to manager + main bot + essay
# generation for the rest of the session.
try:
_key_idx, _session_client = get_next_client()
logger.set_assigned_key(_key_idx)
print(f"[key_pool] session {logger.session_id} -> key_idx={_key_idx}",
flush=True)
except Exception as _e:
_key_idx, _session_client = None, None
print(f"[key_pool] failed to assign key: {_e}", flush=True)
# Persist the walkthrough-finished timestamp the JS handler
# captured at the walkthrough-end "Start" click. Robust to
# empty / malformed input.
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.argument_submitted_at = now # gates End Session by time
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()
cache = {INITIAL_ARG_KEY: arg_text,
SESSION_CLIENT_KEY: _session_client}
history = history + [{"role": "user", "content": arg_text}]
# Yield 1: phase-2 layout with a "preparing" status, chat disabled
# while we generate the per-session essay + discussion plan.
# 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": "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),
cache,
0.0,
gr.update(active=False),
logger,
gr.update(value=ARG_LABEL_PHASE2),
gr.update(value=save_payload),
)
# Generate the essay + plan. On any failure, fall back to no
# plan (the main bot will freestyle without PLAN CONTROL).
try:
arts = generate_session_artifacts(arg_text, client=_session_client)
if arts.get("perfect_answer"):
cache[PERFECT_ANSWER_KEY] = arts["perfect_answer"]
logger.set_perfect_answer(arts["perfect_answer"])
if arts.get("dominant_framework"):
logger.set_dominant_framework(arts["dominant_framework"])
print(f"[framework] {arts['dominant_framework']}",
flush=True)
if arts.get("discussion_plan"):
cache[PLAN_TEXT_KEY] = arts["discussion_plan"]
logger.set_discussion_plan(arts["discussion_plan"])
subprobes = plan_parsing.parse_plan(arts["discussion_plan"])
cache[PLAN_SUBPROBES_KEY] = subprobes
cache[MANAGER_HISTORY_KEY] = []
print(f"[plan_parsing] parsed {len(subprobes)} sub-probes",
flush=True)
except Exception as e:
print(f"[session_artifacts] generation failed: {e}", flush=True)
# Yield 2: enable chat input, clear the status, prepare to stream
# the bot's first reply.
yield (
history,
gr.update(), gr.update(), gr.update(),
gr.update(interactive=True,
placeholder="Type your response..."),
gr.update(interactive=True),
cache,
gr.update(), gr.update(),
logger,
gr.update(),
gr.update(),
)
# Stream the first bot reply. The participant's argument is the
# 'user message' for turn 1 from the bot's perspective.
t0 = time.perf_counter()
turn_result = {}
try:
for history in stream_turn(arg_text, history, CASE_TEXT, cache, turn_result):
yield (
history,
gr.update(), gr.update(), gr.update(),
gr.update(), gr.update(), cache,
gr.update(), gr.update(),
logger,
gr.update(),
gr.update(),
)
# After streaming finishes, persist this turn (manager_verdict +
# plan_control + assistant_response + scratch + latencies).
_log_turn(arg_text, cache, logger, turn_result, t0)
finally:
# Trigger ① on auto-first-turn: covers the manager-generated
# perfect_answer + discussion_plan + dominant_framework (set
# earlier in this handler) together with the first turn data.
# In try/finally so PA/plan/framework get persisted even when
# stream_turn raises (e.g. OpenAI mid-stream failure).
if logger:
logger.checkpoint()
# ENABLE end_session_btn.
yield (
gr.update(),
gr.update(), gr.update(),
gr.update(interactive=True),
gr.update(), gr.update(),
cache,
gr.update(), gr.update(),
logger,
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(), cache,
arm_at, gr.update(),
logger,
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(),
cache,
now,
gr.update(active=True),
logger,
gr.update(),
gr.update(),
)
def on_submit_initial_tick(arm_at):
"""1Hz tick: auto-revert submit_initial_btn after ARM_TTL."""
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_send(msg, history, cache, logger, arg_text):
"""Regular chat turn: snapshot notepad, show user message, then run
manager + main bot via the two-agent orchestrator. `arg_text` is the
current notepad value (snapshotted to logger, never sent to bot).
Trigger ① (per-turn push): exactly one HF upload at the end of this
handler, covering everything accumulated since last upload:
- chat_send snapshot
- any feedback events / textbox snapshots between turns
- this turn (user_message + manager_verdict + assistant_response)
Bot-failure path also gets one push (try/finally) so a failed turn
doesn't lose the user's message."""
if not msg.strip():
yield history, "", cache
return
logger.add_argument_snapshot(
trigger="chat_send",
text=arg_text,
context={
"next_turn_n": len(logger.turns) + 1,
"user_msg_excerpt": msg[:120],
},
)
history = history + [{"role": "user", "content": msg}]
yield history, "", cache
t0 = time.perf_counter()
turn_result = {}
try:
for history in stream_turn(msg, history, CASE_TEXT, cache, turn_result):
yield history, "", cache
_log_turn(msg, cache, logger, turn_result, t0)
finally:
# Trigger ①: persist this turn + interim events. Runs on both
# success and failure paths so we never lose the user's message
# even if stream_turn raises mid-stream.
if logger:
logger.checkpoint()
def on_end_session(arm_at, arg_text, logger):
"""2-click end-session/finalize. Generator so we can show an
intermediate 'Uploading your session…' state while flush runs (up
to ~165s of retries under HF rate-limit), then a final status
message reflecting whether the upload actually succeeded.
The 9th 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 (9): arg_input, end_session_btn, msg_input, send_btn,
done_panel, arm_state, revert_timer, arg_label_row,
recovery_payload
"""
now = time.time()
# Gate check on FIRST click only (when not yet armed / arm expired).
# If the participant has not advanced through enough probes and
# the 20-minute timeout hasn't elapsed, do not arm — instead emit
# a toast asking them to keep discussing. Plan-complete sessions
# always pass _can_end_session. Bypassed entirely if the
# `lockdown.end_session_gate` config flag is false (test mode).
is_first_click = not (arm_at and (now - arm_at) <= ARM_TTL)
if ENFORCE_END_SESSION_GATE and is_first_click and not _can_end_session(logger):
yield (
gr.update(), # arg_input
gr.update(), # end_session_btn (stays in normal state)
gr.update(), # msg_input
gr.update(), # send_btn
gr.update(value=_gate_toast_trigger_html(), visible=True),
arm_at,
gr.update(),
gr.update(),
gr.update(), # recovery_payload (no event)
)
return
# Gate 2: revise-check. Fires at most once per session: compare the
# current arg_text against the initial argument the participant
# submitted at Phase-1; if word-level similarity is too high, they
# have not revised — show the modal and block this click. Any later
# End Session click goes through regardless of similarity.
if (
LOCKDOWN_REVISE_CHECK
and is_first_click
and logger is not None
and not getattr(logger, "revise_warned", False)
):
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:
logger.revise_warned = True
yield (
gr.update(),
gr.update(),
gr.update(),
gr.update(),
gr.update(value=_revise_gate_trigger_html(), visible=True),
arm_at,
gr.update(),
gr.update(),
gr.update(), # recovery_payload (no event)
)
return
if arm_at and (now - arm_at) <= ARM_TTL:
# CONFIRMED. First yield: lock the UI + show 'uploading' state.
yield (
gr.update(interactive=False),
gr.update(visible=False, value=END_SESSION_LABEL,
variant="secondary"),
gr.update(interactive=False,
placeholder="Session ended. Saving your data…", value=""),
gr.update(interactive=False),
gr.update(value=SAVING_PANEL_HTML, visible=True),
0.0,
gr.update(active=False),
gr.update(value=ARG_LABEL_LOCKED),
gr.update(), # recovery_payload (clear happens in next yield)
)
# Now run the actual flush (blocks for up to ~165s of retries).
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()
# Second yield: full-screen modal with the completion code.
# Also emit "ended" event so the chained .then(js=...) clears
# localStorage now that the session has been finalised.
import json as _json_local
yield (
gr.update(),
gr.update(),
gr.update(),
gr.update(),
gr.update(value=_completion_modal_html(flush_result), visible=True),
gr.update(),
gr.update(),
gr.update(),
gr.update(value=_json_local.dumps({"event": "ended"})),
)
return
# ARM (single yield equivalent to the original 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(),
gr.update(),
gr.update(),
now,
gr.update(active=True),
gr.update(),
gr.update(), # recovery_payload (no event)
)
def on_timer_tick(arm_at):
"""1Hz tick: revert end_session_btn if not confirmed within ARM_TTL."""
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_feedback(arg_text, logger, data: gr.LikeData):
"""Participant clicked aha! or ugh. on a bot message."""
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.
All three conditions (A/B/C) share the same HF dataset
(`AE-Talk-bot/log-Jun-17-2026`), so the file lookup is restricted to
this Space's mode prefix (LOG_PATH's parent) to prevent picking up a
session belonging to a different condition.
Outputs (12): same shape as on_submit_initial.
"""
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")
# Restrict to this Space's mode_prefix (e.g. "A-soc/" /
# "B-ans/" / "C-non/") so a session belonging to a different
# condition in the same shared dataset does not match.
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)")
initial_arg = (data.get("essays") or {}).get("argument", {}).get("text", "")
# Resolve the assigned OpenAI key back to its pool client. The index
# was preserved across the recovery boundary (from_dict restored it
# on the logger). If we can't find a matching pool entry, fall back
# to whatever get_next_client returns now.
from core.config_loader import KEY_POOL as _KP
_restored_client = None
_restored_idx = new_logger.assigned_key_idx
if _restored_idx is not None:
for _idx, _c in _KP:
if _idx == _restored_idx:
_restored_client = _c
break
if _restored_client is None:
try:
_restored_idx, _restored_client = get_next_client()
new_logger.set_assigned_key(_restored_idx)
print(f"[recover] no original key match; re-assigned key_idx={_restored_idx}",
flush=True)
except Exception as _e:
print(f"[recover] failed to assign client on recovery: {_e}", flush=True)
cache = {INITIAL_ARG_KEY: initial_arg,
SESSION_CLIENT_KEY: _restored_client}
pa = data.get("perfect_answer")
if pa and pa.get("text"):
cache[PERFECT_ANSWER_KEY] = pa["text"]
dp = data.get("discussion_plan")
if dp and dp.get("text"):
cache[PLAN_TEXT_KEY] = dp["text"]
try:
cache[PLAN_SUBPROBES_KEY] = plan_parsing.parse_plan(dp["text"])
except Exception as e:
print(f"[recover] plan re-parse failed: {e}", flush=True)
cache[MANAGER_HISTORY_KEY] = list(data.get("manager_history") or [])
history = []
for t in (data.get("turns") or []):
um = t.get("user_message")
ar = t.get("assistant_response")
if um:
history.append({"role": "user", "content": um})
if ar:
history.append({"role": "assistant", "content": ar})
snapshots = data.get("argument_snapshots") or []
arg_text = snapshots[-1].get("text") if snapshots else initial_arg
print(f"[recover] OK: sid={sid}, "
f"{len(history)//2} chat exchanges, "
f"{len(cache.get(MANAGER_HISTORY_KEY) or [])} verdicts, "
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.
new_logger.checkpoint()
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 response..."),
gr.update(interactive=True),
cache,
0.0,
gr.update(active=False),
new_logger,
gr.update(value=ARG_LABEL_PHASE2),
gr.update(value=save_payload),
)
_initial_outputs = [
chatbot,
arg_input,
submit_initial_btn,
end_session_btn,
msg_input,
send_btn,
cache_state,
]
_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, cache_state, submit_initial_arm_state, logger_state, walkthrough_ts_input],
outputs=_initial_outputs + [submit_initial_arm_state, submit_initial_revert_timer, logger_state, arg_label_row, recovery_payload],
js="""(arg_text, history, cache, arm_at, logger, ts_in) => {
// Override the hidden walkthrough-ts input with the value JS
// captured at the walkthrough-end Start click. Empty string
// if the participant somehow skipped the walkthrough.
var ts = window.__walkthroughStartedAt;
return [arg_text, history, cache, 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, 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],
)
send_btn.click(
on_send,
inputs=[msg_input, chatbot, cache_state, logger_state, arg_input],
outputs=[chatbot, msg_input, cache_state],
)
msg_input.submit(
on_send,
inputs=[msg_input, chatbot, cache_state, logger_state, arg_input],
outputs=[chatbot, msg_input, cache_state],
)
end_session_btn.click(
on_end_session,
inputs=[arm_state, arg_input, logger_state],
outputs=[arg_input, end_session_btn, msg_input, send_btn,
done_panel, arm_state, revert_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],
)
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,
)
# ============================================================
# Auto-restart (unchanged from v3)
# ============================================================
def _start_auto_restart():
import os
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)
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 "