helmo Codex commited on
Commit
e16f16c
·
1 Parent(s): f868661

[T017-T029] Phase 1 — agent loop + interview core

Browse files

- agent/loop.py: full turn cycle (already streamed); separate system prompt
from persisted history (no accumulation); create_loop() session factory (T019)
- app/prompts/interview.md: 4 sub-phase question bank + @@RESPONDER directive (T020)
- app/prompt_loader.py: compose system + interview prompts (T021)
- app/responder.py: parse @@RESPONDER -> ResponderSpec, lenient fallback
- app/phases/interview.py: mockup-faithful progress rail, chat bubbles,
thinking indicator, structured responder (choice/country/text), ghost
"Save and continue later"; real per-delta streaming; turn_end sets responder
mode; agent_end advances state (T022-T025)
- app/countries.py: country selector choices from countries.json
- app/design_tokens.py: align CSS var names to mockup (--primary/--bg/--r-md/
--s-md) + shadows/maxw from DESIGN §4/§5; app.py hero updated
- app/app.py: mount interview, auto-start first question via demo.load

Tests (33 pass): responder parse, event contracts (T028), loop isolation,
loop error->ErrorEvent (SC-015); real-model interview flow + 3 distinct
questions (SC-008/014) and progressive streaming via the Gradio handler
(SC-009/010/011).

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

agent/loop.py CHANGED
@@ -153,14 +153,16 @@ class AgentLoop:
153
  tool_map = self._tool_map(active_tools)
154
  tool_schemas = [t.to_ollama_schema() for t in active_tools] or None
155
 
156
- # Build the message list: system prompt, prior history, new prompt.
157
- messages: list[dict] = []
158
- if system_prompt:
159
- messages.append({"role": "system", "content": system_prompt})
160
- prior = list(getattr(session, "messages", None) or [])
161
- messages.extend(prior)
162
  if prompt:
163
- messages.append({"role": "user", "content": prompt})
 
 
 
 
164
 
165
  try:
166
  client = self._client_lazy()
@@ -176,7 +178,7 @@ class AgentLoop:
176
 
177
  think_arg = thinking_level if thinking_level in ("low", "medium", "high") else None
178
 
179
- async for chunk in self._iter_chat(client, messages, tool_schemas, think_arg):
180
  if self.abort_event.is_set():
181
  break
182
  msg = getattr(chunk, "message", None)
@@ -203,7 +205,7 @@ class AgentLoop:
203
  args = {}
204
  serialised_calls.append({"function": {"name": fn.name, "arguments": args}})
205
  assistant_message["tool_calls"] = serialised_calls
206
- messages.append(assistant_message)
207
 
208
  # Run each requested tool and feed results back to the model.
209
  for call in serialised_calls:
@@ -212,7 +214,7 @@ class AgentLoop:
212
  yield ToolStartEvent(name=name, args=args)
213
  result = await self._execute_tool(name, args, tool_map)
214
  yield ToolEndEvent(name=name, result=result)
