Spaces:
Running
Running
| """app/phases/interview.py — Phase 2 fully-scripted, multilingual interview. | |
| The interview is 100% deterministic: a fixed set of questions | |
| (app/interview_script.py), each with a fixed control, pre-translated into every | |
| language, templated with already-collected values. The LLM is NOT used here at | |
| all — that removes every source of inconsistency (wrong/repeated questions, | |
| mismatched controls, drift). Answers are captured straight into | |
| ``session.interview``. | |
| """ | |
| from __future__ import annotations | |
| import html | |
| import re | |
| import gradio as gr | |
| from agent.events import TextDeltaEvent | |
| from agent.loop import create_loop | |
| from app import mdlite | |
| from app.countries import country_choices, country_name | |
| from app.prompt_loader import load_prompt | |
| from app.interview_script import ( | |
| QUESTIONS, | |
| REVIEW_INDEX, | |
| Question, | |
| in_origin, | |
| option_labels, | |
| question_text, | |
| t, | |
| ) | |
| CORRECT_INDEX = REVIEW_INDEX + 1 # pseudo-step: free-text correction | |
| from app.state.session import SessionState, State | |
| RAIL = [ | |
| ("Intake", State.INTAKE), ("Situation", State.SITUATION), ("History", State.HISTORY), | |
| ("Goals", State.GOALS), ("Review", State.REVIEW), | |
| ] | |
| AGENT_AVATAR = ( | |
| '<svg width="18" height="18" viewBox="0 0 32 32" aria-hidden="true">' | |
| '<path d="M16 7l7 6.5V25h-4.6v-6.2h-4.8V25H9V13.5L16 7z" fill="#fff"/></svg>' | |
| ) | |
| # Interview chrome (rail steps + responder) in every supported language, so the | |
| # UI around the conversation matches the conversation's language. | |
| _CHROME = { | |
| "English": {"Intake": "Intake", "Situation": "Situation", "History": "History", "Goals": "Goals", "Review": "Review", "your_answer": "Your answer", "continue": "Continue →"}, | |
| "French": {"Intake": "Accueil", "Situation": "Situation", "History": "Parcours", "Goals": "Objectifs", "Review": "Vérification", "your_answer": "Votre réponse", "continue": "Continuer →"}, | |
| "Spanish": {"Intake": "Recepción", "Situation": "Situación", "History": "Trayecto", "Goals": "Objetivos", "Review": "Revisión", "your_answer": "Tu respuesta", "continue": "Continuar →"}, | |
| "Portuguese": {"Intake": "Acolhimento", "Situation": "Situação", "History": "Percurso", "Goals": "Objetivos", "Review": "Revisão", "your_answer": "Sua resposta", "continue": "Continuar →"}, | |
| "Arabic": {"Intake": "الاستقبال", "Situation": "الوضع", "History": "المسار", "Goals": "الأهداف", "Review": "المراجعة", "your_answer": "إجابتك", "continue": "متابعة →"}, | |
| "Hindi": {"Intake": "प्रवेश", "Situation": "स्थिति", "History": "इतिहास", "Goals": "लक्ष्य", "Review": "समीक्षा", "your_answer": "आपका उत्तर", "continue": "जारी रखें →"}, | |
| "Chinese": {"Intake": "登记", "Situation": "情况", "History": "经历", "Goals": "目标", "Review": "确认", "your_answer": "您的回答", "continue": "继续 →"}, | |
| "Japanese": {"Intake": "受付", "Situation": "状況", "History": "経緯", "Goals": "目標", "Review": "確認", "your_answer": "あなたの回答", "continue": "続ける →"}, | |
| "Korean": {"Intake": "접수", "Situation": "상황", "History": "경위", "Goals": "목표", "Review": "확인", "your_answer": "당신의 답변", "continue": "계속 →"}, | |
| "Russian": {"Intake": "Приём", "Situation": "Ситуация", "History": "История", "Goals": "Цели", "Review": "Проверка", "your_answer": "Ваш ответ", "continue": "Продолжить →"}, | |
| } | |
| def _chrome(lang: str | None, key: str) -> str: | |
| d = _CHROME.get(lang or "English", _CHROME["English"]) | |
| return d.get(key) or _CHROME["English"].get(key, key) | |
| INTERVIEW_CSS = """ | |
| #iv-screen { background: var(--surface); border: 1px solid var(--line); | |
| border-radius: var(--r-lg); box-shadow: var(--shadow-md); overflow: hidden; | |
| max-width: 1180px; width: 100%; margin: 0 auto; } | |
| .iv-rail { display:flex; align-items:center; gap:6px; padding:18px clamp(18px,4vw,28px); | |
| background:var(--surface-2); border-bottom:1px solid var(--line); flex-wrap:wrap; } | |
| .iv-pill { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--text-muted); font-weight:500; } | |
| .iv-pill .dot { width:22px; height:22px; border-radius:var(--r-full); border:1.5px solid var(--line-strong); | |
| display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; | |
| color:var(--text-muted); background:var(--surface); } | |
| .iv-pill.done .dot { background:var(--primary); border-color:var(--primary); color:#fff; } | |
| .iv-pill.done { color:var(--text-secondary); } | |
| .iv-pill.active .dot { background:var(--accent); border-color:var(--accent); color:#fff; box-shadow:0 0 0 4px var(--accent-tint); } | |
| .iv-pill.active { color:var(--text); font-weight:600; } | |
| .iv-sep { width:18px; height:1.5px; background:var(--line-strong); flex:0 0 auto; } | |
| @media(max-width:620px){ .iv-pill span.t{display:none;} .iv-sep{width:10px;} } | |
| .iv-chat { padding:clamp(18px,4vw,30px); display:flex; flex-direction:column; gap:18px; background:var(--surface); min-height:140px; } | |
| .iv-msg { display:flex; gap:12px; max-width:88%; } | |
| .iv-msg__av { flex:0 0 auto; width:34px; height:34px; border-radius:var(--r-full); background:var(--primary); | |
| display:flex; align-items:center; justify-content:center; box-shadow:var(--shadow-sm); } | |
| .iv-msg__bubble { padding:13px 16px; border-radius:14px; font-size:15px; line-height:1.55; } | |
| .iv-msg--agent .iv-msg__bubble { background:var(--primary-tint); color:#15302a; border-bottom-left-radius:5px; } | |
| .iv-msg--user { margin-left:auto; flex-direction:row-reverse; } | |
| .iv-msg--user .iv-msg__bubble { background:var(--accent-tint); color:#5c3415; border-bottom-right-radius:5px; } | |
| .iv-msg--user .iv-msg__av { background:var(--accent); } | |
| .iv-msg--user .iv-msg__av span { color:#fff; font-size:13px; font-weight:600; } | |
| .iv-msg--current .iv-msg__bubble { background:var(--surface); border:1.5px solid var(--primary-tint-2); | |
| box-shadow:var(--shadow-sm); font-weight:500; color:var(--text); } | |
| #iv-responder { border-top:1px solid var(--line); background:var(--surface-2); padding:clamp(16px,4vw,24px); } | |
| #iv-responder .label-row { font-size:12px; letter-spacing:.04em; text-transform:uppercase; | |
| color:var(--text-muted); font-weight:600; margin-bottom:6px; } | |
| #iv-choice .wrap label, #iv-multi .wrap label, #iv-multi-docs .wrap label { border-radius:var(--r-full) !important; } | |
| /* Choice controls (radio + the two checkbox groups) stay mounted-visible and are | |
| driven by their CHOICES, never by Gradio's `visible` flag — toggling a | |
| CheckboxGroup visible does not build its option DOM (it paints empty until the | |
| next click). When a choice control has no options it is simply collapsed. */ | |
| #iv-choice:not(:has(label)), #iv-multi:not(:has(label)), #iv-multi-docs:not(:has(label)) { display:none !important; } | |
| #iv-continue, #iv-continue button { background:var(--primary) !important; color:var(--on-primary) !important; | |
| box-shadow:0 2px 0 var(--primary-deep) !important; border:0 !important; font-weight:600 !important; } | |
| #iv-continue:hover, #iv-continue button:hover { background:var(--primary-deep) !important; } | |
| """ | |
| # -------------------------------------------------------------------------- | |
| # Rendering | |
| # -------------------------------------------------------------------------- | |
| def render_rail(state: State, lang: str | None = "English") -> str: | |
| pills = [] | |
| for i, (label, st) in enumerate(RAIL): | |
| if i: | |
| pills.append('<span class="iv-sep"></span>') | |
| if state > st: | |
| cls, dot = "iv-pill done", "✓" | |
| elif state == st: | |
| cls, dot = "iv-pill active", str(i + 1) if i else "✓" | |
| else: | |
| cls, dot = "iv-pill", str(i + 1) | |
| pills.append(f'<div class="{cls}"><span class="dot">{dot}</span>' | |
| f'<span class="t">{html.escape(_chrome(lang, label))}</span></div>') | |
| return f'<div class="iv-rail" aria-label="Interview progress">{"".join(pills)}</div>' | |
| def _bubble(role: str, text: str, current: bool = False) -> str: | |
| safe = mdlite.inline(text).replace("\n", "<br>") | |
| if role == "user": | |
| return ('<div class="iv-msg iv-msg--user"><div class="iv-msg__av"><span>You</span></div>' | |
| f'<div class="iv-msg__bubble">{safe}</div></div>') | |
| cur = " iv-msg--current" if current else "" | |
| return (f'<div class="iv-msg iv-msg--agent{cur}"><div class="iv-msg__av">{AGENT_AVATAR}</div>' | |
| f'<div class="iv-msg__bubble">{safe}</div></div>') | |
| def render_chat(messages: list[dict]) -> str: | |
| rows = [] | |
| visible = [m for m in messages if m.get("role") in ("user", "assistant")] | |
| last = len(visible) - 1 | |
| for i, m in enumerate(visible): | |
| if not m.get("content", "").strip(): | |
| continue | |
| role = "agent" if m["role"] == "assistant" else "user" | |
| rows.append(_bubble(role, m["content"], current=(role == "agent" and i == last))) | |
| return f'<div class="iv-chat">{"".join(rows)}</div>' | |
| def advance_to(session: SessionState, target) -> None: | |
| if target is None: | |
| return | |
| if isinstance(target, str): | |
| try: | |
| target = State[target] | |
| except KeyError: | |
| return | |
| while session.state < target: | |
| session.advance() | |
| # -------------------------------------------------------------------------- | |
| # Controls (deterministic per question) | |
| # -------------------------------------------------------------------------- | |
| def _next_index(session: SessionState, idx: int) -> int: | |
| """Next applicable question index, skipping ones that don't apply.""" | |
| j = idx + 1 | |
| while j < REVIEW_INDEX and QUESTIONS[j].skip_if_in_origin and in_origin(session): | |
| j += 1 | |
| return j | |
| # The two multi-select questions get their own pre-mounted controls so their | |
| # choices are set once (in the person's language) and never swapped at reveal — | |
| # Gradio lags when it has to populate a CheckboxGroup's choices in the same beat | |
| # it is shown, which made the options appear only after an extra "Continue". | |
| _Q_GROUNDS = next(q for q in QUESTIONS if q.field == "persecution_types") | |
| _Q_DOCS = next(q for q in QUESTIONS if q.field == "documents_available") | |
| def _active_control(session: SessionState, idx: int): | |
| """Return (which, choices) for the active control at ``idx``. | |
| which ∈ {"radio","grounds","docs","country","text"}.""" | |
| lang = session.language | |
| if idx == CORRECT_INDEX: | |
| return "text", None | |
| if idx >= REVIEW_INDEX: | |
| return "radio", [t(lang, "review_yes"), t(lang, "review_no")] | |
| q = QUESTIONS[idx] | |
| if q.control == "yesno": | |
| return "radio", option_labels(lang, q) | |
| if q.control == "choice": | |
| return ("grounds" if q.field == "persecution_types" else "docs"), option_labels(lang, q) | |
| if q.control == "country": | |
| return "country", None | |
| return "text", None | |
| # Order of the five control updates everywhere: (radio, grounds, docs, country, text). | |
| # radio/grounds/docs are choice controls: ALWAYS mounted-visible, driven by choices | |
| # (empty = collapsed by CSS). country/text are toggled with `visible`. | |
| def control_updates(session: SessionState, idx: int): | |
| """Updates that make exactly the active control appear. Choice controls get | |
| their choices (empty when inactive — never a `visible` toggle); country/text | |
| toggle visibility.""" | |
| which, choices = _active_control(session, idx) | |
| lang = session.language | |
| return ( | |
| gr.update(choices=choices if which == "radio" else [], value=None), | |
| gr.update(choices=option_labels(lang, _Q_GROUNDS) if which == "grounds" else [], value=[]), | |
| gr.update(choices=option_labels(lang, _Q_DOCS) if which == "docs" else [], value=[]), | |
| gr.update(visible=which == "country", value=None), | |
| gr.update(visible=which == "text", value=""), | |
| ) | |
| # Clears every control: choice controls emptied (collapse via CSS), country/text | |
| # hidden. Used while the conversation advances, before the active control is set | |
| # in the next round-trip. | |
| def _clear_controls(): | |
| return ( | |
| gr.update(choices=[], value=None), | |
| gr.update(choices=[], value=[]), | |
| gr.update(choices=[], value=[]), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| ) | |
| def _store(session: SessionState, q: Question, radio_v, grounds_v, docs_v, country_v, text_v): | |
| lang = session.language | |
| if q.control == "country": | |
| raw = country_name(country_v) if country_v else (text_v or "").strip() | |
| if not raw: | |
| return None | |
| setattr(session.interview, q.field, raw) | |
| return raw | |
| if q.control == "yesno": | |
| if not radio_v: | |
| return None | |
| is_yes = radio_v == t(lang, "opt_yes") | |
| setattr(session.interview, q.field, is_yes) | |
| return radio_v | |
| if q.control == "choice": | |
| multi_v = grounds_v if q.field == "persecution_types" else docs_v | |
| if not multi_v: | |
| return None | |
| # map translated labels back to canonical English labels | |
| rev = {t(lang, oid): t("English", oid) for oid in q.options} | |
| canon = [rev.get(v, v) for v in multi_v] | |
| setattr(session.interview, q.field, canon) | |
| return ", ".join(multi_v) | |
| # text | |
| raw = (text_v or "").strip() | |
| if not raw: | |
| return None | |
| if q.kind == "list_text": | |
| setattr(session.interview, q.field, [p.strip() for p in raw.replace(";", ",").split(",") if p.strip()]) | |
| else: | |
| setattr(session.interview, q.field, raw) | |
| return raw | |
| def _facts_recap(session: SessionState) -> str: | |
| iv = session.interview | |
| bits = [] | |
| for label, val in [ | |
| ("•", iv.current_country), ("•", iv.origin_country), | |
| ("•", ", ".join(iv.persecution_types) if iv.persecution_types else None), | |
| ("•", iv.displacement_duration), | |
| ("•", ", ".join(iv.documents_available) if iv.documents_available else None), | |
| ("•", ", ".join(iv.languages_spoken) if iv.languages_spoken else None), | |
| ("•", ", ".join(iv.destination_preferences) if iv.destination_preferences else None), | |
| ]: | |
| if val: | |
| bits.append(f"{label} {val}") | |
| return "\n".join(bits) | |
| def _agent_message_for(session: SessionState, idx: int, *, welcome: bool = False) -> str: | |
| if idx == CORRECT_INDEX: | |
| return t(session.language, "q_correct") | |
| text = question_text(session.language, QUESTIONS[idx], session) | |
| if welcome: | |
| return f"{t(session.language, 'welcome')}\n{text}" | |
| return text | |
| def _labeled_facts(session: SessionState) -> str: | |
| iv = session.interview | |
| fields = [ | |
| ("Country of origin", iv.origin_country), | |
| ("Current country", iv.current_country), | |
| ("What happened", iv.free_text_history), | |
| ("Reason", ", ".join(iv.persecution_types) if iv.persecution_types else None), | |
| ("Immediate danger", None if iv.immediate_danger is None else ("yes" if iv.immediate_danger else "no")), | |
| ("Time since leaving", iv.displacement_duration), | |
| ("Documents", ", ".join(iv.documents_available) if iv.documents_available else None), | |
| ("Languages", ", ".join(iv.languages_spoken) if iv.languages_spoken else None), | |
| ("Preferred destination", ", ".join(iv.destination_preferences) if iv.destination_preferences else None), | |
| ] | |
| return "\n".join(f"{k}: {v}" for k, v in fields if v) | |
| def _bold_labels(text: str) -> str: | |
| """Wrap the 'Label:' prefix of each line in ** so it renders bold.""" | |
| out = [] | |
| for line in text.split("\n"): | |
| if ":" in line and not line.lstrip().startswith("**"): | |
| label, _, rest = line.partition(":") | |
| if 0 < len(label) <= 40: | |
| out.append(f"**{label.strip()}:**{rest}") | |
| continue | |
| out.append(line) | |
| return "\n".join(out) | |
| def _labeled_fallback(session: SessionState) -> str: | |
| lang = session.language | |
| facts = _bold_labels(_labeled_facts(session)) | |
| return f"{t(lang, 'review_intro')}\n{facts}\n\n{t(lang, 'review_confirm')}" | |
| async def _draft_review(session: SessionState, loop) -> str: | |
| """LLM-written labeled review summary in the person's language (bold labels).""" | |
| lang = session.language or "English" | |
| system_prompt = ( | |
| load_prompt("system") | |
| + f"\n\n# Right now\nIn {lang}, briefly summarise back what the person told you so they " | |
| "can confirm. State each item on its own line, clearly labelled (country of origin, " | |
| "current country, what happened, immediate danger, time since leaving, documents, " | |
| "languages, preferred destination). Use ONLY the facts given — do not invent or omit. " | |
| "End by asking, in one short sentence, whether it is correct." | |
| ) | |
| acc = "" | |
| try: | |
| async for ev in loop.run("Facts:\n" + _labeled_facts(session), session=None, | |
| system_prompt=system_prompt, thinking_level="off"): | |
| if isinstance(ev, TextDeltaEvent): | |
| acc += ev.delta | |
| except Exception: | |
| acc = "" | |
| return _bold_labels(acc.strip()) if acc.strip() else _labeled_fallback(session) | |
| _FIELD_KEYS = { | |
| "origin_country", "current_country", "free_text_history", "immediate_danger", | |
| "displacement_duration", "documents_available", "languages_spoken", "destination_preferences", | |
| } | |
| async def _apply_correction(session: SessionState, loop, correction: str) -> None: | |
| """Agentic correction: the LLM maps the person's free-text fix to fields.""" | |
| system_prompt = ( | |
| "You update a structured interview record from a person's free-text correction. " | |
| "Output ONLY lines of the form field=value, using these field names exactly: " | |
| + ", ".join(sorted(_FIELD_KEYS)) + ". " | |
| "immediate_danger must be yes or no. Only output the fields that should change. " | |
| "No commentary." | |
| ) | |
| prompt = ( | |
| "Current record:\n" + _labeled_facts(session) | |
| + f"\n\nThe person says: {correction}\n\nWhat should change?" | |
| ) | |
| acc = "" | |
| try: | |
| async for ev in loop.run(prompt, session=None, system_prompt=system_prompt, thinking_level="off"): | |
| if isinstance(ev, TextDeltaEvent): | |
| acc += ev.delta | |
| except Exception: | |
| acc = "" | |
| for line in acc.splitlines(): | |
| if "=" not in line: | |
| continue | |
| key, _, val = line.partition("=") | |
| key, val = key.strip(), val.strip() | |
| if key not in _FIELD_KEYS or not val: | |
| continue | |
| if key == "immediate_danger": | |
| setattr(session.interview, key, val.lower().startswith("y")) | |
| elif key in ("documents_available", "languages_spoken", "destination_preferences"): | |
| setattr(session.interview, key, [p.strip() for p in val.replace(";", ",").split(",") if p.strip()]) | |
| else: | |
| setattr(session.interview, key, val) | |
| # -------------------------------------------------------------------------- | |
| # UI assembly | |
| # -------------------------------------------------------------------------- | |
| class InterviewUI: | |
| def __init__(self, **kw): | |
| self.__dict__.update(kw) | |
| def build(visible: bool = True, session_st=None, loop_st=None, slot_idx_st=None) -> InterviewUI: | |
| session_st = session_st or gr.State(None) | |
| loop_st = loop_st or gr.State(None) | |
| slot_idx_st = slot_idx_st or gr.State(0) | |
| with gr.Column(visible=visible, elem_classes=["screen-wrap"]) as column: | |
| with gr.Column(elem_id="iv-screen"): | |
| rail = gr.HTML(render_rail(State.SITUATION)) | |
| chat = gr.HTML(render_chat([])) | |
| with gr.Column(elem_id="iv-responder"): | |
| lbl = gr.HTML('<div class="label-row">Your answer</div>') # localised per turn | |
| # Choice controls are mounted VISIBLE with empty choices and driven | |
| # by their choices alone (CSS collapses them while empty). Toggling | |
| # a CheckboxGroup's `visible` doesn't build its option DOM, so it | |
| # would paint empty until the next click — this avoids that entirely. | |
| radio = gr.Radio(choices=[], label="", show_label=False, visible=True, elem_id="iv-choice") | |
| multi_grounds = gr.CheckboxGroup(choices=[], label="", show_label=False, | |
| visible=True, elem_id="iv-multi") | |
| multi_docs = gr.CheckboxGroup(choices=[], label="", show_label=False, | |
| visible=True, elem_id="iv-multi-docs") | |
| country = gr.Dropdown(choices=country_choices(), label="", show_label=False, visible=False, | |
| allow_custom_value=True, filterable=True, elem_id="iv-country") | |
| text = gr.Textbox(label="", show_label=False, lines=3, visible=False, | |
| placeholder="…", elem_id="iv-text") | |
| cont = gr.Button("Continue →", elem_id="iv-continue") | |
| stream_outputs = [chat, rail, radio, multi_grounds, multi_docs, country, text, | |
| session_st, loop_st, slot_idx_st, lbl, cont] | |
| def _chrome_updates(session): | |
| lang = session.language if session else "English" | |
| return ( | |
| gr.update(value=f'<div class="label-row">{html.escape(_chrome(lang, "your_answer"))}</div>'), | |
| gr.update(value=_chrome(lang, "continue")), | |
| ) | |
| async def _present(session, loop, idx): | |
| """Append the agent's message (scripted question, LLM review, or the | |
| correction prompt) and show the right control for the step.""" | |
| target = State.REVIEW if idx >= REVIEW_INDEX else QUESTIONS[idx].phase | |
| advance_to(session, target) | |
| if idx == CORRECT_INDEX: | |
| msg = _agent_message_for(session, idx) | |
| elif idx >= REVIEW_INDEX: | |
| msg = await _draft_review(session, loop) | |
| else: | |
| msg = _agent_message_for(session, idx, welcome=(idx == 0)) | |
| session.messages = list(session.messages) + [{"role": "assistant", "content": msg}] | |
| return (render_chat(session.messages), render_rail(session.state, session.language), | |
| *control_updates(session, idx), session, idx) | |
| _KEEP5 = (gr.update(), gr.update(), gr.update(), gr.update(), gr.update()) | |
| # Each interview turn is two round-trips. Round 1 (the click handler) advances | |
| # the conversation and CLEARS the controls. Round 2 (a chained | |
| # ``.then(reveal_controls)``) sets the active control on its own — a separate | |
| # render cycle with nothing else changing — so a CheckboxGroup's choices paint | |
| # reliably (setting choices on an already-mounted-visible group always works; | |
| # toggling its `visible` does not). | |
| async def _advance(o, loop): | |
| """One frame: advance chat + rail, clear all controls (the active control | |
| is set in the next round-trip). ``o`` is ``_present``'s tuple (…, session, idx).""" | |
| session, idx = o[7], o[8] | |
| yield (o[0], o[1], *_clear_controls(), session, loop, idx, *_chrome_updates(session)) | |
| async def start(session, loop, slot_idx=0): | |
| if session is None: | |
| session = SessionState(); session.transition_to(State.INTAKE) | |
| loop = loop or create_loop() | |
| o = await _present(session, loop, 0) | |
| async for frame in _advance(o, loop): | |
| yield frame | |
| async def on_continue(radio_v, grounds_v, docs_v, country_v, text_v, session, loop, idx): | |
| if session is None: | |
| session = SessionState(); session.transition_to(State.INTAKE) | |
| loop = loop or create_loop() | |
| o = await _present(session, loop, 0) | |
| async for frame in _advance(o, loop): | |
| yield frame | |
| return | |
| loop = loop or create_loop() | |
| lang = session.language | |
| # Correction step: the person typed what to change; the agent applies it. | |
| if idx == CORRECT_INDEX: | |
| correction = (text_v or "").strip() | |
| if not correction: | |
| yield (gr.update(), gr.update(), *_KEEP5, session, loop, idx, gr.update(), gr.update()) | |
| return | |
| session.messages = list(session.messages) + [{"role": "user", "content": correction}] | |
| await _apply_correction(session, loop, correction) | |
| o = await _present(session, loop, REVIEW_INDEX) | |
| async for frame in _advance(o, loop): | |
| yield frame | |
| return | |
| # Review step | |
| if idx >= REVIEW_INDEX: | |
| if not radio_v: | |
| yield (gr.update(), gr.update(), *_KEEP5, session, loop, idx, gr.update(), gr.update()) | |
| return | |
| session.messages = list(session.messages) + [{"role": "user", "content": radio_v}] | |
| if radio_v == t(lang, "review_yes"): | |
| advance_to(session, State.ASSESSMENT) # triggers assessment via .then() | |
| yield (render_chat(session.messages), render_rail(session.state, session.language), | |
| *_clear_controls(), session, loop, idx, gr.update(), gr.update()) | |
| return | |
| o = await _present(session, loop, CORRECT_INDEX) # ask what to change | |
| async for frame in _advance(o, loop): | |
| yield frame | |
| return | |
| q = QUESTIONS[idx] | |
| display = _store(session, q, radio_v, grounds_v, docs_v, country_v, text_v) | |
| if display is None: | |
| yield (gr.update(), gr.update(), *_KEEP5, session, loop, idx, gr.update(), gr.update()) | |
| return | |
| session.messages = list(session.messages) + [{"role": "user", "content": display}] | |
| o = await _present(session, loop, _next_index(session, idx)) | |
| async for frame in _advance(o, loop): | |
| yield frame | |
| control_comps = [radio, multi_grounds, multi_docs, country, text] | |
| def reveal_controls(session, idx): | |
| """Second round-trip: set the active control on its own (no chat change | |
| competing for the paint). Once the interview has handed off to the | |
| assessment, clear everything (controls collapse).""" | |
| if session is None or session.state >= State.ASSESSMENT: | |
| return _clear_controls() | |
| return control_updates(session, idx) | |
| # show_progress="hidden": the choice controls are always mounted-visible, so | |
| # Gradio's generic per-component "processing…" overlay would paint a spinner on | |
| # each of them (it showed twice). The conversation itself signals progress. | |
| continue_event = cont.click( | |
| on_continue, | |
| inputs=[radio, multi_grounds, multi_docs, country, text, session_st, loop_st, slot_idx_st], | |
| outputs=stream_outputs, | |
| show_progress="hidden", | |
| ).then( | |
| reveal_controls, | |
| inputs=[session_st, slot_idx_st], | |
| outputs=control_comps, | |
| show_progress="hidden", | |
| ) | |
| return InterviewUI( | |
| column=column, session=session_st, loop=loop_st, slot_idx=slot_idx_st, | |
| start_fn=start, start_inputs=[session_st, loop_st, slot_idx_st], | |
| stream_outputs=stream_outputs, continue_btn=cont, continue_event=continue_event, | |
| reveal_fn=reveal_controls, reveal_inputs=[session_st, slot_idx_st], reveal_outputs=control_comps, | |
| ) | |
| __all__ = ["build", "InterviewUI", "INTERVIEW_CSS", "render_chat", "render_rail", | |
| "advance_to", "control_updates"] | |