Spaces:
Sleeping
[interview] Conditional questions, agentic correction loop, bold review labels
Browse filesFrom live testing:
- If current country == origin (person still in their country / in hiding), use
"what is happening … that makes you fear for your safety" wording and SKIP the
"how long ago did you leave" question (conditional script in interview_script).
- "Something needs changing" no longer resets the whole interview: the agent now
asks what to correct (free text) and an LLM maps the correction to the right
field(s), then re-shows the summary (agentic correction loop).
- Review summary labels are bold (markdown ** -> <strong> in the chat renderer).
- Assessment: thinking off so the reasoning narration streams visibly (was
stuck on "Beginning your assessment…") and runs faster.
64 fast tests pass.
Co-authored-by: Codex <noreply@openai.com>
- app/interview_script.py +68 -5
- app/phases/assessment.py +1 -1
- app/phases/interview.py +99 -9
|
@@ -25,15 +25,17 @@ class Question:
|
|
| 25 |
control: str # "country" | "yesno" | "choice" | "text"
|
| 26 |
kind: str = "str" # str | bool | list | list_text
|
| 27 |
options: list = field(default_factory=list) # option string-ids for choice/yesno
|
|
|
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
# Ordered interview plan. Controls are fixed; some questions template {origin}.
|
| 31 |
QUESTIONS: list[Question] = [
|
| 32 |
Question("q_current", "current_country", State.SITUATION, "country"),
|
| 33 |
Question("q_origin", "origin_country", State.SITUATION, "country"),
|
| 34 |
-
Question("q_reason", "free_text_history", State.SITUATION, "text"),
|
| 35 |
Question("q_danger", "immediate_danger", State.SITUATION, "yesno", kind="bool"),
|
| 36 |
-
Question("q_duration", "displacement_duration", State.HISTORY, "text"),
|
| 37 |
Question("q_documents", "documents_available", State.HISTORY, "choice", kind="list",
|
| 38 |
options=["opt_passport", "opt_id", "opt_birth", "opt_none", "opt_other"]),
|
| 39 |
Question("q_languages", "languages_spoken", State.GOALS, "text", kind="list_text"),
|
|
@@ -236,15 +238,75 @@ TR: dict[str, dict[str, str]] = {
|
|
| 236 |
}
|
| 237 |
|
| 238 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
def t(language: str | None, sid: str) -> str:
|
| 240 |
"""Translation lookup with English fallback."""
|
| 241 |
lang = language if language in TR else "English"
|
| 242 |
return TR[lang].get(sid) or TR["English"].get(sid, sid)
|
| 243 |
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
def question_text(language: str | None, q: Question, session) -> str:
|
| 246 |
-
"""Localised question text, templated with collected values.
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
origin = session.interview.origin_country or t(language, "q_origin")
|
| 249 |
current = session.interview.current_country or ""
|
| 250 |
return raw.replace("{origin}", str(origin)).replace("{current}", str(current))
|
|
@@ -256,4 +318,5 @@ def option_labels(language: str | None, q: Question) -> list[str]:
|
|
| 256 |
return [t(language, oid) for oid in q.options]
|
| 257 |
|
| 258 |
|
| 259 |
-
__all__ = ["Question", "QUESTIONS", "REVIEW_INDEX", "TR", "t", "question_text",
|
|
|
|
|
|
| 25 |
control: str # "country" | "yesno" | "choice" | "text"
|
| 26 |
kind: str = "str" # str | bool | list | list_text
|
| 27 |
options: list = field(default_factory=list) # option string-ids for choice/yesno
|
| 28 |
+
alt_sid: str | None = None # alternate text when still in country of origin
|
| 29 |
+
skip_if_in_origin: bool = False # skip when current country == origin
|
| 30 |
|
| 31 |
|
| 32 |
# Ordered interview plan. Controls are fixed; some questions template {origin}.
|
| 33 |
QUESTIONS: list[Question] = [
|
| 34 |
Question("q_current", "current_country", State.SITUATION, "country"),
|
| 35 |
Question("q_origin", "origin_country", State.SITUATION, "country"),
|
| 36 |
+
Question("q_reason", "free_text_history", State.SITUATION, "text", alt_sid="q_reason_instill"),
|
| 37 |
Question("q_danger", "immediate_danger", State.SITUATION, "yesno", kind="bool"),
|
| 38 |
+
Question("q_duration", "displacement_duration", State.HISTORY, "text", skip_if_in_origin=True),
|
| 39 |
Question("q_documents", "documents_available", State.HISTORY, "choice", kind="list",
|
| 40 |
options=["opt_passport", "opt_id", "opt_birth", "opt_none", "opt_other"]),
|
| 41 |
Question("q_languages", "languages_spoken", State.GOALS, "text", kind="list_text"),
|
|
|
|
| 238 |
}
|
| 239 |
|
| 240 |
|
| 241 |
+
# Extra strings (still-in-country reason wording + the correction prompt),
|
| 242 |
+
# merged into TR. t() falls back to English for any language missing a key.
|
| 243 |
+
_EXTRA = {
|
| 244 |
+
"English": {
|
| 245 |
+
"q_reason_instill": "Can you tell me what is happening in {origin} that makes you fear for your safety?",
|
| 246 |
+
"q_correct": "What would you like to correct? You can tell me in your own words.",
|
| 247 |
+
},
|
| 248 |
+
"French": {
|
| 249 |
+
"q_reason_instill": "Pouvez-vous me dire ce qui se passe en {origin} qui vous fait craindre pour votre sécurité ?",
|
| 250 |
+
"q_correct": "Que souhaitez-vous corriger ? Dites-le-moi avec vos propres mots.",
|
| 251 |
+
},
|
| 252 |
+
"Spanish": {
|
| 253 |
+
"q_reason_instill": "¿Puedes contarme qué está pasando en {origin} que te hace temer por tu seguridad?",
|
| 254 |
+
"q_correct": "¿Qué te gustaría corregir? Puedes decírmelo con tus propias palabras.",
|
| 255 |
+
},
|
| 256 |
+
"Portuguese": {
|
| 257 |
+
"q_reason_instill": "Pode me contar o que está acontecendo em {origin} que faz você temer pela sua segurança?",
|
| 258 |
+
"q_correct": "O que você gostaria de corrigir? Pode me dizer com suas próprias palavras.",
|
| 259 |
+
},
|
| 260 |
+
"Arabic": {
|
| 261 |
+
"q_reason_instill": "هل يمكنك أن تخبرني بما يحدث في {origin} ويجعلك تخشى على سلامتك؟",
|
| 262 |
+
"q_correct": "ما الذي تودّ تصحيحه؟ يمكنك إخباري بكلماتك الخاصة.",
|
| 263 |
+
},
|
| 264 |
+
"Hindi": {
|
| 265 |
+
"q_reason_instill": "क्या आप बता सकते हैं कि {origin} में क्या हो रहा है जिससे आपको अपनी सुरक्षा का डर है?",
|
| 266 |
+
"q_correct": "आप क्या ठीक करना चाहेंगे? आप अपने शब्दों में बता सकते हैं।",
|
| 267 |
+
},
|
| 268 |
+
"Chinese": {
|
| 269 |
+
"q_reason_instill": "能告诉我{origin}正在发生什么、让您为自己的安全感到担忧吗?",
|
| 270 |
+
"q_correct": "您想更正什么?可以用您自己的话告诉我。",
|
| 271 |
+
},
|
| 272 |
+
"Japanese": {
|
| 273 |
+
"q_reason_instill": "{origin}で何が起きていて、ご自身の安全が脅かされていると感じるのか教えていただけますか?",
|
| 274 |
+
"q_correct": "どこを修正したいですか? ご自身の言葉で教えてください。",
|
| 275 |
+
},
|
| 276 |
+
"Korean": {
|
| 277 |
+
"q_reason_instill": "{origin}에서 무슨 일이 일어나고 있어 안전이 걱정되시는지 말씀해 주시겠어요?",
|
| 278 |
+
"q_correct": "무엇을 고치고 싶으신가요? 편하게 말씀해 주세요.",
|
| 279 |
+
},
|
| 280 |
+
"Russian": {
|
| 281 |
+
"q_reason_instill": "Расскажите, что происходит в {origin}, из-за чего вы опасаетесь за свою безопасность?",
|
| 282 |
+
"q_correct": "Что вы хотели бы исправить? Можете рассказать своими словами.",
|
| 283 |
+
},
|
| 284 |
+
}
|
| 285 |
+
for _lang, _d in _EXTRA.items():
|
| 286 |
+
TR.setdefault(_lang, {}).update(_d)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
def t(language: str | None, sid: str) -> str:
|
| 290 |
"""Translation lookup with English fallback."""
|
| 291 |
lang = language if language in TR else "English"
|
| 292 |
return TR[lang].get(sid) or TR["English"].get(sid, sid)
|
| 293 |
|
| 294 |
|
| 295 |
+
def in_origin(session) -> bool:
|
| 296 |
+
"""True when the person is still in their country of origin."""
|
| 297 |
+
o = (session.interview.origin_country or "").strip().lower()
|
| 298 |
+
c = (session.interview.current_country or "").strip().lower()
|
| 299 |
+
return bool(o) and o == c
|
| 300 |
+
|
| 301 |
+
|
| 302 |
def question_text(language: str | None, q: Question, session) -> str:
|
| 303 |
+
"""Localised question text, templated with collected values.
|
| 304 |
+
|
| 305 |
+
Uses the alternate wording when the person is still in their origin country
|
| 306 |
+
(so we don't ask "what made you leave" / "how long ago did you leave").
|
| 307 |
+
"""
|
| 308 |
+
sid = q.alt_sid if (q.alt_sid and in_origin(session)) else q.sid
|
| 309 |
+
raw = t(language, sid)
|
| 310 |
origin = session.interview.origin_country or t(language, "q_origin")
|
| 311 |
current = session.interview.current_country or ""
|
| 312 |
return raw.replace("{origin}", str(origin)).replace("{current}", str(current))
|
|
|
|
| 318 |
return [t(language, oid) for oid in q.options]
|
| 319 |
|
| 320 |
|
| 321 |
+
__all__ = ["Question", "QUESTIONS", "REVIEW_INDEX", "TR", "t", "question_text",
|
| 322 |
+
"option_labels", "in_origin"]
|
|
@@ -192,7 +192,7 @@ async def stream_assessment(session: SessionState, loop):
|
|
| 192 |
|
| 193 |
async for ev in loop.run(
|
| 194 |
prompt, session, system_prompt=system_prompt,
|
| 195 |
-
tools=ASSESSMENT_TOOLS, thinking_level="
|
| 196 |
):
|
| 197 |
if isinstance(ev, TextDeltaEvent):
|
| 198 |
acc += ev.delta
|
|
|
|
| 192 |
|
| 193 |
async for ev in loop.run(
|
| 194 |
prompt, session, system_prompt=system_prompt,
|
| 195 |
+
tools=ASSESSMENT_TOOLS, thinking_level="off", hooks=hooks,
|
| 196 |
):
|
| 197 |
if isinstance(ev, TextDeltaEvent):
|
| 198 |
acc += ev.delta
|
|
@@ -11,6 +11,7 @@ mismatched controls, drift). Answers are captured straight into
|
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
import html
|
|
|
|
| 14 |
|
| 15 |
import gradio as gr
|
| 16 |
|
|
@@ -22,10 +23,13 @@ from app.interview_script import (
|
|
| 22 |
QUESTIONS,
|
| 23 |
REVIEW_INDEX,
|
| 24 |
Question,
|
|
|
|
| 25 |
option_labels,
|
| 26 |
question_text,
|
| 27 |
t,
|
| 28 |
)
|
|
|
|
|
|
|
| 29 |
from app.state.session import SessionState, State
|
| 30 |
|
| 31 |
RAIL = [
|
|
@@ -98,8 +102,13 @@ def render_rail(state: State) -> str:
|
|
| 98 |
return f'<div class="iv-rail" aria-label="Interview progress">{"".join(pills)}</div>'
|
| 99 |
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
def _bubble(role: str, text: str, current: bool = False) -> str:
|
| 102 |
-
safe = html.escape(text).replace("\n", "<br>")
|
| 103 |
if role == "user":
|
| 104 |
return ('<div class="iv-msg iv-msg--user"><div class="iv-msg__av"><span>You</span></div>'
|
| 105 |
f'<div class="iv-msg__bubble">{safe}</div></div>')
|
|
@@ -136,9 +145,20 @@ def advance_to(session: SessionState, target) -> None:
|
|
| 136 |
# Controls (deterministic per question)
|
| 137 |
# --------------------------------------------------------------------------
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
def control_updates(session: SessionState, idx: int):
|
| 140 |
"""(radio, multi, country, text) updates for the question at ``idx``."""
|
| 141 |
lang = session.language
|
|
|
|
|
|
|
|
|
|
| 142 |
if idx >= REVIEW_INDEX:
|
| 143 |
choices = [t(lang, "review_yes"), t(lang, "review_no")]
|
| 144 |
return (gr.update(visible=True, choices=choices, value=None),
|
|
@@ -208,6 +228,8 @@ def _facts_recap(session: SessionState) -> str:
|
|
| 208 |
|
| 209 |
|
| 210 |
def _agent_message_for(session: SessionState, idx: int, *, welcome: bool = False) -> str:
|
|
|
|
|
|
|
| 211 |
text = question_text(session.language, QUESTIONS[idx], session)
|
| 212 |
if welcome:
|
| 213 |
return f"{t(session.language, 'welcome')}\n{text}"
|
|
@@ -229,13 +251,27 @@ def _labeled_facts(session: SessionState) -> str:
|
|
| 229 |
return "\n".join(f"{k}: {v}" for k, v in fields if v)
|
| 230 |
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
def _labeled_fallback(session: SessionState) -> str:
|
| 233 |
lang = session.language
|
| 234 |
-
|
|
|
|
| 235 |
|
| 236 |
|
| 237 |
async def _draft_review(session: SessionState, loop) -> str:
|
| 238 |
-
"""LLM-written labeled review summary in the person's language."""
|
| 239 |
lang = session.language or "English"
|
| 240 |
system_prompt = (
|
| 241 |
load_prompt("system")
|
|
@@ -253,7 +289,48 @@ async def _draft_review(session: SessionState, loop) -> str:
|
|
| 253 |
acc += ev.delta
|
| 254 |
except Exception:
|
| 255 |
acc = ""
|
| 256 |
-
return acc.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
|
| 259 |
# --------------------------------------------------------------------------
|
|
@@ -292,11 +369,13 @@ def build(visible: bool = True, session_st=None, loop_st=None, slot_idx_st=None)
|
|
| 292 |
stream_outputs = [chat, rail, radio, multi, country, text, session_st, loop_st, slot_idx_st]
|
| 293 |
|
| 294 |
async def _present(session, loop, idx):
|
| 295 |
-
"""Append the agent's message (scripted question,
|
| 296 |
-
and show the right control for the step."""
|
| 297 |
target = State.REVIEW if idx >= REVIEW_INDEX else QUESTIONS[idx].phase
|
| 298 |
advance_to(session, target)
|
| 299 |
-
if idx
|
|
|
|
|
|
|
| 300 |
msg = await _draft_review(session, loop)
|
| 301 |
else:
|
| 302 |
msg = _agent_message_for(session, idx, welcome=(idx == 0))
|
|
@@ -320,6 +399,17 @@ def build(visible: bool = True, session_st=None, loop_st=None, slot_idx_st=None)
|
|
| 320 |
loop = loop or create_loop()
|
| 321 |
lang = session.language
|
| 322 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
if idx >= REVIEW_INDEX:
|
| 324 |
if not radio_v:
|
| 325 |
return (gr.update(), gr.update(), *control_updates(session, idx), session, loop, idx)
|
|
@@ -329,7 +419,7 @@ def build(visible: bool = True, session_st=None, loop_st=None, slot_idx_st=None)
|
|
| 329 |
return (render_chat(session.messages), render_rail(session.state),
|
| 330 |
gr.update(visible=False), gr.update(visible=False),
|
| 331 |
gr.update(visible=False), gr.update(visible=False), session, loop, idx)
|
| 332 |
-
o = await _present(session, loop,
|
| 333 |
return (o[0], o[1], o[2], o[3], o[4], o[5], o[6], loop, o[7])
|
| 334 |
|
| 335 |
q = QUESTIONS[idx]
|
|
@@ -337,7 +427,7 @@ def build(visible: bool = True, session_st=None, loop_st=None, slot_idx_st=None)
|
|
| 337 |
if display is None:
|
| 338 |
return (gr.update(), gr.update(), *control_updates(session, idx), session, loop, idx)
|
| 339 |
session.messages = list(session.messages) + [{"role": "user", "content": display}]
|
| 340 |
-
o = await _present(session, loop,
|
| 341 |
return (o[0], o[1], o[2], o[3], o[4], o[5], o[6], loop, o[7])
|
| 342 |
|
| 343 |
continue_event = cont.click(
|
|
|
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
import html
|
| 14 |
+
import re
|
| 15 |
|
| 16 |
import gradio as gr
|
| 17 |
|
|
|
|
| 23 |
QUESTIONS,
|
| 24 |
REVIEW_INDEX,
|
| 25 |
Question,
|
| 26 |
+
in_origin,
|
| 27 |
option_labels,
|
| 28 |
question_text,
|
| 29 |
t,
|
| 30 |
)
|
| 31 |
+
|
| 32 |
+
CORRECT_INDEX = REVIEW_INDEX + 1 # pseudo-step: free-text correction
|
| 33 |
from app.state.session import SessionState, State
|
| 34 |
|
| 35 |
RAIL = [
|
|
|
|
| 102 |
return f'<div class="iv-rail" aria-label="Interview progress">{"".join(pills)}</div>'
|
| 103 |
|
| 104 |
|
| 105 |
+
def _bold(escaped: str) -> str:
|
| 106 |
+
"""Render **markdown bold** (used for review summary labels)."""
|
| 107 |
+
return re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", escaped)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
def _bubble(role: str, text: str, current: bool = False) -> str:
|
| 111 |
+
safe = _bold(html.escape(text)).replace("\n", "<br>")
|
| 112 |
if role == "user":
|
| 113 |
return ('<div class="iv-msg iv-msg--user"><div class="iv-msg__av"><span>You</span></div>'
|
| 114 |
f'<div class="iv-msg__bubble">{safe}</div></div>')
|
|
|
|
| 145 |
# Controls (deterministic per question)
|
| 146 |
# --------------------------------------------------------------------------
|
| 147 |
|
| 148 |
+
def _next_index(session: SessionState, idx: int) -> int:
|
| 149 |
+
"""Next applicable question index, skipping ones that don't apply."""
|
| 150 |
+
j = idx + 1
|
| 151 |
+
while j < REVIEW_INDEX and QUESTIONS[j].skip_if_in_origin and in_origin(session):
|
| 152 |
+
j += 1
|
| 153 |
+
return j
|
| 154 |
+
|
| 155 |
+
|
| 156 |
def control_updates(session: SessionState, idx: int):
|
| 157 |
"""(radio, multi, country, text) updates for the question at ``idx``."""
|
| 158 |
lang = session.language
|
| 159 |
+
if idx == CORRECT_INDEX: # free-text correction
|
| 160 |
+
return (gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
|
| 161 |
+
gr.update(visible=True, value=""))
|
| 162 |
if idx >= REVIEW_INDEX:
|
| 163 |
choices = [t(lang, "review_yes"), t(lang, "review_no")]
|
| 164 |
return (gr.update(visible=True, choices=choices, value=None),
|
|
|
|
| 228 |
|
| 229 |
|
| 230 |
def _agent_message_for(session: SessionState, idx: int, *, welcome: bool = False) -> str:
|
| 231 |
+
if idx == CORRECT_INDEX:
|
| 232 |
+
return t(session.language, "q_correct")
|
| 233 |
text = question_text(session.language, QUESTIONS[idx], session)
|
| 234 |
if welcome:
|
| 235 |
return f"{t(session.language, 'welcome')}\n{text}"
|
|
|
|
| 251 |
return "\n".join(f"{k}: {v}" for k, v in fields if v)
|
| 252 |
|
| 253 |
|
| 254 |
+
def _bold_labels(text: str) -> str:
|
| 255 |
+
"""Wrap the 'Label:' prefix of each line in ** so it renders bold."""
|
| 256 |
+
out = []
|
| 257 |
+
for line in text.split("\n"):
|
| 258 |
+
if ":" in line and not line.lstrip().startswith("**"):
|
| 259 |
+
label, _, rest = line.partition(":")
|
| 260 |
+
if 0 < len(label) <= 40:
|
| 261 |
+
out.append(f"**{label.strip()}:**{rest}")
|
| 262 |
+
continue
|
| 263 |
+
out.append(line)
|
| 264 |
+
return "\n".join(out)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
def _labeled_fallback(session: SessionState) -> str:
|
| 268 |
lang = session.language
|
| 269 |
+
facts = _bold_labels(_labeled_facts(session))
|
| 270 |
+
return f"{t(lang, 'review_intro')}\n{facts}\n\n{t(lang, 'review_confirm')}"
|
| 271 |
|
| 272 |
|
| 273 |
async def _draft_review(session: SessionState, loop) -> str:
|
| 274 |
+
"""LLM-written labeled review summary in the person's language (bold labels)."""
|
| 275 |
lang = session.language or "English"
|
| 276 |
system_prompt = (
|
| 277 |
load_prompt("system")
|
|
|
|
| 289 |
acc += ev.delta
|
| 290 |
except Exception:
|
| 291 |
acc = ""
|
| 292 |
+
return _bold_labels(acc.strip()) if acc.strip() else _labeled_fallback(session)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
_FIELD_KEYS = {
|
| 296 |
+
"origin_country", "current_country", "free_text_history", "immediate_danger",
|
| 297 |
+
"displacement_duration", "documents_available", "languages_spoken", "destination_preferences",
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
async def _apply_correction(session: SessionState, loop, correction: str) -> None:
|
| 302 |
+
"""Agentic correction: the LLM maps the person's free-text fix to fields."""
|
| 303 |
+
system_prompt = (
|
| 304 |
+
"You update a structured interview record from a person's free-text correction. "
|
| 305 |
+
"Output ONLY lines of the form field=value, using these field names exactly: "
|
| 306 |
+
+ ", ".join(sorted(_FIELD_KEYS)) + ". "
|
| 307 |
+
"immediate_danger must be yes or no. Only output the fields that should change. "
|
| 308 |
+
"No commentary."
|
| 309 |
+
)
|
| 310 |
+
prompt = (
|
| 311 |
+
"Current record:\n" + _labeled_facts(session)
|
| 312 |
+
+ f"\n\nThe person says: {correction}\n\nWhat should change?"
|
| 313 |
+
)
|
| 314 |
+
acc = ""
|
| 315 |
+
try:
|
| 316 |
+
async for ev in loop.run(prompt, session=None, system_prompt=system_prompt, thinking_level="off"):
|
| 317 |
+
if isinstance(ev, TextDeltaEvent):
|
| 318 |
+
acc += ev.delta
|
| 319 |
+
except Exception:
|
| 320 |
+
acc = ""
|
| 321 |
+
for line in acc.splitlines():
|
| 322 |
+
if "=" not in line:
|
| 323 |
+
continue
|
| 324 |
+
key, _, val = line.partition("=")
|
| 325 |
+
key, val = key.strip(), val.strip()
|
| 326 |
+
if key not in _FIELD_KEYS or not val:
|
| 327 |
+
continue
|
| 328 |
+
if key == "immediate_danger":
|
| 329 |
+
setattr(session.interview, key, val.lower().startswith("y"))
|
| 330 |
+
elif key in ("documents_available", "languages_spoken", "destination_preferences"):
|
| 331 |
+
setattr(session.interview, key, [p.strip() for p in val.replace(";", ",").split(",") if p.strip()])
|
| 332 |
+
else:
|
| 333 |
+
setattr(session.interview, key, val)
|
| 334 |
|
| 335 |
|
| 336 |
# --------------------------------------------------------------------------
|
|
|
|
| 369 |
stream_outputs = [chat, rail, radio, multi, country, text, session_st, loop_st, slot_idx_st]
|
| 370 |
|
| 371 |
async def _present(session, loop, idx):
|
| 372 |
+
"""Append the agent's message (scripted question, LLM review, or the
|
| 373 |
+
correction prompt) and show the right control for the step."""
|
| 374 |
target = State.REVIEW if idx >= REVIEW_INDEX else QUESTIONS[idx].phase
|
| 375 |
advance_to(session, target)
|
| 376 |
+
if idx == CORRECT_INDEX:
|
| 377 |
+
msg = _agent_message_for(session, idx)
|
| 378 |
+
elif idx >= REVIEW_INDEX:
|
| 379 |
msg = await _draft_review(session, loop)
|
| 380 |
else:
|
| 381 |
msg = _agent_message_for(session, idx, welcome=(idx == 0))
|
|
|
|
| 399 |
loop = loop or create_loop()
|
| 400 |
lang = session.language
|
| 401 |
|
| 402 |
+
# Correction step: the person typed what to change; the agent applies it.
|
| 403 |
+
if idx == CORRECT_INDEX:
|
| 404 |
+
correction = (text_v or "").strip()
|
| 405 |
+
if not correction:
|
| 406 |
+
return (gr.update(), gr.update(), *control_updates(session, idx), session, loop, idx)
|
| 407 |
+
session.messages = list(session.messages) + [{"role": "user", "content": correction}]
|
| 408 |
+
await _apply_correction(session, loop, correction)
|
| 409 |
+
o = await _present(session, loop, REVIEW_INDEX) # show the corrected summary
|
| 410 |
+
return (o[0], o[1], o[2], o[3], o[4], o[5], o[6], loop, o[7])
|
| 411 |
+
|
| 412 |
+
# Review step
|
| 413 |
if idx >= REVIEW_INDEX:
|
| 414 |
if not radio_v:
|
| 415 |
return (gr.update(), gr.update(), *control_updates(session, idx), session, loop, idx)
|
|
|
|
| 419 |
return (render_chat(session.messages), render_rail(session.state),
|
| 420 |
gr.update(visible=False), gr.update(visible=False),
|
| 421 |
gr.update(visible=False), gr.update(visible=False), session, loop, idx)
|
| 422 |
+
o = await _present(session, loop, CORRECT_INDEX) # ask what to change (free text)
|
| 423 |
return (o[0], o[1], o[2], o[3], o[4], o[5], o[6], loop, o[7])
|
| 424 |
|
| 425 |
q = QUESTIONS[idx]
|
|
|
|
| 427 |
if display is None:
|
| 428 |
return (gr.update(), gr.update(), *control_updates(session, idx), session, loop, idx)
|
| 429 |
session.messages = list(session.messages) + [{"role": "user", "content": display}]
|
| 430 |
+
o = await _present(session, loop, _next_index(session, idx))
|
| 431 |
return (o[0], o[1], o[2], o[3], o[4], o[5], o[6], loop, o[7])
|
| 432 |
|
| 433 |
continue_event = cont.click(
|