helmo Codex commited on
Commit
bfcae03
·
1 Parent(s): dc49265

[interview] Conditional questions, agentic correction loop, bold review labels

Browse files

From 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 CHANGED
@@ -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
- raw = t(language, q.sid)
 
 
 
 
 
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", "option_labels"]
 
 
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"]
app/phases/assessment.py CHANGED
@@ -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="medium", hooks=hooks,
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
app/phases/interview.py CHANGED
@@ -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
- return f"{t(lang, 'review_intro')}\n{_labeled_facts(session)}\n\n{t(lang, 'review_confirm')}"
 
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() or _labeled_fallback(session)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, or LLM review summary)
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 >= REVIEW_INDEX:
 
 
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, 0) # "something needs changing" restart
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, idx + 1)
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(