215
- messages.append(
216
  {
217
  "role": "tool",
218
  "name": name,
@@ -223,21 +225,35 @@ class AgentLoop:
223
  continue
224
 
225
  # No tool calls: this turn produced the final answer.
226
- messages.append(assistant_message)
227
  yield TurnEndEvent(message=assistant_message)
228
 
229
  # Honour any steering message queued during the turn.
230
  if not self.steering_queue.empty():
231
  steer_msg = await self.steering_queue.get()
232
- messages.append({"role": "user", "content": steer_msg})
233
  continue
234
 
235
  break
236
 
237
- yield AgentEndEvent(messages=messages)
238
 
239
  except Exception as exc: # noqa: BLE001 — surfaced to UI, never raised through
240
  yield ErrorEvent(message=f"{type(exc).__name__}: {exc}")
241
 
242
 
243
- __all__ = ["AgentLoop", "AgentTool", "DEFAULT_MODEL_ID"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  tool_map = self._tool_map(active_tools)
154
  tool_schemas = [t.to_ollama_schema() for t in active_tools] or None
155
 
156
+ # Conversation history excludes the system prompt: the system message is
157
+ # supplied fresh on every API call, so it must not be persisted into the
158
+ # history we return (otherwise it would accumulate across turns).
159
+ history: list[dict] = list(getattr(session, "messages", None) or [])
 
 
160
  if prompt:
161
+ history.append({"role": "user", "content": prompt})
162
+
163
+ def api_messages() -> list[dict]:
164
+ sys = [{"role": "system", "content": system_prompt}] if system_prompt else []
165
+ return sys + history
166
 
167
  try:
168
  client = self._client_lazy()
 
178
 
179
  think_arg = thinking_level if thinking_level in ("low", "medium", "high") else None
180
 
181
+ async for chunk in self._iter_chat(client, api_messages(), tool_schemas, think_arg):
182
  if self.abort_event.is_set():
183
  break
184
  msg = getattr(chunk, "message", None)
 
205
  args = {}
206
  serialised_calls.append({"function": {"name": fn.name, "arguments": args}})
207
  assistant_message["tool_calls"] = serialised_calls
208
+ history.append(assistant_message)
209
 
210
  # Run each requested tool and feed results back to the model.
211
  for call in serialised_calls:
 
214
  yield ToolStartEvent(name=name, args=args)
215
  result = await self._execute_tool(name, args, tool_map)
216
  yield ToolEndEvent(name=name, result=result)
217
+ history.append(
218
  {
219
  "role": "tool",
220
  "name": name,
 
225
  continue
226
 
227
  # No tool calls: this turn produced the final answer.
228
+ history.append(assistant_message)
229
  yield TurnEndEvent(message=assistant_message)
230
 
231
  # Honour any steering message queued during the turn.
232
  if not self.steering_queue.empty():
233
  steer_msg = await self.steering_queue.get()
234
+ history.append({"role": "user", "content": steer_msg})
235
  continue
236
 
237
  break
238
 
239
+ yield AgentEndEvent(messages=history)
240
 
241
  except Exception as exc: # noqa: BLE001 — surfaced to UI, never raised through
242
  yield ErrorEvent(message=f"{type(exc).__name__}: {exc}")
243
 
244
 
245
+ def create_loop(
246
+ tools: Optional[Iterable[AgentTool]] = None,
247
+ model_id: Optional[str] = None,
248
+ host: Optional[str] = None,
249
+ ) -> AgentLoop:
250
+ """Factory: a fresh, isolated AgentLoop for one Gradio session (T019).
251
+
252
+ Each session must call this to get its own loop instance — the steering
253
+ queue and abort flag are per-instance, so concurrent sessions never share
254
+ mutable state.
255
+ """
256
+ return AgentLoop(tools=tools, model_id=model_id, host=host)
257
+
258
+
259
+ __all__ = ["AgentLoop", "AgentTool", "DEFAULT_MODEL_ID", "create_loop"]
app/app.py CHANGED
@@ -24,6 +24,7 @@ import gradio as gr # noqa: E402
24
 
25
  from app.config import load_env # noqa: E402
26
  from app.design_tokens import root_css # noqa: E402
 
27
 
28
  # Load .env (OLLAMA_HOST, MODEL_ID, …) before anything reads the environment.
29
  load_env()
@@ -39,15 +40,15 @@ FONT_IMPORT = (
39
  # literal hex/px design values live here.
40
  COMPONENT_CSS = """
41
  body, .gradio-container {
42
- background: var(--color-background) !important;
43
- color: var(--color-text-primary);
44
  font-family: var(--font-ui);
45
  }
46
- .gradio-container { max-width: 780px !important; margin: 0 auto !important; }
47
 
48
  #refuge-hero {
49
  text-align: center;
50
- padding: var(--space-xxl) var(--space-lg);
51
  }
52
  #refuge-wordmark {
53
  font-family: var(--font-display);
@@ -55,40 +56,44 @@ body, .gradio-container {
55
  font-size: clamp(38px, 6vw, 58px);
56
  line-height: 1.15;
57
  letter-spacing: -0.02em;
58
- color: var(--color-primary);
59
- margin: 0 0 var(--space-md) 0;
60
  text-wrap: balance;
61
  }
62
  #refuge-tagline {
63
  font-family: var(--font-ui);
64
  font-size: 18px;
65
  line-height: 1.6;
66
- color: var(--color-text-secondary);
67
- margin: 0 0 var(--space-xl) 0;
68
  }
69
  #refuge-trust {
70
  display: inline-flex;
71
  align-items: center;
72
- gap: var(--space-sm);
73
- padding: var(--space-sm) var(--space-md);
74
- border-radius: var(--radius-full);
75
- background: var(--color-surface);
76
- border: 1px solid var(--color-line);
77
- color: var(--color-text-muted);
78
  font-size: 13.5px;
 
79
  }
80
- #refuge-trust .lock { color: var(--color-primary); }
81
  #refuge-footer {
82
- margin-top: var(--space-xxl);
83
- padding-top: var(--space-lg);
84
- border-top: 1px solid var(--color-line);
85
- color: var(--color-text-muted);
86
  font-size: 12px;
87
  text-align: center;
88
  }
89
  """
90
 
91
- APP_CSS = FONT_IMPORT + "\n" + root_css() + "\n" + COMPONENT_CSS
 
 
 
92
 
93
  HERO_HTML = """
94
  <main id="refuge-hero">
@@ -96,7 +101,6 @@ HERO_HTML = """
96
  <p id="refuge-tagline">Safe guidance for people on the move</p>
97
  <span id="refuge-trust"><span class="lock">&#128274;</span>
98
  This conversation is private. Nothing is stored without your consent.</span>
99
- <div id="refuge-footer">Built for the Hugging Face Build Small Hackathon &middot; runs on a small, local model</div>
100
  </main>
101
  """
102
 
@@ -105,6 +109,9 @@ def build_app() -> gr.Blocks:
105
  # Gradio 6: `css` is passed to launch(), not the Blocks constructor.
106
  with gr.Blocks(title="Refuge", analytics_enabled=False) as demo:
107
  gr.HTML(HERO_HTML)
 
 
 
108
  return demo
109
 
110
 
 
24
 
25
  from app.config import load_env # noqa: E402
26
  from app.design_tokens import root_css # noqa: E402
27
+ from app.phases import interview as interview_phase # noqa: E402
28
 
29
  # Load .env (OLLAMA_HOST, MODEL_ID, …) before anything reads the environment.
30
  load_env()
 
40
  # literal hex/px design values live here.
41
  COMPONENT_CSS = """
42
  body, .gradio-container {
43
+ background: var(--bg) !important;
44
+ color: var(--text);
45
  font-family: var(--font-ui);
46
  }
47
+ .gradio-container { max-width: var(--maxw) !important; margin: 0 auto !important; }
48
 
49
  #refuge-hero {
50
  text-align: center;
51
+ padding: var(--s-xxl) var(--s-lg);
52
  }
53
  #refuge-wordmark {
54
  font-family: var(--font-display);
 
56
  font-size: clamp(38px, 6vw, 58px);
57
  line-height: 1.15;
58
  letter-spacing: -0.02em;
59
+ color: var(--primary);
60
+ margin: 0 0 var(--s-md) 0;
61
  text-wrap: balance;
62
  }
63
  #refuge-tagline {
64
  font-family: var(--font-ui);
65
  font-size: 18px;
66
  line-height: 1.6;
67
+ color: var(--text-secondary);
68
+ margin: 0 0 var(--s-xl) 0;
69
  }
70
  #refuge-trust {
71
  display: inline-flex;
72
  align-items: center;
73
+ gap: var(--s-sm);
74
+ padding: var(--s-sm) var(--s-md);
75
+ border-radius: var(--r-full);
76
+ background: var(--surface);
77
+ border: 1px solid var(--line);
78
+ color: var(--text-muted);
79
  font-size: 13.5px;
80
+ box-shadow: var(--shadow-sm);
81
  }
82
+ #refuge-trust .lock { color: var(--primary); }
83
  #refuge-footer {
84
+ margin-top: var(--s-xxl);
85
+ padding-top: var(--s-lg);
86
+ border-top: 1px solid var(--line);
87
+ color: var(--text-muted);
88
  font-size: 12px;
89
  text-align: center;
90
  }
91
  """
92
 
93
+ APP_CSS = (
94
+ FONT_IMPORT + "\n" + root_css() + "\n" + COMPONENT_CSS
95
+ + "\n" + interview_phase.INTERVIEW_CSS
96
+ )
97
 
98
  HERO_HTML = """
99
  <main id="refuge-hero">
 
101
  <p id="refuge-tagline">Safe guidance for people on the move</p>
102
  <span id="refuge-trust"><span class="lock">&#128274;</span>
103
  This conversation is private. Nothing is stored without your consent.</span>
 
104
  </main>
105
  """
106
 
 
109
  # Gradio 6: `css` is passed to launch(), not the Blocks constructor.
110
  with gr.Blocks(title="Refuge", analytics_enabled=False) as demo:
111
  gr.HTML(HERO_HTML)
112
+ iv = interview_phase.build()
113
+ # Auto-start the interview: stream the agent's first question on load.
114
+ demo.load(iv.start_fn, inputs=iv.start_inputs, outputs=iv.stream_outputs)
115
  return demo
116
 
117
 
app/countries.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """app/countries.py — lightweight country list for UI selectors.
2
+
3
+ Reads names + flags from specs/data/countries.json (the committed fallback) for
4
+ the interview's country selector. The richer per-country asylum data is loaded by
5
+ the country_lookup tool in Phase 3; this is only for the dropdown labels.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+
14
+ REPO_ROOT = Path(__file__).resolve().parent.parent
15
+ COUNTRIES_JSON = REPO_ROOT / "specs" / "data" / "countries.json"
16
+
17
+
18
+ @lru_cache(maxsize=1)
19
+ def country_choices() -> list[str]:
20
+ """Sorted ``"<flag> <name>"`` labels for every documented country."""
21
+ data = json.loads(COUNTRIES_JSON.read_text(encoding="utf-8"))
22
+ labels: list[str] = []
23
+ for group in ("signatories", "non_signatories"):
24
+ for entry in data.get(group, {}).values():
25
+ name = entry.get("name")
26
+ if not name:
27
+ continue
28
+ flag = entry.get("flag", "").strip()
29
+ labels.append(f"{flag} {name}".strip())
30
+ return sorted(set(labels), key=lambda s: s.split(" ", 1)[-1])
31
+
32
+
33
+ def country_name(label: str) -> str:
34
+ """Strip the leading flag emoji from a selector label."""
35
+ if not label:
36
+ return ""
37
+ parts = label.split(" ", 1)
38
+ # If the first token is non-ASCII (a flag), drop it.
39
+ if len(parts) == 2 and not parts[0].isascii():
40
+ return parts[1].strip()
41
+ return label.strip()
42
+
43
+
44
+ __all__ = ["country_choices", "country_name", "COUNTRIES_JSON"]
app/design_tokens.py CHANGED
@@ -1,13 +1,17 @@
1
  """app/design_tokens.py — single source for DESIGN.md tokens in Python.
2
 
3
- DESIGN.md is the canonical design system (CLAUDE.md Design Rule 7). To guarantee
4
- the Gradio app can never drift from it, both the app's injected ``:root`` CSS
5
- block and the T010 token validator are derived from this one parser. No hardcoded
6
- hex values live in the Python UI code (ARCHITECTURE.md §4 theming contract).
7
-
8
- The parser reads the YAML frontmatter of DESIGN.md without a YAML dependency —
9
- it only needs the flat ``colors``, ``typography`` (font families), ``rounded``,
10
- and ``spacing`` maps, all of which are simple ``key: "value"`` lines.
 
 
 
 
11
  """
12
 
13
  from __future__ import annotations
@@ -15,35 +19,42 @@ from __future__ import annotations
15
  import re
16
  from pathlib import Path
17
 
18
- # Repo root = two levels up from this file (app/ -> repo).
19
  REPO_ROOT = Path(__file__).resolve().parent.parent
20
  DESIGN_MD = REPO_ROOT / "DESIGN.md"
21
 
22
- # Sections of the frontmatter we map to CSS custom properties, and the prefix
23
- # each becomes under :root.
24
- _SECTION_PREFIX = {
25
- "colors": "color",
26
- "rounded": "radius",
27
- "spacing": "space",
28
- }
29
 
30
- # Within typography we only lift the two font-family declarations to vars.
31
  _FONT_KEYS = {"font-display", "font-ui"}
32
 
 
 
 
 
 
 
 
 
 
 
33
  _SECTION_RE = re.compile(r"^([a-z0-9-]+):\s*$")
34
  _KV_RE = re.compile(r'^\s+([a-z0-9-]+):\s*"(.*?)"')
35
 
36
 
37
  def load_tokens(design_md: Path | None = None) -> dict[str, str]:
38
- """Parse DESIGN.md frontmatter into a flat {css-var-name: value} map.
39
 
40
- Keys are the full CSS custom property names without the leading ``--``,
41
- e.g. ``color-primary``, ``font-display``, ``radius-md``, ``space-lg``.
 
42
  """
43
  path = design_md or DESIGN_MD
44
  text = path.read_text(encoding="utf-8")
45
 
46
- # Frontmatter is the first block fenced by --- ... ---.
47
  if text.startswith("---"):
48
  end = text.find("\n---", 3)
49
  frontmatter = text[3:end] if end != -1 else text
@@ -66,11 +77,14 @@ def load_tokens(design_md: Path | None = None) -> dict[str, str]:
66
  continue
67
  key, value = kv.group(1), kv.group(2)
68
 
69
- if section in _SECTION_PREFIX:
70
- tokens[f"{_SECTION_PREFIX[section]}-{key}"] = value
 
 
71
  elif section == "typography" and key in _FONT_KEYS:
72
  tokens[key] = value
73
 
 
74
  return tokens
75
 
76
 
@@ -81,4 +95,4 @@ def root_css(design_md: Path | None = None) -> str:
81
  return ":root {\n" + "\n".join(lines) + "\n}"
82
 
83
 
84
- __all__ = ["load_tokens", "root_css", "DESIGN_MD", "REPO_ROOT"]
 
1
  """app/design_tokens.py — single source for DESIGN.md tokens in Python.
2
 
3
+ DESIGN.md is the canonical design system (CLAUDE.md Design Rule 7) and
4
+ ``mockup.html`` is the visual reference. To guarantee the Gradio app matches
5
+ both, this module parses DESIGN.md's YAML frontmatter and emits CSS custom
6
+ properties using the **same variable names as mockup.html** (``--primary``,
7
+ ``--bg``, ``--text``, ``--r-md``, ``--s-md`` …). That way the phase CSS can be
8
+ lifted from the mockup verbatim and stay token-faithful.
9
+
10
+ A few tokens that mockup.html uses live in DESIGN.md prose (§4 Layout, §5
11
+ Elevation) rather than the frontmatter — the shadows and the max reading width.
12
+ Those are declared here as ``PROSE_TOKENS`` with their DESIGN.md source noted.
13
+
14
+ No hardcoded hex values live in the Python UI code (ARCHITECTURE.md §4).
15
  """
16
 
17
  from __future__ import annotations
 
19
  import re
20
  from pathlib import Path
21
 
 
22
  REPO_ROOT = Path(__file__).resolve().parent.parent
23
  DESIGN_MD = REPO_ROOT / "DESIGN.md"
24
 
25
+ # DESIGN.md frontmatter sections we lift, and how each key becomes a CSS var.
26
+ # Most keys map to "<prefix><key>"; a few colours are renamed to match the
27
+ # mockup's shorter names (background -> bg, text-primary -> text).
28
+ _COLOR_RENAME = {"background": "bg", "text-primary": "text"}
29
+ _PREFIX = {"rounded": "r-", "spacing": "s-"}
 
 
30
 
31
+ # Typography keys lifted as font-family vars.
32
  _FONT_KEYS = {"font-display", "font-ui"}
33
 
34
+ # Tokens defined in DESIGN.md prose (not frontmatter) but used by mockup.html.
35
+ # shadows -> DESIGN.md §5 Elevation & Depth
36
+ # maxw -> DESIGN.md §4 Layout (max content width 780px)
37
+ PROSE_TOKENS: dict[str, str] = {
38
+ "shadow-sm": "0 1px 2px rgba(13,46,38,.06), 0 1px 1px rgba(13,46,38,.04)",
39
+ "shadow-md": "0 6px 20px rgba(13,46,38,.08)",
40
+ "shadow-lg": "0 18px 48px rgba(13,46,38,.12)",
41
+ "maxw": "780px",
42
+ }
43
+
44
  _SECTION_RE = re.compile(r"^([a-z0-9-]+):\s*$")
45
  _KV_RE = re.compile(r'^\s+([a-z0-9-]+):\s*"(.*?)"')
46
 
47
 
48
  def load_tokens(design_md: Path | None = None) -> dict[str, str]:
49
+ """Parse DESIGN.md into a flat {css-var-name: value} map (mockup naming).
50
 
51
+ Keys are CSS custom-property names without the leading ``--`` — e.g.
52
+ ``primary``, ``bg``, ``text``, ``r-md``, ``s-lg``, ``font-display`` — plus
53
+ the prose-sourced ``shadow-*`` and ``maxw`` tokens.
54
  """
55
  path = design_md or DESIGN_MD
56
  text = path.read_text(encoding="utf-8")
57
 
 
58
  if text.startswith("---"):
59
  end = text.find("\n---", 3)
60
  frontmatter = text[3:end] if end != -1 else text
 
77
  continue
78
  key, value = kv.group(1), kv.group(2)
79
 
80
+ if section == "colors":
81
+ tokens[_COLOR_RENAME.get(key, key)] = value
82
+ elif section in _PREFIX:
83
+ tokens[f"{_PREFIX[section]}{key}"] = value
84
  elif section == "typography" and key in _FONT_KEYS:
85
  tokens[key] = value
86
 
87
+ tokens.update(PROSE_TOKENS)
88
  return tokens
89
 
90
 
 
95
  return ":root {\n" + "\n".join(lines) + "\n}"
96
 
97
 
98
+ __all__ = ["load_tokens", "root_css", "PROSE_TOKENS", "DESIGN_MD", "REPO_ROOT"]
app/phases/interview.py CHANGED
@@ -1 +1,359 @@
1
- # interview.py — see PLAN.md and ARCHITECTURE.md for implementation spec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """app/phases/interview.py — Phase 2 structured interview screen (T022-T025).
2
+
3
+ Renders the interview as in mockup.html #phase-2: a progress rail, agent/user
4
+ chat bubbles, a "Refuge is listening" thinking indicator, and a structured
5
+ responder that switches between Choice pills / Country selector / Free text based
6
+ on the agent's ``@@RESPONDER`` directive. Agent replies stream token-by-token via
7
+ ``AgentLoop.run`` (no buffering).
8
+
9
+ State lives in per-session ``gr.State`` (session, loop, current responder spec),
10
+ so concurrent sessions stay isolated (T019).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import html
16
+ from dataclasses import dataclass
17
+
18
+ import gradio as gr
19
+
20
+ from agent.events import AgentEndEvent, ErrorEvent, TextDeltaEvent
21
+ from agent.loop import create_loop
22
+ from app.countries import country_choices, country_name
23
+ from app.prompt_loader import interview_system_prompt
24
+ from app.responder import ResponderSpec, parse, strip_directive
25
+ from app.state.session import SessionState, State
26
+
27
+ # Order of the progress rail (Intake is "done" once the interview starts).
28
+ RAIL = [
29
+ ("Intake", State.INTAKE),
30
+ ("Situation", State.SITUATION),
31
+ ("History", State.HISTORY),
32
+ ("Goals", State.GOALS),
33
+ ("Review", State.REVIEW),
34
+ ]
35
+
36
+ # Internal first-turn instruction — never shown to the person or resent.
37
+ BOOTSTRAP = "Please begin the interview now with your first question."
38
+
39
+ AGENT_AVATAR = (
40
+ '<svg width="18" height="18" viewBox="0 0 32 32" aria-hidden="true">'
41
+ '<path d="M16 7l7 6.5V25h-4.6v-6.2h-4.8V25H9V13.5L16 7z" fill="#fff"/></svg>'
42
+ )
43
+
44
+ # CSS lifted from mockup.html #phase-2 — variable names already match the
45
+ # injected :root block (app/design_tokens.py).
46
+ INTERVIEW_CSS = """
47
+ #iv-screen { background: var(--surface); border: 1px solid var(--line);
48
+ border-radius: var(--r-lg); box-shadow: var(--shadow-md); overflow: hidden;
49
+ max-width: var(--maxw); margin: 0 auto; }
50
+
51
+ .iv-rail { display:flex; align-items:center; gap:6px; padding:18px clamp(18px,4vw,28px);
52
+ background:var(--surface-2); border-bottom:1px solid var(--line); flex-wrap:wrap; }
53
+ .iv-pill { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--text-muted); font-weight:500; }
54
+ .iv-pill .dot { width:22px; height:22px; border-radius:var(--r-full); border:1.5px solid var(--line-strong);
55
+ display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700;
56
+ color:var(--text-muted); background:var(--surface); }
57
+ .iv-pill.done .dot { background:var(--primary); border-color:var(--primary); color:#fff; }
58
+ .iv-pill.done { color:var(--text-secondary); }
59
+ .iv-pill.active .dot { background:var(--accent); border-color:var(--accent); color:#fff; box-shadow:0 0 0 4px var(--accent-tint); }
60
+ .iv-pill.active { color:var(--text); font-weight:600; }
61
+ .iv-sep { width:18px; height:1.5px; background:var(--line-strong); flex:0 0 auto; }
62
+ @media(max-width:620px){ .iv-pill span.t{display:none;} .iv-sep{width:10px;} }
63
+
64
+ .iv-chat { padding:clamp(18px,4vw,30px); display:flex; flex-direction:column; gap:18px; background:var(--surface); }
65
+ .iv-msg { display:flex; gap:12px; max-width:88%; }
66
+ .iv-msg__av { flex:0 0 auto; width:34px; height:34px; border-radius:var(--r-full); background:var(--primary);
67
+ display:flex; align-items:center; justify-content:center; box-shadow:var(--shadow-sm); }
68
+ .iv-msg__bubble { padding:13px 16px; border-radius:14px; font-size:15px; line-height:1.55; }
69
+ .iv-msg--agent .iv-msg__bubble { background:var(--primary-tint); color:#15302a; border-bottom-left-radius:5px; }
70
+ .iv-msg--user { margin-left:auto; flex-direction:row-reverse; }
71
+ .iv-msg--user .iv-msg__bubble { background:var(--accent-tint); color:#5c3415; border-bottom-right-radius:5px; }
72
+ .iv-msg--user .iv-msg__av { background:var(--accent); }
73
+ .iv-msg--user .iv-msg__av span { color:#fff; font-size:13px; font-weight:600; }
74
+ .iv-msg--current .iv-msg__bubble { background:var(--surface); border:1.5px solid var(--primary-tint-2);
75
+ box-shadow:var(--shadow-sm); font-weight:500; color:var(--text); }
76
+
77
+ .iv-thinking { display:inline-flex; align-items:center; gap:9px; font-size:13px; color:var(--text-muted); padding:2px 0 0 46px; }
78
+ .iv-thinking .dots { display:inline-flex; gap:4px; }
79
+ .iv-thinking .dots i { width:6px; height:6px; border-radius:50%; background:var(--primary); opacity:.4; animation:ivblink 1.2s infinite; display:inline-block; }
80
+ .iv-thinking .dots i:nth-child(2){ animation-delay:.2s; }
81
+ .iv-thinking .dots i:nth-child(3){ animation-delay:.4s; }
82
+ @keyframes ivblink { 0%,60%,100%{opacity:.25;transform:translateY(0);} 30%{opacity:1;transform:translateY(-2px);} }
83
+
84
+ #iv-responder { border-top:1px solid var(--line); background:var(--surface-2); padding:clamp(16px,4vw,24px); }
85
+ #iv-responder .label-row { font-size:12px; letter-spacing:.04em; text-transform:uppercase;
86
+ color:var(--text-muted); font-weight:600; }
87
+
88
+ /* Native Gradio controls themed to match the responder */
89
+ #iv-choice .wrap label, #iv-multi .wrap label { border-radius:var(--r-full) !important; }
90
+ #iv-continue button { background:var(--primary) !important; color:var(--on-primary) !important;
91
+ box-shadow:0 2px 0 var(--primary-deep) !important; border:0 !important; font-weight:600 !important; }
92
+ #iv-continue button:hover { background:var(--primary-deep) !important; }
93
+ #iv-save button { background:transparent !important; color:var(--primary-deep) !important;
94
+ border:0 !important; box-shadow:none !important; font-weight:600 !important; }
95
+ #iv-save button:hover { background:var(--primary-tint) !important; }
96
+ """
97
+
98
+
99
+ # --------------------------------------------------------------------------
100
+ # Rendering helpers (pure functions → HTML)
101
+ # --------------------------------------------------------------------------
102
+
103
+ def render_rail(state: State) -> str:
104
+ pills = []
105
+ for i, (label, st) in enumerate(RAIL):
106
+ if i:
107
+ pills.append('<span class="iv-sep"></span>')
108
+ if state > st:
109
+ cls, dot = "iv-pill done", "✓"
110
+ elif state == st:
111
+ cls, dot = "iv-pill active", str(i + 1) if i else "✓"
112
+ else:
113
+ cls, dot = "iv-pill", str(i + 1)
114
+ pills.append(
115
+ f'<div class="{cls}"><span class="dot">{dot}</span>'
116
+ f'<span class="t">{html.escape(label)}</span></div>'
117
+ )
118
+ return f'<div class="iv-rail" aria-label="Interview progress">{"".join(pills)}</div>'
119
+
120
+
121
+ def _bubble(role: str, text: str, current: bool = False) -> str:
122
+ safe = html.escape(text).replace("\n", "<br>")
123
+ if role == "user":
124
+ return (
125
+ '<div class="iv-msg iv-msg--user"><div class="iv-msg__av"><span>You</span></div>'
126
+ f'<div class="iv-msg__bubble">{safe}</div></div>'
127
+ )
128
+ cur = " iv-msg--current" if current else ""
129
+ return (
130
+ f'<div class="iv-msg iv-msg--agent{cur}"><div class="iv-msg__av">{AGENT_AVATAR}</div>'
131
+ f'<div class="iv-msg__bubble">{safe}</div></div>'
132
+ )
133
+
134
+
135
+ def render_chat(messages: list[dict], streaming_text: str | None = None, thinking: bool = False) -> str:
136
+ """Render the visible conversation. Agent directives are stripped."""
137
+ rows: list[str] = []
138
+ visible_msgs = [m for m in messages if m.get("role") in ("user", "assistant")]
139
+ last_idx = len(visible_msgs) - 1
140
+ for i, m in enumerate(visible_msgs):
141
+ role = m["role"]
142
+ content = m.get("content", "")
143
+ if role == "assistant":
144
+ content = strip_directive(content)
145
+ if not content.strip():
146
+ continue
147
+ rows.append(_bubble("agent", content, current=(streaming_text is None and i == last_idx)))
148
+ else:
149
+ rows.append(_bubble("user", content))
150
+ if streaming_text is not None:
151
+ rows.append(_bubble("agent", strip_directive(streaming_text) or "…", current=True))
152
+ if thinking:
153
+ rows.append(
154
+ '<div class="iv-thinking" aria-live="polite"><span class="dots">'
155
+ "<i></i><i></i><i></i></span> Refuge is listening</div>"
156
+ )
157
+ return f'<div class="iv-chat">{"".join(rows)}</div>'
158
+
159
+
160
+ # --------------------------------------------------------------------------
161
+ # State helpers
162
+ # --------------------------------------------------------------------------
163
+
164
+ def advance_to(session: SessionState, target: "State | str | None") -> None:
165
+ """Step the state machine forward to ``target`` (never backward).
166
+
167
+ ``target`` may be a State or a phase name string (e.g. "SITUATION") as
168
+ produced by the responder directive.
169
+ """
170
+ if target is None:
171
+ return
172
+ if isinstance(target, str):
173
+ try:
174
+ target = State[target]
175
+ except KeyError:
176
+ return
177
+ while session.state < target:
178
+ session.advance()
179
+
180
+
181
+ def derive_answer(spec: ResponderSpec, radio_v, multi_v, country_v, text_v) -> str:
182
+ if spec.mode == "choice" and spec.multi:
183
+ return ", ".join(multi_v) if multi_v else ""
184
+ if spec.mode == "choice":
185
+ return radio_v or ""
186
+ if spec.mode == "country":
187
+ return country_name(country_v) if country_v else ""
188
+ return (text_v or "").strip()
189
+
190
+
191
+ def responder_updates(spec: ResponderSpec):
192
+ """gr.update() tuple for (radio, multi, country, text) given the spec."""
193
+ is_choice_single = spec.mode == "choice" and not spec.multi
194
+ is_choice_multi = spec.mode == "choice" and spec.multi
195
+ is_country = spec.mode == "country"
196
+ is_text = spec.mode == "text"
197
+ return (
198
+ gr.update(visible=is_choice_single, choices=spec.options if is_choice_single else [], value=None),
199
+ gr.update(visible=is_choice_multi, choices=spec.options if is_choice_multi else [], value=[]),
200
+ gr.update(visible=is_country, value=None),
201
+ gr.update(
202
+ visible=is_text,
203
+ value="",
204
+ placeholder=spec.placeholder or "Take your time. You can write in any language.",
205
+ ),
206
+ )
207
+
208
+
209
+ # --------------------------------------------------------------------------
210
+ # UI assembly
211
+ # --------------------------------------------------------------------------
212
+
213
+ @dataclass
214
+ class InterviewUI:
215
+ column: gr.Column
216
+ session: gr.State
217
+ loop: gr.State
218
+ spec: gr.State
219
+ start_fn: callable
220
+ start_inputs: list
221
+ stream_outputs: list
222
+
223
+
224
+ def build() -> InterviewUI:
225
+ """Build the interview screen inside the current gr.Blocks context."""
226
+ session_st = gr.State(None)
227
+ loop_st = gr.State(None)
228
+ spec_st = gr.State(ResponderSpec())
229
+
230
+ with gr.Column(elem_id="iv-screen") as column:
231
+ rail = gr.HTML(render_rail(State.SITUATION))
232
+ chat = gr.HTML(render_chat([], thinking=True))
233
+
234
+ with gr.Column(elem_id="iv-responder"):
235
+ gr.HTML('<div class="label-row">Your answer</div>')
236
+ radio = gr.Radio(choices=[], label="", visible=False, elem_id="iv-choice")
237
+ multi = gr.CheckboxGroup(choices=[], label="", visible=False, elem_id="iv-multi")
238
+ country = gr.Dropdown(
239
+ choices=country_choices(), label="", visible=False,
240
+ allow_custom_value=True, filterable=True, elem_id="iv-country",
241
+ )
242
+ text = gr.Textbox(
243
+ label="", lines=3, visible=False, elem_id="iv-text",
244
+ placeholder="Take your time. You can write in any language.",
245
+ )
246
+ with gr.Row():
247
+ save = gr.Button("⤓ Save and continue later", elem_id="iv-save", scale=1)
248
+ cont = gr.Button("Continue →", elem_id="iv-continue", scale=1)
249
+
250
+ stream_outputs = [chat, rail, radio, multi, country, text, session_st, loop_st, spec_st]
251
+
252
+ # -- handlers -------------------------------------------------------
253
+
254
+ async def _run_turn(prompt, session, loop):
255
+ """Shared streaming routine. ``prompt`` is the new user turn, or ""
256
+ when the user message is already appended to ``session.messages``."""
257
+ system_prompt = interview_system_prompt()
258
+ acc = ""
259
+ final_messages = list(session.messages)
260
+ async for ev in loop.run(prompt, session, system_prompt=system_prompt):
261
+ if isinstance(ev, TextDeltaEvent):
262
+ acc += ev.delta
263
+ yield render_chat(session.messages, streaming_text=acc), None
264
+ elif isinstance(ev, AgentEndEvent):
265
+ final_messages = ev.messages
266
+ elif isinstance(ev, ErrorEvent):
267
+ acc += f"\n\n_(Something went wrong: {ev.message})_"
268
+ yield render_chat(session.messages, streaming_text=acc), None
269
+
270
+ session.messages = final_messages
271
+ _, spec = parse(acc)
272
+ advance_to(session, spec.phase)
273
+ yield render_chat(session.messages), spec
274
+
275
+ async def start(session, loop):
276
+ if session is None:
277
+ session = SessionState()
278
+ session.transition_to(State.INTAKE)
279
+ loop = create_loop()
280
+ # entering the interview shows the thinking indicator immediately
281
+ yield (
282
+ render_chat(session.messages, thinking=True), render_rail(session.state),
283
+ gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
284
+ gr.update(visible=False), session, loop, ResponderSpec(),
285
+ )
286
+ spec = ResponderSpec()
287
+ async for chat_html, maybe_spec in _run_turn(BOOTSTRAP, session, loop):
288
+ if maybe_spec is None:
289
+ yield (chat_html, render_rail(session.state), *(_NO_CHANGE * 4),
290
+ session, loop, gr.update())
291
+ else:
292
+ spec = maybe_spec
293
+ # The bootstrap instruction is internal — never show or resend it.
294
+ session.messages = [
295
+ m for m in session.messages
296
+ if not (m.get("role") == "user" and m.get("content") == BOOTSTRAP)
297
+ ]
298
+ r_up = responder_updates(spec)
299
+ yield (render_chat(session.messages), render_rail(session.state), *r_up,
300
+ session, loop, spec)
301
+
302
+ async def on_continue(radio_v, multi_v, country_v, text_v, session, loop, spec):
303
+ if session is None:
304
+ session = SessionState(); session.transition_to(State.INTAKE)
305
+ loop = create_loop()
306
+ answer = derive_answer(spec, radio_v, multi_v, country_v, text_v)
307
+ if not answer:
308
+ # nothing chosen — leave everything as-is
309
+ yield (gr.update(), gr.update(), *(_NO_CHANGE * 4), session, loop, spec)
310
+ return
311
+ # Append the answer now so it shows during streaming; pass "" to the
312
+ # loop so it isn't added a second time.
313
+ session.messages = list(session.messages) + [{"role": "user", "content": answer}]
314
+ yield (
315
+ render_chat(session.messages, thinking=True), render_rail(session.state),
316
+ gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
317
+ gr.update(visible=False), session, loop, spec,
318
+ )
319
+ new_spec = spec
320
+ async for chat_html, maybe_spec in _run_turn("", session, loop):
321
+ if maybe_spec is None:
322
+ yield (chat_html, render_rail(session.state), *(_NO_CHANGE * 4),
323
+ session, loop, gr.update())
324
+ else:
325
+ new_spec = maybe_spec
326
+ r_up = responder_updates(new_spec)
327
+ yield (render_chat(session.messages), render_rail(session.state), *r_up,
328
+ session, loop, new_spec)
329
+
330
+ cont.click(
331
+ on_continue,
332
+ inputs=[radio, multi, country, text, session_st, loop_st, spec_st],
333
+ outputs=stream_outputs,
334
+ )
335
+
336
+ return InterviewUI(
337
+ column=column,
338
+ session=session_st,
339
+ loop=loop_st,
340
+ spec=spec_st,
341
+ start_fn=start,
342
+ start_inputs=[session_st, loop_st],
343
+ stream_outputs=stream_outputs,
344
+ )
345
+
346
+
347
+ # A no-op gr.update placeholder used when a yield doesn't touch a control.
348
+ _NO_CHANGE = (gr.update(),)
349
+
350
+
351
+ __all__ = [
352
+ "build",
353
+ "InterviewUI",
354
+ "INTERVIEW_CSS",
355
+ "render_chat",
356
+ "render_rail",
357
+ "advance_to",
358
+ "derive_answer",
359
+ ]
app/prompt_loader.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """app/prompt_loader.py — load and compose the agent's markdown prompts.
2
+
3
+ Prompts live in app/prompts/*.md. ``system.md`` is always the base persona;
4
+ phase prompts (``interview.md``, ``assessment.md``) are appended for the phase
5
+ the agent is currently driving (T021).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from functools import lru_cache
11
+ from pathlib import Path
12
+
13
+ PROMPTS_DIR = Path(__file__).resolve().parent / "prompts"
14
+
15
+
16
+ @lru_cache(maxsize=None)
17
+ def load_prompt(name: str) -> str:
18
+ """Read a prompt markdown file by stem (e.g. ``"system"``)."""
19
+ path = PROMPTS_DIR / f"{name}.md"
20
+ return path.read_text(encoding="utf-8").strip()
21
+
22
+
23
+ def compose(*names: str) -> str:
24
+ """Join several prompts with blank-line separators, in order."""
25
+ return "\n\n".join(load_prompt(n) for n in names)
26
+
27
+
28
+ def interview_system_prompt() -> str:
29
+ """Base persona + interview protocol + responder directive spec (T021)."""
30
+ return compose("system", "interview")
31
+
32
+
33
+ __all__ = ["load_prompt", "compose", "interview_system_prompt", "PROMPTS_DIR"]
app/prompts/interview.md CHANGED
@@ -1 +1,79 @@
1
- # interview.md — to be written by Codex per PLAN.md
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Interview protocol
2
+
3
+ You are now conducting the **structured interview**. Gather what you need to
4
+ assess this person's situation, one question at a time, in their chosen language.
5
+ Be warm and unhurried. Acknowledge what they just said before asking the next
6
+ thing. Never stack two questions in one message.
7
+
8
+ The interview has four sub-phases, in order. Move forward through them; never go
9
+ back to a sub-phase you have left.
10
+
11
+ ## SITUATION
12
+ - What country are you currently in, and what country are you originally from?
13
+ - What is the primary reason you had to leave your home?
14
+ (political / ethnic / religious / gender-based / sexual orientation /
15
+ climate displacement / other)
16
+ - Are you in immediate danger where you are now?
17
+
18
+ ## HISTORY
19
+ - How long ago did you have to leave?
20
+ - Have you applied for asylum or refugee status anywhere before?
21
+ - What identity or travel documents do you still have?
22
+
23
+ ## GOALS
24
+ - Is there a country where you already have family or a community?
25
+ - Do you have a preference for where you would like to seek safety?
26
+ - What languages do you speak?
27
+
28
+ ## REVIEW
29
+ - Briefly summarise back what you have understood, and ask the person to
30
+ confirm it is correct before you begin your assessment.
31
+
32
+ ---
33
+
34
+ # Answer controls — REQUIRED machine directive
35
+
36
+ The interface shows the person a tailored answer control for each question.
37
+ You choose it by ending **every** message with **exactly one** directive line,
38
+ on its own line, in this format:
39
+
40
+ @@RESPONDER mode=<choice|country|text>; phase=<SITUATION|HISTORY|GOALS|REVIEW>; multi=<true|false>; options=<A | B | C>; placeholder=<short hint>
41
+
42
+ Rules:
43
+ - The directive line is the **last line** of your message. Write nothing after it.
44
+ - It is metadata for the interface — the person never sees it. Keep it out of the
45
+ natural-language part of your message.
46
+ - `mode=choice` → also give `options=` (a `|`-separated list) and `multi=` (true
47
+ if several can apply, e.g. reasons for leaving; false for yes/no).
48
+ - `mode=country` → for any "which country" question. No options needed.
49
+ - `mode=text` → for open questions; give a short `placeholder=`.
50
+ - `phase=` is the sub-phase this question belongs to. Set it correctly so the
51
+ progress rail advances. Move it forward only (SITUATION → HISTORY → GOALS →
52
+ REVIEW).
53
+
54
+ ## Examples
55
+
56
+ A yes/no question:
57
+
58
+ Are you in immediate danger where you are right now?
59
+ @@RESPONDER mode=choice; phase=SITUATION; multi=false; options=Yes | No
60
+
61
+ The reason-for-leaving question:
62
+
63
+ What is the primary reason you had to leave your home? Take your time.
64
+ @@RESPONDER mode=choice; phase=SITUATION; multi=true; options=Political | Ethnic | Religious | Gender-based | Sexual orientation | Climate displacement | Other
65
+
66
+ A country question:
67
+
68
+ Which country are you originally from?
69
+ @@RESPONDER mode=country; phase=SITUATION
70
+
71
+ An open question:
72
+
73
+ What languages do you speak?
74
+ @@RESPONDER mode=text; phase=GOALS; placeholder=e.g. Amharic, Arabic (basic)
75
+
76
+ The review step:
77
+
78
+ Here is what I've understood so far: … Is this correct?
79
+ @@RESPONDER mode=choice; phase=REVIEW; multi=false; options=Yes, that's correct | Something needs changing
app/responder.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """app/responder.py — parse the agent's ``@@RESPONDER`` answer-control directive.
2
+
3
+ The interview agent ends each message with one machine directive line telling the
4
+ UI which answer control to show (see app/prompts/interview.md). This module
5
+ splits that directive off the visible message and parses it into a
6
+ :class:`ResponderSpec`, so the chat bubble shows only natural language and the
7
+ structured responder switches mode deterministically (SC-011).
8
+
9
+ Parsing is lenient: a missing or malformed directive falls back to free text,
10
+ so a small model that forgets the directive never breaks the UI.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from typing import Optional
18
+
19
+ DIRECTIVE_RE = re.compile(r"^[ \t]*@@RESPONDER\b(.*)$", re.IGNORECASE | re.MULTILINE)
20
+
21
+ VALID_MODES = {"choice", "country", "text"}
22
+ VALID_PHASES = {"SITUATION", "HISTORY", "GOALS", "REVIEW"}
23
+
24
+
25
+ @dataclass
26
+ class ResponderSpec:
27
+ mode: str = "text"
28
+ options: list[str] = field(default_factory=list)
29
+ multi: bool = False
30
+ placeholder: str = ""
31
+ phase: Optional[str] = None
32
+
33
+
34
+ def _parse_fields(body: str) -> dict[str, str]:
35
+ """Parse ``key=value; key=value`` pairs from a directive body."""
36
+ fields: dict[str, str] = {}
37
+ for part in body.split(";"):
38
+ if "=" not in part:
39
+ continue
40
+ key, _, value = part.partition("=")
41
+ key = key.strip().lower()
42
+ value = value.strip()
43
+ if key:
44
+ fields[key] = value
45
+ return fields
46
+
47
+
48
+ def parse(text: str) -> tuple[str, ResponderSpec]:
49
+ """Split ``text`` into (visible_message, ResponderSpec).
50
+
51
+ The visible message is everything before the directive line, trimmed. If no
52
+ directive is present, the whole text is returned with a free-text spec.
53
+ """
54
+ if not text:
55
+ return "", ResponderSpec(mode="text")
56
+
57
+ match = DIRECTIVE_RE.search(text)
58
+ if not match:
59
+ return text.strip(), ResponderSpec(mode="text")
60
+
61
+ visible = text[: match.start()].rstrip()
62
+ fields = _parse_fields(match.group(1))
63
+
64
+ mode = fields.get("mode", "text").lower()
65
+ if mode not in VALID_MODES:
66
+ mode = "text"
67
+
68
+ options = [o.strip() for o in fields.get("options", "").split("|") if o.strip()]
69
+ multi = fields.get("multi", "false").lower() == "true"
70
+
71
+ phase = fields.get("phase", "").upper() or None
72
+ if phase not in VALID_PHASES:
73
+ phase = None
74
+
75
+ # A choice with no options is meaningless — degrade to free text.
76
+ if mode == "choice" and not options:
77
+ mode = "text"
78
+
79
+ return visible, ResponderSpec(
80
+ mode=mode,
81
+ options=options,
82
+ multi=multi,
83
+ placeholder=fields.get("placeholder", ""),
84
+ phase=phase,
85
+ )
86
+
87
+
88
+ def strip_directive(text: str) -> str:
89
+ """Return just the visible message (drops the directive)."""
90
+ return parse(text)[0]
91
+
92
+
93
+ __all__ = ["ResponderSpec", "parse", "strip_directive", "VALID_PHASES", "VALID_MODES"]
tests/integration/test_interview_flow.py CHANGED
@@ -1 +1,118 @@
1
- # test_interview_flow.py — see PLAN.md and ARCHITECTURE.md for implementation spec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """tests/integration/test_interview_flow.py — real multi-turn interview (T026).
2
+
3
+ Uses the real AgentLoop and a real model (whatever MODEL_ID/.env points at —
4
+ gemma4:12b by default) against the configured Ollama host. No mocks, no
5
+ fabricated tool results. Skips cleanly if the model host is unreachable so the
6
+ unit suite can still run offline.
7
+
8
+ Covers: SC-008 (3 distinct agent questions), SC-014 (state advances within
9
+ SITUATION..REVIEW), and the streaming event ordering contract.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+
16
+ import pytest
17
+
18
+ from agent.events import (
19
+ AgentEndEvent,
20
+ AgentStartEvent,
21
+ ErrorEvent,
22
+ TextDeltaEvent,
23
+ TurnStartEvent,
24
+ )
25
+ from agent.loop import create_loop
26
+ from app.phases.interview import BOOTSTRAP, advance_to
27
+ from app.prompt_loader import interview_system_prompt
28
+ from app.responder import parse
29
+ from app.state.session import SessionState, State
30
+
31
+
32
+ def _host_reachable() -> bool:
33
+ import os
34
+ import urllib.request
35
+
36
+ host = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/")
37
+ try:
38
+ urllib.request.urlopen(host + "/api/tags", timeout=3)
39
+ return True
40
+ except Exception:
41
+ return False
42
+
43
+
44
+ pytestmark = pytest.mark.skipif(
45
+ not _host_reachable(), reason="Ollama host not reachable; integration test needs a real model"
46
+ )
47
+
48
+
49
+ async def _run_turn(loop, session, prompt):
50
+ """Drive one real turn; return (event_types, agent_text, spec)."""
51
+ types: list[str] = []
52
+ acc = ""
53
+ final = list(session.messages)
54
+ async for ev in loop.run(prompt, session, system_prompt=interview_system_prompt()):
55
+ types.append(ev.type)
56
+ if isinstance(ev, TextDeltaEvent):
57
+ acc += ev.delta
58
+ elif isinstance(ev, AgentEndEvent):
59
+ final = ev.messages
60
+ elif isinstance(ev, ErrorEvent):
61
+ raise AssertionError(f"loop raised ErrorEvent: {ev.message}")
62
+ session.messages = final
63
+ _, spec = parse(acc)
64
+ advance_to(session, spec.phase)
65
+ return types, acc.strip(), spec
66
+
67
+
68
+ def _assert_stream_order(types: list[str]):
69
+ assert types, "no events were produced"
70
+ assert types[0] == "agent_start", f"first event must be agent_start, got {types[0]}"
71
+ assert types[-1] == "agent_end", f"last event must be agent_end, got {types[-1]}"
72
+ assert "turn_start" in types
73
+ assert types.index("agent_start") < types.index("turn_start")
74
+ assert "text_delta" in types, "expected streamed text deltas"
75
+
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_real_interview_multiturn():
79
+ session = SessionState()
80
+ session.transition_to(State.INTAKE)
81
+ loop = create_loop()
82
+
83
+ # Turn 1 — the agent's opening question.
84
+ t1_types, t1_text, _ = await _run_turn(loop, session, BOOTSTRAP)
85
+ _assert_stream_order(t1_types)
86
+ assert len(t1_text) > 0
87
+ # The bootstrap drove us into the interview proper.
88
+ assert State.SITUATION <= session.state <= State.REVIEW
89
+
90
+ # Turn 2 — the seeded real scenario.
91
+ t2_types, t2_text, _ = await _run_turn(
92
+ loop, session, "I am from Ethiopia, and I am currently in Sudan."
93
+ )
94
+ _assert_stream_order(t2_types)
95
+ assert len(t2_text) > 0, "agent must ask a follow-up, not go silent"
96
+ assert State.SITUATION <= session.state <= State.REVIEW
97
+
98
+ # Turn 3 — another answer; agent should keep the interview moving.
99
+ t3_types, t3_text, _ = await _run_turn(loop, session, "I left for political reasons.")
100
+ _assert_stream_order(t3_types)
101
+ assert len(t3_text) > 0
102
+ assert State.SITUATION <= session.state <= State.REVIEW
103
+
104
+ # SC-008: three distinct agent questions.
105
+ assert t1_text != t2_text != t3_text
106
+ assert t1_text != t3_text
107
+
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_first_event_is_agent_start_quickly():
111
+ """The loop yields AgentStartEvent before any network call (SC-004 echo)."""
112
+ loop = create_loop()
113
+ gen = loop.run(BOOTSTRAP, SessionState(), system_prompt=interview_system_prompt())
114
+ try:
115
+ first = await asyncio.wait_for(gen.__anext__(), timeout=3.0)
116
+ finally:
117
+ await gen.aclose()
118
+ assert isinstance(first, AgentStartEvent)
tests/integration/test_interview_handler.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """tests/integration/test_interview_handler.py — the real Gradio handler streams.
2
+
3
+ Exercises the exact ``start_fn`` closure the app wires to ``demo.load`` (built
4
+ inside a gr.Blocks context) against the real model. Proves the UI handler emits
5
+ progressive chat updates (SC-009/SC-010: not buffered) and ends by switching the
6
+ structured responder to a concrete mode (SC-011) while advancing state (SC-014).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import urllib.request
13
+
14
+ import gradio as gr
15
+ import pytest
16
+
17
+ from app.phases.interview import build
18
+ from app.responder import ResponderSpec
19
+ from app.state.session import State
20
+
21
+
22
+ def _host_reachable() -> bool:
23
+ host = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/")
24
+ try:
25
+ urllib.request.urlopen(host + "/api/tags", timeout=3)
26
+ return True
27
+ except Exception:
28
+ return False
29
+
30
+
31
+ pytestmark = pytest.mark.skipif(
32
+ not _host_reachable(), reason="Ollama host not reachable; needs a real model"
33
+ )
34
+
35
+
36
+ @pytest.mark.asyncio
37
+ async def test_start_handler_streams_progressively():
38
+ with gr.Blocks():
39
+ iv = build()
40
+
41
+ chat_lengths: list[int] = []
42
+ last_tuple = None
43
+ session = None
44
+ async for out in iv.start_fn(None, None):
45
+ last_tuple = out
46
+ chat_html = out[0]
47
+ # The chat HTML is the first output; track its visible size growth.
48
+ if isinstance(chat_html, str):
49
+ chat_lengths.append(len(chat_html))
50
+ # session is the 7th output (index 6)
51
+ session = out[6]
52
+
53
+ # Progressive streaming: the chat HTML grew across multiple yields rather
54
+ # than appearing in a single buffered update.
55
+ growth_steps = sum(1 for a, b in zip(chat_lengths, chat_lengths[1:]) if b > a)
56
+ assert len(chat_lengths) >= 3, f"expected multiple streamed updates, got {len(chat_lengths)}"
57
+ assert growth_steps >= 2, "chat did not grow progressively (looks buffered)"
58
+
59
+ # State advanced into the interview proper.
60
+ assert session is not None
61
+ assert State.SITUATION <= session.state <= State.REVIEW
62
+
63
+ # Final responder spec is a concrete, valid mode (mode switching works).
64
+ spec = last_tuple[8]
65
+ assert isinstance(spec, ResponderSpec)
66
+ assert spec.mode in {"choice", "country", "text"}
tests/unit/test_event_parser.py CHANGED
@@ -1 +1,67 @@
1
- # test_event_parser.py — see PLAN.md and ARCHITECTURE.md for implementation spec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """tests/unit/test_event_parser.py — agent event contracts (T028).
2
+
3
+ Every AgentEvent dataclass instantiates with the correct ``type`` string and
4
+ carries its payload fields. These are the typed contracts the Gradio phases
5
+ dispatch on, so a drift here breaks streaming.
6
+ """
7
+
8
+ from agent.events import (
9
+ AgentEndEvent,
10
+ AgentStartEvent,
11
+ ErrorEvent,
12
+ TextDeltaEvent,
13
+ ToolEndEvent,
14
+ ToolStartEvent,
15
+ TurnEndEvent,
16
+ TurnStartEvent,
17
+ )
18
+
19
+
20
+ def test_marker_events_have_correct_type():
21
+ assert AgentStartEvent().type == "agent_start"
22
+ assert TurnStartEvent().type == "turn_start"
23
+
24
+
25
+ def test_text_delta_carries_delta():
26
+ ev = TextDeltaEvent(delta="hello")
27
+ assert ev.type == "text_delta"
28
+ assert ev.delta == "hello"
29
+
30
+
31
+ def test_tool_start_carries_name_and_args():
32
+ ev = ToolStartEvent(name="country_lookup", args={"country": "Kenya"})
33
+ assert ev.type == "tool_start"
34
+ assert ev.name == "country_lookup"
35
+ assert ev.args == {"country": "Kenya"}
36
+
37
+
38
+ def test_tool_end_carries_result():
39
+ ev = ToolEndEvent(name="country_lookup", result={"unhcrPresence": True})
40
+ assert ev.type == "tool_end"
41
+ assert ev.result["unhcrPresence"] is True
42
+
43
+
44
+ def test_turn_end_carries_message():
45
+ ev = TurnEndEvent(message={"role": "assistant", "content": "hi"})
46
+ assert ev.type == "turn_end"
47
+ assert ev.message["role"] == "assistant"
48
+
49
+
50
+ def test_agent_end_carries_messages():
51
+ msgs = [{"role": "user", "content": "x"}]
52
+ ev = AgentEndEvent(messages=msgs)
53
+ assert ev.type == "agent_end"
54
+ assert ev.messages == msgs
55
+
56
+
57
+ def test_error_carries_message():
58
+ ev = ErrorEvent(message="boom")
59
+ assert ev.type == "error"
60
+ assert ev.message == "boom"
61
+
62
+
63
+ def test_default_factories_are_independent():
64
+ # Mutable defaults must not be shared between instances.
65
+ a, b = ToolStartEvent(), ToolStartEvent()
66
+ a.args["k"] = 1
67
+ assert b.args == {}
tests/unit/test_loop_errors.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """tests/unit/test_loop_errors.py — loop error handling (SC-015).
2
+
3
+ If the LLM client fails, the loop must yield an ``ErrorEvent`` and finish
4
+ gracefully — never raise through the async generator (which in the UI would be a
5
+ silent hang). No real network is used: the client is replaced with a fake.
6
+ """
7
+
8
+ import pytest
9
+
10
+ from agent.events import AgentStartEvent, ErrorEvent
11
+ from agent.loop import AgentLoop
12
+
13
+
14
+ class _RaisingClient:
15
+ async def chat(self, *args, **kwargs):
16
+ raise RuntimeError("connection refused")
17
+
18
+
19
+ class _RaisingMidStreamClient:
20
+ async def chat(self, *args, **kwargs):
21
+ async def _gen():
22
+ raise RuntimeError("stream died")
23
+ yield # pragma: no cover - makes this an async generator
24
+ return _gen()
25
+
26
+
27
+ @pytest.mark.asyncio
28
+ async def test_error_before_stream_becomes_error_event():
29
+ loop = AgentLoop()
30
+ loop._client = _RaisingClient() # bypass lazy real client
31
+ events = [ev async for ev in loop.run("hi", session=None, system_prompt="x")]
32
+ assert isinstance(events[0], AgentStartEvent)
33
+ assert isinstance(events[-1], ErrorEvent)
34
+ assert "connection refused" in events[-1].message
35
+
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_error_mid_stream_becomes_error_event():
39
+ loop = AgentLoop()
40
+ loop._client = _RaisingMidStreamClient()
41
+ events = [ev async for ev in loop.run("hi", session=None, system_prompt="x")]
42
+ types = [e.type for e in events]
43
+ assert types[0] == "agent_start"
44
+ assert types[-1] == "error"
45
+ assert "stream died" in events[-1].message
tests/unit/test_loop_isolation.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """tests/unit/test_loop_isolation.py — per-session loop isolation (T019).
2
+
3
+ Each Gradio session gets its own AgentLoop via the factory; their steering
4
+ queues and abort flags must be independent (no shared mutable state).
5
+ """
6
+
7
+ from agent.loop import AgentLoop, create_loop
8
+
9
+
10
+ def test_factory_returns_distinct_instances():
11
+ a = create_loop()
12
+ b = create_loop()
13
+ assert isinstance(a, AgentLoop) and isinstance(b, AgentLoop)
14
+ assert a is not b
15
+
16
+
17
+ def test_steering_queue_is_per_instance():
18
+ a = create_loop()
19
+ b = create_loop()
20
+ a.steer("a-message")
21
+ assert a.steering_queue.qsize() == 1
22
+ assert b.steering_queue.qsize() == 0
23
+
24
+
25
+ def test_abort_flag_is_per_instance():
26
+ a = create_loop()
27
+ b = create_loop()
28
+ a.abort()
29
+ assert a.abort_event.is_set()
30
+ assert not b.abort_event.is_set()
31
+
32
+
33
+ def test_reset_clears_abort_and_steering():
34
+ a = create_loop()
35
+ a.steer("x")
36
+ a.abort()
37
+ a.reset()
38
+ assert not a.abort_event.is_set()
39
+ assert a.steering_queue.empty()
tests/unit/test_responder.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """tests/unit/test_responder.py — @@RESPONDER directive parsing.
2
+
3
+ The directive must be split cleanly off the visible message, parsed correctly,
4
+ and degrade safely to free text when missing or malformed.
5
+ """
6
+
7
+ from app.responder import ResponderSpec, parse, strip_directive
8
+
9
+
10
+ def test_choice_multi_with_options():
11
+ text = (
12
+ "What is the primary reason you had to leave?\n"
13
+ "@@RESPONDER mode=choice; phase=SITUATION; multi=true; "
14
+ "options=Political | Ethnic | Religious | Other"
15
+ )
16
+ visible, spec = parse(text)
17
+ assert visible == "What is the primary reason you had to leave?"
18
+ assert "@@RESPONDER" not in visible
19
+ assert spec.mode == "choice"
20
+ assert spec.multi is True
21
+ assert spec.phase == "SITUATION"
22
+ assert spec.options == ["Political", "Ethnic", "Religious", "Other"]
23
+
24
+
25
+ def test_yes_no_choice_single():
26
+ visible, spec = parse(
27
+ "Are you in immediate danger?\n@@RESPONDER mode=choice; phase=SITUATION; "
28
+ "multi=false; options=Yes | No"
29
+ )
30
+ assert spec.mode == "choice"
31
+ assert spec.multi is False
32
+ assert spec.options == ["Yes", "No"]
33
+
34
+
35
+ def test_country_mode():
36
+ visible, spec = parse("Which country are you from?\n@@RESPONDER mode=country; phase=SITUATION")
37
+ assert spec.mode == "country"
38
+ assert spec.phase == "SITUATION"
39
+ assert spec.options == []
40
+
41
+
42
+ def test_text_mode_with_placeholder():
43
+ _, spec = parse(
44
+ "What languages do you speak?\n@@RESPONDER mode=text; phase=GOALS; "
45
+ "placeholder=e.g. Amharic"
46
+ )
47
+ assert spec.mode == "text"
48
+ assert spec.placeholder == "e.g. Amharic"
49
+ assert spec.phase == "GOALS"
50
+
51
+
52
+ def test_missing_directive_defaults_to_text():
53
+ visible, spec = parse("Just a plain question with no directive.")
54
+ assert visible == "Just a plain question with no directive."
55
+ assert spec.mode == "text"
56
+ assert spec.phase is None
57
+
58
+
59
+ def test_choice_without_options_degrades_to_text():
60
+ _, spec = parse("Hmm?\n@@RESPONDER mode=choice; phase=SITUATION")
61
+ assert spec.mode == "text"
62
+
63
+
64
+ def test_invalid_phase_becomes_none():
65
+ _, spec = parse("Q?\n@@RESPONDER mode=country; phase=NONSENSE")
66
+ assert spec.phase is None
67
+
68
+
69
+ def test_strip_directive_helper():
70
+ assert strip_directive("Hello\n@@RESPONDER mode=text; phase=GOALS") == "Hello"