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

[agentic] LLM labeled review summary, eligibility-aware assessment, control fix

Browse files

- Interview review step is now an LLM step: a warm, labelled summary in the
person's language (country of origin: …, current country: …, what happened: …,
etc.), not plain bullets; deterministic labelled fallback if the model is down.
- Assessment reasons honestly about case type (refugee / broader protection /
statelessness / economic_or_other / unclear) instead of assuming everyone is a
Convention refugee β€” e.g. it won't push UNHCR asylum at a clearly economic or
stateless case. case_type parsed + stored on session.assessment.
- Fixed the "control only appears after pressing Continue" lag by pre-mounting
the choice controls with choices (Gradio lags updating choices on a freshly
revealed component).

64 fast tests pass.

Co-authored-by: Codex <noreply@openai.com>

app/assessment_parse.py CHANGED
@@ -21,6 +21,7 @@ class AssessmentResult:
21
  grounds: list[str] = field(default_factory=list)
22
  risk: str | None = None
23
  countries: list[str] = field(default_factory=list)
 
24
 
25
 
26
  def _split_list(value: str) -> list[str]:
@@ -67,6 +68,8 @@ def parse_assessment(text: str) -> tuple[str, AssessmentResult]:
67
  result.risk = risk if risk in _VALID_RISK else None
68
  elif key == "countries":
69
  result.countries = _split_list(value)
 
 
70
 
71
  return visible, result
72
 
 
21
  grounds: list[str] = field(default_factory=list)
22
  risk: str | None = None
23
  countries: list[str] = field(default_factory=list)
24
+ case_type: str | None = None
25
 
26
 
27
  def _split_list(value: str) -> list[str]:
 
68
  result.risk = risk if risk in _VALID_RISK else None
69
  elif key == "countries":
70
  result.countries = _split_list(value)
71
+ elif key == "case_type":
72
+ result.case_type = value.lower().replace(" ", "_") or None
73
 
74
  return visible, result
75
 
app/phases/assessment.py CHANGED
@@ -236,6 +236,7 @@ async def stream_assessment(session: SessionState, loop):
236
 
237
  session.assessment.convention_grounds = result.grounds
238
  session.assessment.risk_level = result.risk
 
239
  session.assessment.reasoning_trace = visible
240
  session.assessment.recommended_countries = recs
241
  advance_to(session, State.RECOMMENDATIONS)
 
236
 
237
  session.assessment.convention_grounds = result.grounds
238
  session.assessment.risk_level = result.risk
239
+ session.assessment.case_type = result.case_type
240
  session.assessment.reasoning_trace = visible
241
  session.assessment.recommended_countries = recs
242
  advance_to(session, State.RECOMMENDATIONS)
app/phases/interview.py CHANGED
@@ -14,8 +14,10 @@ import html
14
 
15
  import gradio as gr
16
 
 
17
  from agent.loop import create_loop
18
  from app.countries import country_choices, country_name
 
