"""Gradio app for the Socratic AI condition (v4 two-agent architecture). Single-layout design (no visible stage labels): - Left: chat area. The case is the first 'assistant' message. - Right: a text input that doubles as initial-argument writing in Phase 1 and a participant notepad in Phase 2. Phases (implicit, driven by button clicks): Phase 1 (entry): chatbot shows the case; right input is for initial argument; chat input DISABLED. Phase 2 (after Submit Initial confirm): host code calls `core.perfect_answer.generate_session_artifacts` (around 15-30s blocking) to produce the per-session essay + discussion plan. The plan is parsed and pinned into the session cache. Each subsequent chat turn runs a manager agent + main bot agent (see core.orchestrator). Right input becomes a notepad; chat ENABLED. Phase 3 (after End Session): chat input + notepad locked, done panel shown. Per-message aha!/ugh. reactions: same as v3. Gradio's like/dislike buttons swapped via EMOJI_SWAP_HEAD; clicks land in `logger.add_feedback`. Two-agent flow per chat turn (post-Phase 1): 1. Manager agent classifies the turn (~2-4s blocking). 2. Host builds PLAN CONTROL block from the verdict. 3. Main bot streams its reply. 4. Host logs the turn (user_message + verdict + plan_control + bot reply + scratch + latencies). """ import threading import time import gradio as gr from core import client, get_next_client, MODEL, REASONING_EFFORT, VERBOSITY from core import SessionLogger, now_aest from core.config_loader import BASE_DIR, CONFIG # Participant-side lockdown flags read from config.yaml. Defaults are FALSE # (test mode). For an actual experiment run, set all three to true in the # config (or via Space Variables) and redeploy. _LOCKDOWN_CFG = (CONFIG.get("lockdown") if isinstance(CONFIG, dict) else None) or {} LOCKDOWN_ARG_CLIPBOARD = bool(_LOCKDOWN_CFG.get("arg_clipboard", False)) LOCKDOWN_CHAT_COPY = bool(_LOCKDOWN_CFG.get("chat_copy", False)) ENFORCE_END_SESSION_GATE = bool(_LOCKDOWN_CFG.get("end_session_gate", False)) LOCKDOWN_REVISE_CHECK = bool(_LOCKDOWN_CFG.get("revise_check", False)) print( f"[lockdown] arg_clipboard={LOCKDOWN_ARG_CLIPBOARD} " f"chat_copy={LOCKDOWN_CHAT_COPY} " f"end_session_gate={ENFORCE_END_SESSION_GATE} " f"revise_check={LOCKDOWN_REVISE_CHECK}", flush=True, ) # ============================================================ # 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 (`socratic_ca_session_v1` / `socratic_ca_tab_id`) so other A_Ethic # condition Spaces opened in the same browser do not collide. The global JS # helper is exposed as `window.__RECOVERY` (same name across conditions for # the .then(js=...) snippets, but each Space defines its own copy on load). # ============================================================ RECOVERY_HEAD = """ """ from core.content_sync import sync_chapters_from_ssot from core.perfect_answer import generate_session_artifacts from core import plan_parsing from core import orchestrator from core import main_agent from core.manager_agent import ManagerVerdict from core.main_agent import BotResponse # 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() # ============================================================ # Session cache keys (held inside a Gradio State dict) # ============================================================ PERFECT_ANSWER_KEY = "__perfect_answer__" # str, the essay (kept for log) PLAN_TEXT_KEY = "__plan_text__" # str, raw markdown of plan PLAN_SUBPROBES_KEY = "__plan_subprobes__" # list[dict] from plan_parsing MANAGER_HISTORY_KEY = "__manager_history__" # list[dict], cumulative verdicts SESSION_CLIENT_KEY = "__session_client__" # OpenAI client assigned to this # session by get_next_client() — # rotates across OPENAI_KEY_01..10 # so concurrent sessions hit # different OpenAI keys / quotas. INITIAL_ARG_KEY = "__initial_argument__" # str, the full essay the # participant submitted at # Phase 1 — passed to manager # on every turn so its overall # stance never falls out of # the recent-turns window. # ============================================================ # 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 (including the title) via stream_turn. 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) # ============================================================ # Per-turn pipeline (manager then main bot, synchronous) # ============================================================ THINKING_TEXT = "*Thinking…*" # ============================================================ # Multi-message output for `opening` and `transition-due`. # # The main-agent prompt instructs the LLM to emit `<<>>` on its # own line to split its reply into multiple chat messages, ONLY for these # two turn types. The host (stream_turn below) splits on this marker after # the LLM finishes streaming, and replaces the single assembled bot message # in chatbot history with multiple discrete assistant messages. # # For `opening` specifically, the LLM is told NOT to write the greeting / # "I'm here to help you think through your argument" framing — that text # is HARDCODED below (OPENING_M1_HARDCODED) and prepended by the host as # the first message. This guarantees the framing wording is exact and # never drifts under prompt jitter. # # Wording in OPENING_M1_HARDCODED is locked by collaborator request — do # NOT edit without explicit sign-off. # ============================================================ MSG_BREAK_MARKER = "<<>>" OPENING_M1_HARDCODED = ( "Thanks for sharing your argument. I've read it carefully.\n\n" "I'm here to **help you think through your argument, rather than think " "for you**. So, in the following discussion, **I'll ask you questions** to " "**help you examine your reasoning** and decide how you want to revise " "your argument." ) def stream_turn(user_msg, history, case_text, cache, result): """Run one chat turn end-to-end. Synchronous: manager (blocking ~2-5s), then main bot stream (yielding deltas). `history` already contains the participant's message as its last entry. `result` is a caller-supplied dict that this function populates with: - "verdict": ManagerVerdict or None - "plan_control_text": str - "bot_response": BotResponse or None - "manager_latency_ms": int Yields successive history snapshots for the Gradio chatbot. The caller iterates with a for loop and reads `result` after the loop ends; logging happens in the caller (NOT in a post-yield block, which is unreliable under Gradio's streaming framework). """ plan_subprobes = cache.get(PLAN_SUBPROBES_KEY, []) plan_text = cache.get(PLAN_TEXT_KEY, "") manager_history = cache.get(MANAGER_HISTORY_KEY, []) initial_argument = cache.get(INITIAL_ARG_KEY, "") # Bot-slot placeholder while the manager call is running. history = history + [{"role": "assistant", "content": THINKING_TEXT}] yield history # Per-session OpenAI client (assigned at session start via get_next_client(); # may be None on legacy/recovered sessions, in which case downstream falls # back to the module-level default client). session_client = cache.get(SESSION_CLIENT_KEY) # 1. Manager call (blocking). verdict = None manager_latency_ms = 0 if plan_subprobes: t0 = time.perf_counter() try: verdict = orchestrator.run_manager( discussion_plan_subprobes=plan_subprobes, manager_history=manager_history, history=history[:-1], current_msg=user_msg, initial_argument=initial_argument, client=session_client, ) except Exception as e: print(f"[manager] failed: {e!r}", flush=True) manager_latency_ms = int((time.perf_counter() - t0) * 1000) result["verdict"] = verdict result["manager_latency_ms"] = manager_latency_ms # 2. PLAN CONTROL block. if verdict is not None and plan_subprobes: plan_control_text = main_agent.build_plan_control(verdict, plan_subprobes) else: plan_control_text = ( "=== PLAN CONTROL (host-managed) ===\n" "(no discussion plan available for this session; respond using " "your own judgement on the participant's argument)\n" "=== END PLAN CONTROL ===" ) result["plan_control_text"] = plan_control_text # 3. Stream main bot. messages = main_agent.build_messages( history=history[:-1], case_text=case_text, discussion_plan_text=plan_text or "(no plan)", plan_control_text=plan_control_text, ) accumulated = "" bot_response = None streaming_started = False try: for ev in main_agent.stream(messages, client=session_client): if ev[0] == "delta": if not streaming_started: streaming_started = True accumulated = ev[1] else: accumulated += ev[1] # While streaming, hide the raw MSG-BREAK marker so the # participant doesn't see "<<>>" appearing as # literal text. Replace with paragraph break for display # only — the actual split into discrete bubbles happens # at "done" below. display = accumulated.replace(MSG_BREAK_MARKER, "\n\n") history[-1] = {"role": "assistant", "content": display} yield history elif ev[0] == "done": bot_response = ev[1] history = _finalize_multi_message_history( history, bot_response.visible, turn_type=getattr(verdict, "turn_type", None) if verdict else None, ) yield history break except Exception as e: history[-1] = {"role": "assistant", "content": f"*Error during bot reply: {e!r}*"} yield history print(f"[main_bot] failed: {e!r}", flush=True) result["bot_response"] = bot_response def _finalize_multi_message_history(history, raw_visible, turn_type): """Replace the single streaming bot message at history[-1] with one or more discrete assistant messages. Logic: - Split raw_visible on `<<>>` into segments - If turn_type == "opening", prepend OPENING_M1_HARDCODED as the first segment (fixed wording, host-injected, not LLM-authored) - Replace history[-1] (the assembled message) with one assistant entry per segment Logging note: the raw LLM output (with MSG_BREAK_MARKER intact) is still preserved on bot_response.visible by the caller, so the session log records the model's actual output — only the chat UI gets split. """ segments = [ s.strip() for s in raw_visible.split(MSG_BREAK_MARKER) if s.strip() ] if turn_type == "opening": segments = [OPENING_M1_HARDCODED] + segments # If only one segment remains (no MSG-BREAK + not opening), keep the # existing single-message behaviour to avoid disturbing other turn # types like engaging-default / needs-clarification / plan-complete. if len(segments) <= 1: history[-1] = {"role": "assistant", "content": segments[0] if segments else raw_visible} return history # Multi-segment: drop the streaming placeholder, append discrete # assistant messages. history = history[:-1] for seg in segments: history.append({"role": "assistant", "content": seg}) return history def _log_turn(user_msg, cache, logger, result, total_t0): """Apply per-turn state changes to cache + logger. Called by the handler AFTER stream_turn's for loop finishes. This function is NOT a generator: it runs to completion synchronously.""" verdict = result.get("verdict") plan_control_text = result.get("plan_control_text", "") bot_response = result.get("bot_response") manager_latency_ms = result.get("manager_latency_ms", 0) if verdict is not None: mh = cache.get(MANAGER_HISTORY_KEY) if mh is None: mh = [] cache[MANAGER_HISTORY_KEY] = mh mh.append(verdict.to_dict()) logger.add_manager_verdict(verdict.to_dict()) logger.add_turn( user_message=user_msg, manager_verdict=verdict.to_dict() if verdict else None, plan_control=plan_control_text, assistant_response=bot_response.visible if bot_response else "", scratch=bot_response.scratch if bot_response else {}, latency_s={ "manager_ms": manager_latency_ms, "ttft_ms": bot_response.ttft_ms if bot_response else None, "main_ms": bot_response.latency_ms if bot_response else None, "total_ms": int((time.perf_counter() - total_t0) * 1000), }, ) print(f"[logger] add_turn done; turn count now = {len(logger.turns)}", flush=True) # ============================================================ # 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 # seconds before armed state auto-reverts # End Session gate: participant cannot end the session until EITHER # (a) the discussion has advanced past (n_active_probes - 2) probes # (where n_active = non-well-addressed sub-probes in this session's # plan, typically 5-6), OR # (b) plan-complete has fired (manager closed the whole plan), OR # (c) at least END_GATE_TIMEOUT_SECONDS have elapsed since the # participant submitted the initial argument (safety escape hatch). # Both Path-A (gap satisfied) and Path-B (stuck) transitions count # toward (a) — manager already made the judgement, UI follows. END_GATE_TIMEOUT_SECONDS = 15 * 60 # 15 minutes def _count_non_well_addressed_probes(plan_obj): """plan_obj is the dict stored in logger.discussion_plan (has 'text' key, plan body in the 7-component scaffold). Counts sub-probes whose `gap:` line does NOT begin with 'well-addressed'. Returns 0 if no plan.""" if not plan_obj: return 0 text = plan_obj.get("text", "") if isinstance(plan_obj, dict) else "" if not text: return 0 n = 0 for line in text.split("\n"): s = line.strip() if s.startswith("gap:"): body = s[4:].strip().lower() if not body.startswith("well-addressed"): n += 1 return n def _can_end_session(logger_obj): """Returns True if the End Session button should proceed; False to gate. See END_GATE_TIMEOUT_SECONDS comment for the three release conditions.""" if logger_obj is None: return True # no logger state, fall through (don't block) mh = getattr(logger_obj, "manager_history", None) or [] # (b) plan-complete ever fired → unconditionally allow for v in mh: if v.get("turn_type") == "plan-complete": return True # (c) 20-minute timeout since argument submission → allow arg_at = getattr(logger_obj, "argument_submitted_at", None) if arg_at and (time.time() - arg_at) >= END_GATE_TIMEOUT_SECONDS: return True # (a) probe threshold n_active = _count_non_well_addressed_probes( getattr(logger_obj, "discussion_plan", None) ) if n_active <= 2: return True # too few non-well-addressed probes to gate meaningfully n_transitions = sum(1 for v in mh if v.get("advanced_this_turn")) return n_transitions >= (n_active - 2) def _gate_toast_trigger_html(): """Returns a tiny hidden element whose id contains a fresh nonce. The JS watcher in INPUT_LOCKDOWN_HEAD spots the new id and fires the toast. Nonce-per-click is what makes a repeated click re-toast.""" nonce = str(int(time.time() * 1000)) return f'' # Revise-check gate: word-level similarity ratio between initial argument # and current argument. > REVISE_SIMILARITY_THRESHOLD means the participant # has not revised enough — block End Session with a modal. 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'' # ============================================================ # 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 = """ /* Revise-check modal — shown when participant tries to end the session with an argument too similar to their initial submission. Styled to match the tutorial overlay. */ #revise-gate-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.78); z-index: 200001; display: flex; align-items: center; justify-content: center; padding: 24px; } #revise-gate-overlay .revise-gate-modal { background: #ffffff; border-radius: 14px; padding: 36px 44px; max-width: 620px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); display: flex; flex-direction: column; gap: 18px; align-items: center; text-align: center; /* Match the rest of the app's font (Gradio default sans-serif). The modal is appended to document.body so it escapes Gradio's font scope and would otherwise fall back to the browser's serif default — pin a sans-serif stack here and let children inherit. */ font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif; } #revise-gate-overlay .revise-gate-title, #revise-gate-overlay .revise-gate-body, #revise-gate-overlay .revise-gate-btn { font-family: inherit; } #revise-gate-overlay .revise-gate-title { font-size: 1.35em; color: #1f2937; line-height: 1.35; } #revise-gate-overlay .revise-gate-body { font-size: 1em; color: #4b5563; line-height: 1.5; } #revise-gate-overlay .revise-gate-btn { background: #2563eb; color: #fff; border: none; border-radius: 8px; padding: 12px 28px; font-size: 1.05em; cursor: pointer; transition: background 0.15s ease; } #revise-gate-overlay .revise-gate-btn:hover { background: #1d4ed8; } /* 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 (the small clipboard left of aha/ugh). */ #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. Aha/ugh and the rest of the message actions live inside .message-buttons-* so they stay visible. */ #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. The aria-label / title matchers above already target exactly the toolbar buttons we want gone. */ /* Extend the Gradio outer container to the full browser width (height is handled by vh-based heights on the chatbot + textbox so the layout stays responsive to browser zoom). */ .gradio-container, .main, .wrap { max-width: 100% !important; } .gradio-container { padding-left: 12px !important; padding-right: 12px !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 aligns to flex-end so Send button stays with the bottom of the (possibly grown) input, not floating mid-row. */ #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; } /* Right column: keep it inside the row's equal_height stretch (so it matches the left column = chatbot 65vh + feedback panel). Only tighten the inter-row gap; let Gradio handle column display. */ #right-col { gap: 4px !important; } /* Argument textbox: two nested rectangles. The OUTER rectangle is the Gradio block (#right-essay) — explicit fixed height (65vh) so it doesn't collapse. The INNER rectangle is the