19
  from app.interview_script import (
20
  QUESTIONS,
21
  REVIEW_INDEX,
@@ -206,15 +208,54 @@ def _facts_recap(session: SessionState) -> str:
206
 
207
 
208
  def _agent_message_for(session: SessionState, idx: int, *, welcome: bool = False) -> str:
209
- lang = session.language
210
- if idx >= REVIEW_INDEX:
211
- return f"{t(lang, 'review_intro')}\n{_facts_recap(session)}\n\n{t(lang, 'review_confirm')}"
212
- text = question_text(lang, QUESTIONS[idx], session)
213
  if welcome:
214
- return f"{t(lang, 'welcome')}\n{text}"
215
  return text
216
 
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  # --------------------------------------------------------------------------
219
  # UI assembly
220
  # --------------------------------------------------------------------------
@@ -235,8 +276,13 @@ def build(visible: bool = True, session_st=None, loop_st=None, slot_idx_st=None)
235
  chat = gr.HTML(render_chat([]))
236
  with gr.Column(elem_id="iv-responder"):
237
  lbl = gr.HTML('<div class="label-row">Your answer</div>')
238
- radio = gr.Radio(choices=[], label="", show_label=False, visible=False, elem_id="iv-choice")
239
- multi = gr.CheckboxGroup(choices=[], label="", show_label=False, visible=False, elem_id="iv-multi")
 
 
 
 
 
240
  country = gr.Dropdown(choices=country_choices(), label="", show_label=False, visible=False,
241
  allow_custom_value=True, filterable=True, elem_id="iv-country")
242
  text = gr.Textbox(label="", show_label=False, lines=3, visible=False,
@@ -245,11 +291,15 @@ def build(visible: bool = True, session_st=None, loop_st=None, slot_idx_st=None)
245
 
246
  stream_outputs = [chat, rail, radio, multi, country, text, session_st, loop_st, slot_idx_st]
247
 
248
- def _present(session, idx):
249
- """Append the agent's (scripted) message and show its control."""
 
250
  target = State.REVIEW if idx >= REVIEW_INDEX else QUESTIONS[idx].phase
251
  advance_to(session, target)
252
- msg = _agent_message_for(session, idx, welcome=(idx == 0))
 
 
 
253
  session.messages = list(session.messages) + [{"role": "assistant", "content": msg}]
254
  return (render_chat(session.messages), render_rail(session.state),
255
  *control_updates(session, idx), session, idx)
@@ -258,18 +308,18 @@ def build(visible: bool = True, session_st=None, loop_st=None, slot_idx_st=None)
258
  if session is None:
259
  session = SessionState(); session.transition_to(State.INTAKE)
260
  loop = loop or create_loop()
261
- out = _present(session, 0)
262
- # yield (chat, rail, radio, multi, country, text, session, loop, idx)
263
  yield (out[0], out[1], out[2], out[3], out[4], out[5], out[6], loop, out[7])
264
 
265
- def on_continue(radio_v, multi_v, country_v, text_v, session, loop, idx):
266
  if session is None:
267
  session = SessionState(); session.transition_to(State.INTAKE)
268
- o = _present(session, 0)
269
- return (o[0], o[1], o[2], o[3], o[4], o[5], o[6], loop or create_loop(), o[7])
270
-
 
271
  lang = session.language
272
- # Review step
273
  if idx >= REVIEW_INDEX:
274
  if not radio_v:
275
  return (gr.update(), gr.update(), *control_updates(session, idx), session, loop, idx)
@@ -279,8 +329,7 @@ def build(visible: bool = True, session_st=None, loop_st=None, slot_idx_st=None)
279
  return (render_chat(session.messages), render_rail(session.state),
280
  gr.update(visible=False), gr.update(visible=False),
281
  gr.update(visible=False), gr.update(visible=False), session, loop, idx)
282
- # "something needs changing" β†’ start the questions over
283
- o = _present(session, 0)
284
  return (o[0], o[1], o[2], o[3], o[4], o[5], o[6], loop, o[7])
285
 
286
  q = QUESTIONS[idx]
@@ -288,7 +337,7 @@ def build(visible: bool = True, session_st=None, loop_st=None, slot_idx_st=None)
288
  if display is None:
289
  return (gr.update(), gr.update(), *control_updates(session, idx), session, loop, idx)
290
  session.messages = list(session.messages) + [{"role": "user", "content": display}]
291
- o = _present(session, idx + 1)
292
  return (o[0], o[1], o[2], o[3], o[4], o[5], o[6], loop, o[7])
293
 
294
  continue_event = cont.click(
 
14
 
15
  import gradio as gr
16
 
17
+ from agent.events import TextDeltaEvent
18
  from agent.loop import create_loop
19
  from app.countries import country_choices, country_name
20
+ from app.prompt_loader import load_prompt
21
  from app.interview_script import (
22
  QUESTIONS,
23
  REVIEW_INDEX,
 
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}"
214
  return text
215
 
216
 
217
+ def _labeled_facts(session: SessionState) -> str:
218
+ iv = session.interview
219
+ fields = [
220
+ ("Country of origin", iv.origin_country),
221
+ ("Current country", iv.current_country),
222
+ ("What happened", iv.free_text_history),
223
+ ("Immediate danger", None if iv.immediate_danger is None else ("yes" if iv.immediate_danger else "no")),
224
+ ("Time since leaving", iv.displacement_duration),
225
+ ("Documents", ", ".join(iv.documents_available) if iv.documents_available else None),
226
+ ("Languages", ", ".join(iv.languages_spoken) if iv.languages_spoken else None),
227
+ ("Preferred destination", ", ".join(iv.destination_preferences) if iv.destination_preferences else None),
228
+ ]
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")
242
+ + f"\n\n# Right now\nIn {lang}, briefly summarise back what the person told you so they "
243
+ "can confirm. State each item on its own line, clearly labelled (country of origin, "
244
+ "current country, what happened, immediate danger, time since leaving, documents, "
245
+ "languages, preferred destination). Use ONLY the facts given β€” do not invent or omit. "
246
+ "End by asking, in one short sentence, whether it is correct."
247
+ )
248
+ acc = ""
249
+ try:
250
+ async for ev in loop.run("Facts:\n" + _labeled_facts(session), session=None,
251
+ system_prompt=system_prompt, thinking_level="off"):
252
+ if isinstance(ev, TextDeltaEvent):
253
+ acc += ev.delta
254
+ except Exception:
255
+ acc = ""
256
+ return acc.strip() or _labeled_fallback(session)
257
+
258
+
259
  # --------------------------------------------------------------------------
260
  # UI assembly
261
  # --------------------------------------------------------------------------
 
276
  chat = gr.HTML(render_chat([]))
277
  with gr.Column(elem_id="iv-responder"):
278
  lbl = gr.HTML('<div class="label-row">Your answer</div>')
279
+ # Pre-mount with choices so they render immediately when shown
280
+ # (Gradio is laggy updating choices on a freshly-revealed control).
281
+ radio = gr.Radio(choices=["Yes", "No"], label="", show_label=False, visible=False, elem_id="iv-choice")
282
+ multi = gr.CheckboxGroup(
283
+ choices=[t("English", oid) for q in QUESTIONS if q.control == "choice" for oid in q.options],
284
+ label="", show_label=False, visible=False, elem_id="iv-multi",
285
+ )
286
  country = gr.Dropdown(choices=country_choices(), label="", show_label=False, visible=False,
287
  allow_custom_value=True, filterable=True, elem_id="iv-country")
288
  text = gr.Textbox(label="", show_label=False, lines=3, visible=False,
 
291
 
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))
303
  session.messages = list(session.messages) + [{"role": "assistant", "content": msg}]
304
  return (render_chat(session.messages), render_rail(session.state),
305
  *control_updates(session, idx), session, idx)
 
308
  if session is None:
309
  session = SessionState(); session.transition_to(State.INTAKE)
310
  loop = loop or create_loop()
311
+ out = await _present(session, loop, 0)
 
312
  yield (out[0], out[1], out[2], out[3], out[4], out[5], out[6], loop, out[7])
313
 
314
+ async def on_continue(radio_v, multi_v, country_v, text_v, session, loop, idx):
315
  if session is None:
316
  session = SessionState(); session.transition_to(State.INTAKE)
317
+ loop = loop or create_loop()
318
+ o = await _present(session, loop, 0)
319
+ return (o[0], o[1], o[2], o[3], o[4], o[5], o[6], loop, o[7])
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
  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
  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(
app/prompts/assessment.md CHANGED
@@ -5,12 +5,25 @@ plain, calm language, as if thinking a case through on paper β€” not hidden behi
5
  spinner, and not as raw JSON. Write it so the person can follow why you reach your
6
  recommendation. Address them as "you".
7
 
8
- Work through these steps in order, narrating each briefly:
 
9
 
10
- 1. **1951 Refugee Convention.** Consider whether the person's situation fits the
11
- Convention grounds β€” persecution for reasons of race, religion, nationality,
12
- membership of a particular social group, or political opinion. Say which
13
- ground(s) appear to apply, and why, in one or two plain sentences.
 
 
 
 
 
 
 
 
 
 
 
 
14
  2. **1969 AU (OAU) Refugee Convention.** For people in Africa, this is broader β€”
15
  it also covers those fleeing external aggression, occupation, foreign
16
  domination, or events seriously disturbing public order. Note if it applies.
@@ -41,14 +54,18 @@ After your narrated reasoning, end your message with **exactly one** structured
41
  block, on its own lines, so the interface can build the recommendation cards:
42
 
43
  @@ASSESSMENT
44
- grounds: <convention ground(s), pipe-separated>
 
45
  risk: <high | moderate | low>
46
  countries: <Country A | Country B | Country C>
47
  @@END
48
 
49
  Rules:
50
- - `countries` are your ranked destination recommendations (2–3), by name, best
51
- first. Never include the person's country of origin.
 
 
 
52
  - `risk` is your overall read of the danger the person faces.
53
  - Write nothing after the `@@END` line. The person never sees this block β€” it is
54
  metadata for the interface.
 
5
  spinner, and not as raw JSON. Write it so the person can follow why you reach your
6
  recommendation. Address them as "you".
7
 
8
+ Work through these steps in order, narrating each briefly. **Be honest about
9
+ eligibility β€” do not assume every person is a Convention refugee.**
10
 
11
+ 1. **What kind of case is this?** First decide, from what the person told you,
12
+ which situation best fits β€” and say so plainly:
13
+ - **Refugee (1951 Convention):** a real risk of persecution on grounds of
14
+ race, religion, nationality, political opinion, or membership of a
15
+ particular social group. Name the ground(s) that apply.
16
+ - **Broader protection (1969 AU Convention):** fleeing war, occupation, or
17
+ events seriously disturbing public order.
18
+ - **Statelessness:** the person has no nationality (e.g. born somewhere that
19
+ gave no citizenship, parents from elsewhere). This is a protection issue β€”
20
+ point to statelessness determination procedures and UNHCR's statelessness
21
+ mandate, not ordinary refugee asylum.
22
+ - **Mainly economic / other migration:** if there is no protection ground,
23
+ say so honestly and kindly. Do **not** pretend it is a strong asylum claim.
24
+ Point to realistic alternatives (regular migration/work routes, consular
25
+ help, legal advice) instead of UNHCR asylum.
26
+ If you are unsure, say what is unclear and what would change the assessment.
27
  2. **1969 AU (OAU) Refugee Convention.** For people in Africa, this is broader β€”
28
  it also covers those fleeing external aggression, occupation, foreign
29
  domination, or events seriously disturbing public order. Note if it applies.
 
54
  block, on its own lines, so the interface can build the recommendation cards:
55
 
56
  @@ASSESSMENT
57
+ case_type: <refugee | broader_protection | statelessness | economic_or_other | unclear>
58
+ grounds: <convention ground(s) or basis, pipe-separated>
59
  risk: <high | moderate | low>
60
  countries: <Country A | Country B | Country C>
61
  @@END
62
 
63
  Rules:
64
+ - `case_type` is your honest read of what kind of case this is (step 1).
65
+ - `countries` are realistic destination recommendations (2–3), best first, that
66
+ fit `case_type`. Never include the person's country of origin. If the case is
67
+ `economic_or_other` and no protection applies, you may give fewer countries (or
68
+ none) and rely on your narrated honest guidance instead.
69
  - `risk` is your overall read of the danger the person faces.
70
  - Write nothing after the `@@END` line. The person never sees this block β€” it is
71
  metadata for the interface.
app/state/session.py CHANGED
@@ -71,6 +71,7 @@ class Interview:
71
  class Assessment:
72
  convention_grounds: list[str] = field(default_factory=list)
73
  risk_level: Optional[str] = None # "high" | "moderate" | "low"
 
74
  reasoning_trace: str = ""
75
  recommended_countries: list[dict] = field(default_factory=list)
76
 
 
71
  class Assessment:
72
  convention_grounds: list[str] = field(default_factory=list)
73
  risk_level: Optional[str] = None # "high" | "moderate" | "low"
74
+ case_type: Optional[str] = None # refugee | broader_protection | statelessness | economic_or_other | unclear
75
  reasoning_trace: str = ""
76
  recommended_countries: list[dict] = field(default_factory=list)
77