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

[agentic] Port pi loop hooks + LLM-drafted editable (Word) documents

Browse files

Audited /tmp/pi and ported its agentic control architecture into Refuge's
Python loop, then used it to make the assessment and documents genuinely agentic
(the hackathon point: small-LLM agentic AI).

Agent framework (agent/loop.py) — Python port of pi's AgentLoopConfig hooks:
- LoopHooks: transform_context, before_tool_call, after_tool_call,
should_stop_after_turn, prepare_next_turn; plus tool `terminate` handling.

Assessment now uses the hooks:
- should_stop_after_turn ends the run once the @@ASSESSMENT block is produced
(no rambling); before_tool_call blocks web_search queries containing the
person's private narrative (privacy, ARCHITECTURE §Security).

Documents are now agentic + editable:
- agent/drafting.py: the LLM drafts an in-depth, first-person personal statement
from the person's own answers, in their language, with [placeholders] to
complete (deterministic fallback if the model is down; nothing fabricated).
- doc_generator: exports every document as editable Word (.docx) AND PDF;
placeholders highlighted; "Download all" zips all 8 files.
- documents.py: populate() awaits the LLM draft; shows Word + PDF.

Fixes: recognition rate shown as % and relabelled honestly; assessment facts
panel shows the free-text "What happened"; checkbox label leak fixed.

Deps: python-docx==1.1.2 (one before latest). Tests updated (58 fast pass).

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

agent/drafting.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """agent/drafting.py — LLM-drafted asylum documents (the agentic document step).
2
+
3
+ Uses the agent loop to draft an in-depth, first-person personal statement from
4
+ the person's own answers, in their language, inserting clearly-marked
5
+ ``[placeholders]`` for specifics not collected (name, dates, names of people/
6
+ places) so they can complete and edit it. Facts are never invented (CLAUDE.md
7
+ Rule 4) — the model expands respectfully only on what the person said.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from agent.events import ErrorEvent, TextDeltaEvent
13
+ from agent.loop import create_loop
14
+
15
+ _SYSTEM = """You are Refuge, helping a person draft a first-person PERSONAL STATEMENT
16
+ to support an asylum / refugee claim. Write with dignity, in plain, clear language,
17
+ in {language}.
18
+
19
+ Rules:
20
+ - Use ONLY the facts provided. Do not invent events, dates, names, or details.
21
+ - Where a specific fact is missing (full name, date of birth, exact dates, names
22
+ of people or places, document numbers), insert a clearly marked placeholder in
23
+ square brackets, e.g. [your full name], [date], [town], so the person can fill it in.
24
+ - Expand respectfully on what the person told you about why they left — do not
25
+ exaggerate or add claims they did not make.
26
+ - Structure: 4–6 short paragraphs covering, in the first person ("I"):
27
+ 1) who I am and where I am from; 2) where I am now; 3) what happened that forced
28
+ me to leave; 4) why I fear returning; 5) the protection I am asking for.
29
+ - End with two lines: "Signed: [your full name]" and "Date: [date]".
30
+ - Output ONLY the statement text. No headings, no commentary, no markdown."""
31
+
32
+
33
+ def _facts(session) -> str:
34
+ iv = session.interview
35
+ rows = [
36
+ ("Country of origin", iv.origin_country),
37
+ ("Current country", iv.current_country),
38
+ ("What happened (their words)", iv.free_text_history
39
+ or (", ".join(iv.persecution_types) if iv.persecution_types else None)),
40
+ ("In immediate danger", None if iv.immediate_danger is None else ("yes" if iv.immediate_danger else "no")),
41
+ ("Time since leaving", iv.displacement_duration),
42
+ ("Travelling with", iv.family_situation),
43
+ ("Languages", ", ".join(iv.languages_spoken) if iv.languages_spoken else None),
44
+ ("Seeking protection in", session.selected_country),
45
+ ]
46
+ return "\n".join(f"- {k}: {v}" for k, v in rows if v)
47
+
48
+
49
+ async def draft_personal_statement(session, loop=None) -> str:
50
+ """Draft the statement via the LLM. Returns plain text with [placeholders]."""
51
+ loop = loop or create_loop()
52
+ language = getattr(session, "language", None) or "English"
53
+ system_prompt = _SYSTEM.format(language=language)
54
+ prompt = (
55
+ "Draft my personal statement now using these facts:\n\n" + _facts(session)
56
+ + "\n\nWrite it in " + language + "."
57
+ )
58
+ acc = ""
59
+ async for ev in loop.run(prompt, session=None, system_prompt=system_prompt, thinking_level="off"):
60
+ if isinstance(ev, TextDeltaEvent):
61
+ acc += ev.delta
62
+ elif isinstance(ev, ErrorEvent):
63
+ acc += "" # fall back to template below if the model failed
64
+ return acc.strip()
65
+
66
+
67
+ def fallback_statement(session) -> str:
68
+ """Deterministic statement if the model is unavailable (no fabrication)."""
69
+ iv = session.interview
70
+ origin = iv.origin_country or "[country of origin]"
71
+ current = iv.current_country or "[current country]"
72
+ reason = iv.free_text_history or "[describe what happened that made you leave]"
73
+ return (
74
+ f"My name is [your full name]. I am a national of {origin}, born in [town] on [date of birth].\n\n"
75
+ f"I am currently in {current}.\n\n"
76
+ f"I had to leave {origin} because: {reason}\n\n"
77
+ "I fear that if I am returned I will be at serious risk of harm for the reasons described above.\n\n"
78
+ "I am asking for protection and the right not to be returned to a place where my life or freedom "
79
+ "would be threatened.\n\n"
80
+ "Signed: [your full name]\nDate: [date]"
81
+ )
82
+
83
+
84
+ __all__ = ["draft_personal_statement", "fallback_statement"]
agent/loop.py CHANGED
@@ -23,9 +23,11 @@ Guarantees:
23
  from __future__ import annotations
24
 
25
  import asyncio
 
26
  import json
27
  import os
28
- from typing import AsyncGenerator, Iterable, Optional
 
29
 
30
  from agent.events import (
31
  AgentEndEvent,
@@ -45,6 +47,36 @@ DEFAULT_MODEL_ID = "qwen2.5:7b"
45
  MAX_TURNS = 12
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  class AgentLoop:
49
  """A stateless-per-session driver around the LLM + tools.
50
 
@@ -145,24 +177,25 @@ class AgentLoop:
145
  system_prompt: str = "",
146
  tools: Optional[Iterable[AgentTool]] = None,
147
  thinking_level: str = "low",
 
148
  ) -> AsyncGenerator[AgentEvent, None]:
149
  # AgentStartEvent is yielded before any network call (SC-004).
150
  yield AgentStartEvent()
151
 
 
152
  active_tools = list(tools) if tools is not None else self.tools
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()
@@ -173,12 +206,14 @@ class AgentLoop:
173
 
174
  yield TurnStartEvent()
175
 
 
 
 
176
  assistant_text = ""
177
  tool_calls: list = []
 
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)
@@ -190,7 +225,6 @@ class AgentLoop:
190
  if getattr(msg, "tool_calls", None):
191
  tool_calls.extend(msg.tool_calls)
192
 
193
- # Record the assistant turn in history.
194
  assistant_message: dict = {"role": "assistant", "content": assistant_text}
195
 
196
  if tool_calls:
@@ -207,33 +241,51 @@ class AgentLoop:
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:
212
  name = call["function"]["name"]
213
  args = call["function"]["arguments"]
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,
221
- "content": json.dumps(result, ensure_ascii=False),
222
- }
223
- )
224
- # Loop again so the model can use the tool results.
 
 
 
 
 
 
 
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)
@@ -256,4 +308,4 @@ def create_loop(
256
  return AgentLoop(tools=tools, model_id=model_id, host=host)
257
 
258
 
259
- __all__ = ["AgentLoop", "AgentTool", "DEFAULT_MODEL_ID", "create_loop"]
 
23
  from __future__ import annotations
24
 
25
  import asyncio
26
+ import inspect
27
  import json
28
  import os
29
+ from dataclasses import dataclass
30
+ from typing import AsyncGenerator, Callable, Iterable, Optional
31
 
32
  from agent.events import (
33
  AgentEndEvent,
 
47
  MAX_TURNS = 12
48
 
49
 
50
+ @dataclass
51
+ class LoopHooks:
52
+ """Agent-loop control hooks — a Python port of pi's AgentLoopConfig hooks.
53
+
54
+ Each is optional and may be sync or async. They give the harness real agency
55
+ over the loop without the model deciding control flow:
56
+
57
+ * ``transform_context(history)`` -> history — shape context before the call
58
+ * ``before_tool_call(name, args)`` -> {"block": bool, "reason": str} | None
59
+ * ``after_tool_call(name, args, result)`` -> replacement result dict | None
60
+ * ``should_stop_after_turn(assistant_message, history)`` -> bool — graceful stop
61
+ * ``prepare_next_turn(assistant_message, history)`` -> {"thinking_level": ...} | None
62
+ """
63
+
64
+ transform_context: Optional[Callable] = None
65
+ before_tool_call: Optional[Callable] = None
66
+ after_tool_call: Optional[Callable] = None
67
+ should_stop_after_turn: Optional[Callable] = None
68
+ prepare_next_turn: Optional[Callable] = None
69
+
70
+
71
+ async def _maybe_await(fn, *args):
72
+ if fn is None:
73
+ return None
74
+ result = fn(*args)
75
+ if inspect.isawaitable(result):
76
+ return await result
77
+ return result
78
+
79
+
80
  class AgentLoop:
81
  """A stateless-per-session driver around the LLM + tools.
82
 
 
177
  system_prompt: str = "",
178
  tools: Optional[Iterable[AgentTool]] = None,
179
  thinking_level: str = "low",
180
+ hooks: Optional[LoopHooks] = None,
181
  ) -> AsyncGenerator[AgentEvent, None]:
182
  # AgentStartEvent is yielded before any network call (SC-004).
183
  yield AgentStartEvent()
184
 
185
+ hooks = hooks or LoopHooks()
186
  active_tools = list(tools) if tools is not None else self.tools
187
  tool_map = self._tool_map(active_tools)
188
  tool_schemas = [t.to_ollama_schema() for t in active_tools] or None
189
+ think_level = thinking_level
190
 
191
+ # Conversation history excludes the system prompt (supplied fresh each call).
 
 
192
  history: list[dict] = list(getattr(session, "messages", None) or [])
193
  if prompt:
194
  history.append({"role": "user", "content": prompt})
195
 
196
+ def api_messages(ctx: list[dict]) -> list[dict]:
197
  sys = [{"role": "system", "content": system_prompt}] if system_prompt else []
198
+ return sys + ctx
199
 
200
  try:
201
  client = self._client_lazy()
 
206
 
207
  yield TurnStartEvent()
208
 
209
+ # transform_context hook (pi: transformContext) — shape context.
210
+ ctx = await _maybe_await(hooks.transform_context, history) or history
211
+
212
  assistant_text = ""
213
  tool_calls: list = []
214
+ think_arg = think_level if think_level in ("low", "medium", "high") else None
215
 
216
+ async for chunk in self._iter_chat(client, api_messages(ctx), tool_schemas, think_arg):
 
 
217
  if self.abort_event.is_set():
218
  break
219
  msg = getattr(chunk, "message", None)
 
225
  if getattr(msg, "tool_calls", None):
226
  tool_calls.extend(msg.tool_calls)
227
 
 
228
  assistant_message: dict = {"role": "assistant", "content": assistant_text}
229
 
230
  if tool_calls:
 
241
  assistant_message["tool_calls"] = serialised_calls
242
  history.append(assistant_message)
243
 
244
+ terminate_flags = []
245
  for call in serialised_calls:
246
  name = call["function"]["name"]
247
  args = call["function"]["arguments"]
248
  yield ToolStartEvent(name=name, args=args)
249
+
250
+ # before_tool_call hook (pi: beforeToolCall) — guard/block.
251
+ guard = await _maybe_await(hooks.before_tool_call, name, args)
252
+ if guard and guard.get("block"):
253
+ result = {"error": "blocked", "reason": guard.get("reason", "blocked")}
254
+ else:
255
+ result = await self._execute_tool(name, args, tool_map)
256
+ # after_tool_call hook (pi: afterToolCall) — transform result.
257
+ replaced = await _maybe_await(hooks.after_tool_call, name, args, result)
258
+ if replaced is not None:
259
+ result = replaced
260
+
261
+ terminate_flags.append(bool(isinstance(result, dict) and result.get("terminate")))
262
  yield ToolEndEvent(name=name, result=result)
263
+ history.append({
264
+ "role": "tool", "name": name,
265
+ "content": json.dumps(result, ensure_ascii=False),
266
+ })
267
+
268
+ yield TurnEndEvent(message=assistant_message)
269
+
270
+ # tool `terminate`: stop when every tool in the batch asked to.
271
+ if terminate_flags and all(terminate_flags):
272
+ break
273
+ if await _maybe_await(hooks.should_stop_after_turn, assistant_message, history):
274
+ break
275
+ upd = await _maybe_await(hooks.prepare_next_turn, assistant_message, history)
276
+ if upd and upd.get("thinking_level"):
277
+ think_level = upd["thinking_level"]
278
  continue
279
 
280
+ # No tool calls: final answer for this turn.
281
  history.append(assistant_message)
282
  yield TurnEndEvent(message=assistant_message)
283
 
284
+ if await _maybe_await(hooks.should_stop_after_turn, assistant_message, history):
285
+ break
286
  if not self.steering_queue.empty():
287
+ history.append({"role": "user", "content": await self.steering_queue.get()})
 
288
  continue
 
289
  break
290
 
291
  yield AgentEndEvent(messages=history)
 
308
  return AgentLoop(tools=tools, model_id=model_id, host=host)
309
 
310
 
311
+ __all__ = ["AgentLoop", "AgentTool", "LoopHooks", "DEFAULT_MODEL_ID", "create_loop"]
agent/tools/doc_generator.py CHANGED
@@ -1,32 +1,37 @@
1
- """agent/tools/doc_generator.py — Phase 5 document package generator (T049/T050).
2
-
3
- Generates four PDFs from the completed session using WeasyPrint + HTML templates
4
- (agent/tools/templates/). Every pre-filled value comes only from
5
- ``session.interview`` / ``session.assessment`` / ``session.selected_country``
6
- nothing is invented (CLAUDE.md Critical Rule 4). Pre-filled fields are wrapped in
7
- an amber-highlight ``.fill`` span; missing fields render as a blank line (never a
8
- literal "PLACEHOLDER"/"[NAME]"). Every filled field is logged with its source key.
9
-
10
- Output PDFs go to an ephemeral temp directory (ARCHITECTURE.md §Security).
11
  """
12
 
13
  from __future__ import annotations
14
 
15
  import html
16
  import logging
 
17
  import tempfile
18
  import zipfile
19
  from dataclasses import dataclass
20
  from pathlib import Path
21
 
 
 
22
  from weasyprint import HTML
23
 
 
24
  from agent.tools.country_lookup import lookup_country
25
 
26
  logger = logging.getLogger("refuge.doc_generator")
27
 
28
  TEMPLATES = Path(__file__).resolve().parent / "templates"
29
  _HEAD = (TEMPLATES / "_head.html").read_text(encoding="utf-8")
 
 
 
30
 
31
 
32
  @dataclass
@@ -34,177 +39,210 @@ class GeneratedDoc:
34
  key: str
35
  title: str
36
  meta: str
37
- path: Path
38
-
39
-
40
- # -- field helpers ----------------------------------------------------------
41
-
42
- def _is_empty(value) -> bool:
43
- return value is None or value == "" or value == [] or value == {}
44
-
45
-
46
- def fill(value, source_key: str, *, fallback_blank: bool = True) -> str:
47
- """Amber-highlight a pre-filled value, logging its source. Blank if missing."""
48
- if _is_empty(value):
49
- return '<span class="blank">&nbsp;</span>' if fallback_blank else ""
50
- text = ", ".join(str(v) for v in value) if isinstance(value, list) else str(value)
51
- logger.info("Filling field from session key: %s", source_key)
52
- return f'<span class="fill">{html.escape(text)}</span>'
53
-
54
-
55
- def _render(template_name: str, tokens: dict[str, str]) -> str:
56
- tpl = (TEMPLATES / template_name).read_text(encoding="utf-8")
57
- tpl = tpl.replace("%%HEAD%%", _HEAD)
58
- for key, value in tokens.items():
59
- tpl = tpl.replace(f"%%{key}%%", value)
60
- return tpl
61
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
- # -- per-document HTML builders --------------------------------------------
64
 
65
- def _personal_statement_html(session) -> str:
66
- iv = session.interview
67
- family_clause = ""
68
- if not _is_empty(iv.family_situation):
69
- family_clause = f", traveling with {fill(iv.family_situation, 'interview.family_situation')}"
70
- history_para = ""
71
- if not _is_empty(iv.free_text_history):
72
- history_para = f"<p>{fill(iv.free_text_history, 'interview.free_text_history')}</p>"
73
- return _render("personal_statement.html", {
74
- "full_name": fill(None, "interview.full_name"), # not collected — blank to complete
75
- "origin_country": fill(iv.origin_country, "interview.origin_country"),
76
- "current_country": fill(iv.current_country, "interview.current_country"),
77
- "family_clause": family_clause,
78
- "persecution": fill(iv.persecution_types, "interview.persecution_types"),
79
- "history_para": history_para,
80
- })
81
 
82
 
83
- def _action_plan_html(session, rec: dict | None) -> str:
84
  country = session.selected_country or "your chosen country"
85
  months = (rec or {}).get("processingTimeMonths")
86
- processing = f"about {months} months" if months else "a variable amount of time"
87
  office = (rec or {}).get("unhcrOffice")
88
- unhcr = f"Yes — {office}" if office else ("Yes" if (rec or {}).get("unhcrPresence") else "Check locally")
89
- steps_src = (rec or {}).get("steps") or [
90
  "Register with UNHCR or the asylum authority",
91
  "File your asylum claim with your personal statement",
92
  "Attend your refugee status interview (RSD)",
93
  "Receive the decision (appeal if refused)",
94
  "Access integration support",
95
  ]
96
- steps_html = "".join(
97
- f'<p class="step"><b>Step {i}.</b> {html.escape(str(s))}</p>'
98
- for i, s in enumerate(steps_src, start=1)
99
- )
100
- return _render("action_plan.html", {
101
- "selected_country": html.escape(str(country)),
102
- "processing": processing,
103
- "unhcr_office": html.escape(str(unhcr)),
104
- "steps": steps_html,
105
- })
106
 
107
 
108
- def _emergency_contacts_html(session, rec: dict | None) -> str:
109
- country = session.selected_country or "your chosen country"
110
- office = (rec or {}).get("unhcrOffice") or "Nearest UNHCR office"
111
- orgs = (rec or {}).get("legalAidOrgs") or []
112
- if orgs:
113
- orgs_html = "".join(
114
- f'<div class="org"><b>{html.escape(str(o.get("name", "")))}</b>'
115
- f'<span>{html.escape(str(o.get("url", "")))}</span></div>'
116
- for o in orgs if isinstance(o, dict)
117
- )
118
- else:
119
- orgs_html = "<p>Ask the UNHCR office for a list of registered legal-aid partners.</p>"
120
- return _render("emergency_contacts.html", {
121
- "selected_country": html.escape(str(country)),
122
- "unhcr_office": html.escape(str(office)),
123
- "orgs": orgs_html,
124
- })
125
-
126
-
127
- def _rights_card_html(session) -> str:
128
- grounds = session.assessment.convention_grounds or []
129
- grounds_txt = ", ".join(grounds) if grounds else "to be confirmed in your interview"
130
- return _render("rights_summary_card.html", {
131
- "origin_country": fill(session.interview.origin_country, "interview.origin_country"),
132
- "selected_country": html.escape(str(session.selected_country or "your chosen country")),
133
- "grounds": html.escape(grounds_txt),
134
- })
135
-
136
-
137
- # -- public API -------------------------------------------------------------
138
-
139
- _DOCS = [
140
- ("personal_statement", "Personal statement (pre-filled)", "PDF · narrative for your claim"),
141
- ("action_plan", "Action plan", "PDF · step-by-step roadmap with contacts"),
142
- ("emergency_contacts", "Emergency contacts", "PDF · UNHCR offices & legal aid"),
143
- ("rights_summary_card", "Your rights — summary card", "PDF · key protections"),
144
- ]
145
-
146
-
147
- def build_html(session) -> dict[str, str]:
148
- """Return {key: full_html} for all four documents (handy for tests/preview)."""
149
- rec = None
150
- if session.selected_country:
151
- looked = lookup_country(session.selected_country)
152
- rec = None if looked.get("error") else looked
153
- return {
154
- "personal_statement": _personal_statement_html(session),
155
- "action_plan": _action_plan_html(session, rec),
156
- "emergency_contacts": _emergency_contacts_html(session, rec),
157
- "rights_summary_card": _rights_card_html(session),
158
- }
159
-
160
-
161
- def preview_statement_html(session) -> str:
162
- """Inline (head-less) personal-statement snippet for the on-screen preview."""
163
- iv = session.interview
164
- family_clause = ""
165
- if not _is_empty(iv.family_situation):
166
- family_clause = f", traveling with {fill(iv.family_situation, 'interview.family_situation')}"
167
- history = ""
168
- if not _is_empty(iv.free_text_history):
169
- history = f"<p>{fill(iv.free_text_history, 'interview.free_text_history')}</p>"
170
- return (
171
- '<article class="doc"><h4>Personal Statement</h4>'
172
- '<p class="doc__sub">In support of an application for refugee status · Prepared with Refuge</p>'
173
- f"<p>My name is {fill(None, 'interview.full_name')}. I am a national of "
174
- f"{fill(iv.origin_country, 'interview.origin_country')}. I am currently in "
175
- f"{fill(iv.current_country, 'interview.current_country')}{family_clause}.</p>"
176
- '<div class="doc__divider"></div>'
177
- f"<p>I left my home because of {fill(iv.persecution_types, 'interview.persecution_types')}.</p>"
178
- f"{history}"
179
- "</article>"
180
- )
181
 
 
182
 
183
- def generate(session, out_dir: Path | None = None) -> list[GeneratedDoc]:
184
- """Generate the four PDFs. Returns GeneratedDoc records with file paths."""
 
185
  out_dir = Path(out_dir) if out_dir else Path(tempfile.mkdtemp(prefix="refuge_docs_"))
186
  out_dir.mkdir(parents=True, exist_ok=True)
187
- htmls = build_html(session)
188
-
189
  docs: list[GeneratedDoc] = []
190
- for key, title, meta in _DOCS:
191
- path = out_dir / f"{key}.pdf"
192
- HTML(string=htmls[key]).write_pdf(str(path))
193
- docs.append(GeneratedDoc(key=key, title=title, meta=meta, path=path))
194
- logger.info("Generated %d documents in %s", len(docs), out_dir)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  return docs
196
 
197
 
 
 
 
 
 
 
 
 
198
  def zip_package(docs: list[GeneratedDoc], out_dir: Path | None = None) -> Path:
199
- """Bundle the generated PDFs into a single zip for "Download all"."""
200
- base = Path(out_dir) if out_dir else docs[0].path.parent
201
  zip_path = base / "refuge_documents.zip"
202
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
203
- for d in docs:
204
- zf.write(d.path, arcname=d.path.name)
205
  return zip_path
206
 
207
 
208
- __all__ = [
209
- "generate", "build_html", "preview_statement_html", "zip_package", "GeneratedDoc", "fill",
210
- ]
 
 
 
 
 
 
 
1
+ """agent/tools/doc_generator.py — Phase 5 document package (PDF + editable Word).
2
+
3
+ Generates the document package from real session data. The **personal statement
4
+ is drafted by the LLM** (see agent/drafting.py) an in-depth, first-person
5
+ narrative with ``[placeholders]`` the person completes and every document is
6
+ exported as both an **editable Word (.docx)** file and a print-ready **PDF**.
7
+ Country facts come from the curated data via country_lookup; nothing is
8
+ fabricated (CLAUDE.md Rule 4).
 
 
9
  """
10
 
11
  from __future__ import annotations
12
 
13
  import html
14
  import logging
15
+ import re
16
  import tempfile
17
  import zipfile
18
  from dataclasses import dataclass
19
  from pathlib import Path
20
 
21
+ from docx import Document
22
+ from docx.shared import Pt, RGBColor
23
  from weasyprint import HTML
24
 
25
+ from agent.drafting import fallback_statement
26
  from agent.tools.country_lookup import lookup_country
27
 
28
  logger = logging.getLogger("refuge.doc_generator")
29
 
30
  TEMPLATES = Path(__file__).resolve().parent / "templates"
31
  _HEAD = (TEMPLATES / "_head.html").read_text(encoding="utf-8")
32
+ _PLACEHOLDER = re.compile(r"(\[[^\]]+\])")
33
+ _AMBER = RGBColor(0xC2, 0x63, 0x29)
34
+ _TEAL = RGBColor(0x0A, 0x50, 0x42)
35
 
36
 
37
  @dataclass
 
39
  key: str
40
  title: str
41
  meta: str
42
+ pdf_path: Path
43
+ docx_path: Path
44
+
45
+
46
+ # -- shared helpers ---------------------------------------------------------
47
+
48
+ def _paras(text: str) -> list[str]:
49
+ return [p.strip() for p in text.split("\n") if p.strip()]
50
+
51
+
52
+ def _html_with_placeholders(text: str) -> str:
53
+ """Render free text into HTML, highlighting [placeholders] in amber."""
54
+ out = []
55
+ for para in _paras(text):
56
+ pieces = []
57
+ for part in _PLACEHOLDER.split(para):
58
+ if not part:
59
+ continue
60
+ if _PLACEHOLDER.fullmatch(part):
61
+ pieces.append(f'<span class="fill">{html.escape(part)}</span>')
62
+ else:
63
+ pieces.append(html.escape(part))
64
+ out.append("<p>" + "".join(pieces) + "</p>")
65
+ return "\n".join(out)
66
+
67
+
68
+ def _docx_add_rich(doc: Document, text: str):
69
+ """Add paragraphs, bolding [placeholders] so they're easy to find and edit."""
70
+ for para in _paras(text):
71
+ p = doc.add_paragraph()
72
+ for part in _PLACEHOLDER.split(para):
73
+ if not part:
74
+ continue
75
+ run = p.add_run(part)
76
+ if _PLACEHOLDER.fullmatch(part):
77
+ run.bold = True
78
+ run.font.color.rgb = _AMBER
79
+
80
+
81
+ def _new_docx(title: str, subtitle: str) -> Document:
82
+ doc = Document()
83
+ h = doc.add_heading(title, level=0)
84
+ try:
85
+ h.runs[0].font.color.rgb = _TEAL
86
+ except Exception:
87
+ pass
88
+ sub = doc.add_paragraph(subtitle)
89
+ sub.runs[0].italic = True
90
+ sub.runs[0].font.size = Pt(9)
91
+ sub.runs[0].font.color.rgb = RGBColor(0x6B, 0x72, 0x80)
92
+ return doc
93
+
94
+
95
+ def _pdf_from_html(body_html: str, path: Path):
96
+ HTML(string=f"<!DOCTYPE html><html><head>{_HEAD}</head><body>{body_html}</body></html>").write_pdf(str(path))
97
+
98
+
99
+ # -- per-document content ---------------------------------------------------
100
+
101
+ def _statement_body_html(text: str) -> str:
102
+ return (
103
+ "<h1>Personal Statement</h1>"
104
+ '<p class="sub">In support of an application for refugee status · Prepared with Refuge</p>'
105
+ f"{_html_with_placeholders(text)}"
106
+ '<p class="note">Fields in amber are placeholders for you to complete. You can edit any part '
107
+ "of this statement in the Word (.docx) version.</p>"
108
+ )
109
 
 
110
 
111
+ def _rec_for(session):
112
+ if session.selected_country:
113
+ rec = lookup_country(session.selected_country)
114
+ return None if rec.get("error") else rec
115
+ return None
 
 
 
 
 
 
 
 
 
 
 
116
 
117
 
118
+ def _action_plan_blocks(session, rec):
119
  country = session.selected_country or "your chosen country"
120
  months = (rec or {}).get("processingTimeMonths")
 
121
  office = (rec or {}).get("unhcrOffice")
122
+ steps = (rec or {}).get("steps") or [
 
123
  "Register with UNHCR or the asylum authority",
124
  "File your asylum claim with your personal statement",
125
  "Attend your refugee status interview (RSD)",
126
  "Receive the decision (appeal if refused)",
127
  "Access integration support",
128
  ]
129
+ intro = (f"This plan is for seeking protection in {country}. "
130
+ f"Typical processing: {('about ' + str(months) + ' months') if months else 'varies'}. "
131
+ f"UNHCR: {('Yes ' + office) if office else 'check locally'}.")
132
+ return country, intro, steps
 
 
 
 
 
 
133
 
134
 
135
+ def _orgs(rec):
136
+ return [(o.get("name", ""), o.get("url", "")) for o in (rec or {}).get("legalAidOrgs", []) if isinstance(o, dict)]
137
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
+ # -- generation -------------------------------------------------------------
140
 
141
+ def generate(session, statement: str | None = None, out_dir: Path | None = None) -> list[GeneratedDoc]:
142
+ """Build the 4 documents as PDF + DOCX. ``statement`` is the LLM-drafted
143
+ personal statement; if omitted, a deterministic fallback is used."""
144
  out_dir = Path(out_dir) if out_dir else Path(tempfile.mkdtemp(prefix="refuge_docs_"))
145
  out_dir.mkdir(parents=True, exist_ok=True)
146
+ stmt = statement or fallback_statement(session)
147
+ rec = _rec_for(session)
148
  docs: list[GeneratedDoc] = []
149
+
150
+ # 1. Personal statement (LLM-drafted) — PDF + DOCX
151
+ ps_pdf, ps_docx = out_dir / "personal_statement.pdf", out_dir / "personal_statement.docx"
152
+ _pdf_from_html(_statement_body_html(stmt), ps_pdf)
153
+ d = _new_docx("Personal Statement", "In support of an application for refugee status · Prepared with Refuge")
154
+ _docx_add_rich(d, stmt)
155
+ d.save(str(ps_docx))
156
+ docs.append(GeneratedDoc("personal_statement", "Personal statement (editable)",
157
+ "Word + PDF · drafted for you, with placeholders to complete", ps_pdf, ps_docx))
158
+
159
+ # 2. Action plan
160
+ country, intro, steps = _action_plan_blocks(session, rec)
161
+ ap_html = (f"<h1>Action Plan — {html.escape(country)}</h1>"
162
+ '<p class="sub">Step-by-step roadmap · Prepared with Refuge</p>'
163
+ f"<p>{html.escape(intro)}</p>" +
164
+ "".join(f'<p class="step"><b>Step {i}.</b> {html.escape(str(s))}</p>'
165
+ for i, s in enumerate(steps, 1)))
166
+ ap_pdf, ap_docx = out_dir / "action_plan.pdf", out_dir / "action_plan.docx"
167
+ _pdf_from_html(ap_html, ap_pdf)
168
+ d = _new_docx(f"Action Plan — {country}", "Step-by-step roadmap · Prepared with Refuge")
169
+ d.add_paragraph(intro)
170
+ for i, s in enumerate(steps, 1):
171
+ d.add_paragraph(f"Step {i}. {s}", style="List Number" if "List Number" in [s.name for s in d.styles] else None)
172
+ d.save(str(ap_docx))
173
+ docs.append(GeneratedDoc("action_plan", "Action plan", "Word + PDF · roadmap with contacts", ap_pdf, ap_docx))
174
+
175
+ # 3. Emergency contacts
176
+ office = (rec or {}).get("unhcrOffice") or "Nearest UNHCR office"
177
+ orgs = _orgs(rec)
178
+ ec_html = ("<h1>Emergency Contacts</h1>"
179
+ '<p class="sub">UNHCR offices &amp; legal aid · Prepared with Refuge</p>'
180
+ f"<h2>{html.escape(country)}</h2><p>UNHCR office: {html.escape(office)}</p>"
181
+ "<h2>Legal aid &amp; support</h2>" +
182
+ ("".join(f'<div class="org"><b>{html.escape(n)}</b><span>{html.escape(u)}</span></div>' for n, u in orgs)
183
+ or "<p>Ask the UNHCR office for registered legal-aid partners.</p>"))
184
+ ec_pdf, ec_docx = out_dir / "emergency_contacts.pdf", out_dir / "emergency_contacts.docx"
185
+ _pdf_from_html(ec_html, ec_pdf)
186
+ d = _new_docx("Emergency Contacts", "UNHCR offices & legal aid · Prepared with Refuge")
187
+ d.add_paragraph(f"{country} — UNHCR office: {office}")
188
+ for n, u in orgs:
189
+ d.add_paragraph(f"{n} — {u}")
190
+ d.save(str(ec_docx))
191
+ docs.append(GeneratedDoc("emergency_contacts", "Emergency contacts", "Word + PDF · UNHCR & legal aid", ec_pdf, ec_docx))
192
+
193
+ # 4. Rights summary card
194
+ grounds = ", ".join(session.assessment.convention_grounds) if session.assessment.convention_grounds else "to be confirmed"
195
+ rc_html = ("<h1>Your Rights — Summary Card</h1>"
196
+ '<p class="sub">Key protections · Prepared with Refuge</p>'
197
+ "<h2>Non-refoulement</h2><p>You cannot be forced back to a country where your life or freedom "
198
+ "would be at serious risk (1951 Refugee Convention).</p>"
199
+ "<h2>While your claim is decided</h2><ul>"
200
+ "<li>The right to seek asylum and a fair hearing.</li>"
201
+ "<li>The right to an interpreter and free legal assistance.</li>"
202
+ "<li>The right not to be detained arbitrarily.</li>"
203
+ "<li>The right to shelter, food, and emergency healthcare.</li></ul>"
204
+ f"<p>Origin: {html.escape(session.interview.origin_country or '[origin]')} · "
205
+ f"Seeking protection in: {html.escape(country)} · Grounds: {html.escape(grounds)}</p>")
206
+ rc_pdf, rc_docx = out_dir / "rights_summary_card.pdf", out_dir / "rights_summary_card.docx"
207
+ _pdf_from_html(rc_html, rc_pdf)
208
+ d = _new_docx("Your Rights — Summary Card", "Key protections · Prepared with Refuge")
209
+ d.add_paragraph("Non-refoulement: you cannot be forced back to a country where your life or freedom "
210
+ "would be at serious risk (1951 Refugee Convention).")
211
+ for r in ["The right to seek asylum and a fair hearing.",
212
+ "The right to an interpreter and free legal assistance.",
213
+ "The right not to be detained arbitrarily.",
214
+ "The right to shelter, food, and emergency healthcare."]:
215
+ d.add_paragraph(r, style=None)
216
+ d.save(str(rc_docx))
217
+ docs.append(GeneratedDoc("rights_summary_card", "Your rights — summary card", "Word + PDF · key protections", rc_pdf, rc_docx))
218
+
219
+ logger.info("Generated %d documents (PDF + DOCX) in %s", len(docs), out_dir)
220
  return docs
221
 
222
 
223
+ def all_files(docs: list[GeneratedDoc]) -> list[str]:
224
+ files = []
225
+ for d in docs:
226
+ files.append(str(d.docx_path))
227
+ files.append(str(d.pdf_path))
228
+ return files
229
+
230
+
231
  def zip_package(docs: list[GeneratedDoc], out_dir: Path | None = None) -> Path:
232
+ base = Path(out_dir) if out_dir else docs[0].pdf_path.parent
 
233
  zip_path = base / "refuge_documents.zip"
234
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
235
+ for f in all_files(docs):
236
+ zf.write(f, arcname=Path(f).name)
237
  return zip_path
238
 
239
 
240
+ def preview_statement_html(session, statement: str | None = None) -> str:
241
+ """Head-less personal-statement snippet for the on-screen preview."""
242
+ stmt = statement or fallback_statement(session)
243
+ return ('<article class="doc"><h4>Personal Statement</h4>'
244
+ '<p class="doc__sub">In support of an application for refugee status · Prepared with Refuge</p>'
245
+ f"{_html_with_placeholders(stmt)}</article>")
246
+
247
+
248
+ __all__ = ["generate", "preview_statement_html", "zip_package", "all_files", "GeneratedDoc"]
app/app.py CHANGED
@@ -202,13 +202,13 @@ def build_app() -> gr.Blocks:
202
  # Recommendations -> Documents: generate the package for the chosen country.
203
  docs_outputs = [reco_ui.column, docs_ui.column, *docs_ui.render_outputs]
204
 
205
- def show_documents(session):
206
  if session is None or not session.selected_country:
207
  return [gr.update()] * len(docs_outputs)
208
  if session.state < State.DOCUMENTS:
209
  from app.phases.interview import advance_to
210
  advance_to(session, State.DOCUMENTS)
211
- updates = docs_ui.populate(session)
212
  return [gr.update(visible=False), gr.update(visible=True), *updates]
213
 
214
  reco_ui.proceed.click(show_documents, inputs=[session_st], outputs=docs_outputs)
 
202
  # Recommendations -> Documents: generate the package for the chosen country.
203
  docs_outputs = [reco_ui.column, docs_ui.column, *docs_ui.render_outputs]
204
 
205
+ async def show_documents(session):
206
  if session is None or not session.selected_country:
207
  return [gr.update()] * len(docs_outputs)
208
  if session.state < State.DOCUMENTS:
209
  from app.phases.interview import advance_to
210
  advance_to(session, State.DOCUMENTS)
211
+ updates = await docs_ui.populate(session) # LLM drafts the statement
212
  return [gr.update(visible=False), gr.update(visible=True), *updates]
213
 
214
  reco_ui.proceed.click(show_documents, inputs=[session_st], outputs=docs_outputs)
app/phases/assessment.py CHANGED
@@ -16,6 +16,7 @@ from dataclasses import dataclass
16
  import gradio as gr
17
 
18
  from agent.events import ErrorEvent, TextDeltaEvent, ToolEndEvent, ToolStartEvent
 
19
  from agent.tools.asylum_stats import asylum_stats_tool
20
  from agent.tools.country_lookup import country_lookup_tool, lookup_country
21
  from agent.tools.web_search import web_search_tool
@@ -31,12 +32,12 @@ ASSESSMENT_TOOLS = [web_search_tool, country_lookup_tool, asylum_stats_tool]
31
  FACT_FIELDS = [
32
  ("Country of origin", "origin_country", "text"),
33
  ("Current location", "current_country", "text"),
34
- ("Reason for leaving", "persecution_types", "chips"),
35
  ("Immediate danger", "immediate_danger", "bool"),
36
- ("Family situation", "family_situation", "text"),
 
37
  ("Languages", "languages_spoken", "list"),
38
  ("Destination preference", "destination_preferences", "list"),
39
- ("Time displaced", "displacement_duration", "text"),
40
  ]
41
 
42
  ASSESSMENT_CSS = """
@@ -169,11 +170,29 @@ async def stream_assessment(session: SessionState, loop):
169
  pct = 5
170
  status = ""
171
  looked_up: list[str] = [] # countries the agent actually researched
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  yield facts_html, render_reason(acc), render_progress(pct, "")
173
 
174
  async for ev in loop.run(
175
  prompt, session, system_prompt=system_prompt,
176
- tools=ASSESSMENT_TOOLS, thinking_level="medium",
177
  ):
178
  if isinstance(ev, TextDeltaEvent):
179
  acc += ev.delta
 
16
  import gradio as gr
17
 
18
  from agent.events import ErrorEvent, TextDeltaEvent, ToolEndEvent, ToolStartEvent
19
+ from agent.loop import LoopHooks
20
  from agent.tools.asylum_stats import asylum_stats_tool
21
  from agent.tools.country_lookup import country_lookup_tool, lookup_country
22
  from agent.tools.web_search import web_search_tool
 
32
  FACT_FIELDS = [
33
  ("Country of origin", "origin_country", "text"),
34
  ("Current location", "current_country", "text"),
35
+ ("What happened", "free_text_history", "text"),
36
  ("Immediate danger", "immediate_danger", "bool"),
37
+ ("Time displaced", "displacement_duration", "text"),
38
+ ("Documents", "documents_available", "list"),
39
  ("Languages", "languages_spoken", "list"),
40
  ("Destination preference", "destination_preferences", "list"),
 
41
  ]
42
 
43
  ASSESSMENT_CSS = """
 
170
  pct = 5
171
  status = ""
172
  looked_up: list[str] = [] # countries the agent actually researched
173
+
174
+ # Agentic control (ported from pi's AgentLoopConfig hooks):
175
+ # * stop once the structured @@ASSESSMENT block is produced (no rambling)
176
+ # * privacy guard: never let the person's free-text story into a web query
177
+ def _stop(assistant_message, history):
178
+ return "@@ASSESSMENT" in (assistant_message or {}).get("content", "")
179
+
180
+ story = (session.interview.free_text_history or "").strip().lower()
181
+
182
+ def _guard(name, args):
183
+ if name == "web_search" and story:
184
+ q = str((args or {}).get("query", "")).lower()
185
+ if story and (story[:40] in q):
186
+ return {"block": True, "reason": "query contained personal narrative (privacy)"}
187
+ return None
188
+
189
+ hooks = LoopHooks(should_stop_after_turn=_stop, before_tool_call=_guard)
190
+
191
  yield facts_html, render_reason(acc), render_progress(pct, "")
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
app/phases/documents.py CHANGED
@@ -14,7 +14,7 @@ from dataclasses import dataclass
14
 
15
  import gradio as gr
16
 
17
- from agent.tools.doc_generator import generate, preview_statement_html, zip_package
18
  from app.state.session import SessionState
19
 
20
  # Evidence checklist: (label, sublabel, document_available key or None).
@@ -91,12 +91,12 @@ def build(visible: bool = False, session_st: gr.State | None = None) -> Document
91
  gr.HTML('<p class="pkg__lbl">Evidence to gather</p>')
92
  checklist = gr.CheckboxGroup(
93
  choices=[label for label, _s, _k in CHECKLIST],
94
- value=[], label="", elem_id="docs-checklist",
95
  )
96
- gr.HTML('<p class="pkg__lbl">Your files</p>')
97
- files = gr.File(label="", interactive=False, elem_id="docs-files")
98
  download_all = gr.DownloadButton(
99
- "⤓ Download all (4 files)", elem_id="docs-download", visible=False
100
  )
101
  start_over = gr.Button(
102
  "↺ Start over for a different country", elem_id="docs-startover"
@@ -104,13 +104,22 @@ def build(visible: bool = False, session_st: gr.State | None = None) -> Document
104
 
105
  render_outputs = [preview, checklist, files, download_all]
106
 
107
- def populate(session: SessionState) -> list:
108
- docs = generate(session)
 
 
 
 
 
 
 
 
 
109
  zip_path = zip_package(docs)
110
  return [
111
- gr.update(value=preview_statement_html(session)),
112
  gr.update(value=_checklist_values(session)),
113
- gr.update(value=[str(d.path) for d in docs]),
114
  gr.update(value=str(zip_path), visible=True),
115
  ]
116
 
 
14
 
15
  import gradio as gr
16
 
17
+ from agent.tools.doc_generator import all_files, generate, preview_statement_html, zip_package
18
  from app.state.session import SessionState
19
 
20
  # Evidence checklist: (label, sublabel, document_available key or None).
 
91
  gr.HTML('<p class="pkg__lbl">Evidence to gather</p>')
92
  checklist = gr.CheckboxGroup(
93
  choices=[label for label, _s, _k in CHECKLIST],
94
+ value=[], label="", show_label=False, elem_id="docs-checklist",
95
  )
96
+ gr.HTML('<p class="pkg__lbl">Your files · Word (editable) + PDF</p>')
97
+ files = gr.File(label="", show_label=False, interactive=False, elem_id="docs-files")
98
  download_all = gr.DownloadButton(
99
+ "⤓ Download all (Word + PDF)", elem_id="docs-download", visible=False
100
  )
101
  start_over = gr.Button(
102
  "↺ Start over for a different country", elem_id="docs-startover"
 
104
 
105
  render_outputs = [preview, checklist, files, download_all]
106
 
107
+ async def populate(session: SessionState) -> list:
108
+ # The agent drafts the personal statement (LLM), then we render PDF + Word.
109
+ from agent.drafting import draft_personal_statement
110
+ try:
111
+ statement = await draft_personal_statement(session)
112
+ except Exception:
113
+ statement = None
114
+ if not statement:
115
+ from agent.drafting import fallback_statement
116
+ statement = fallback_statement(session)
117
+ docs = generate(session, statement=statement)
118
  zip_path = zip_package(docs)
119
  return [
120
+ gr.update(value=preview_statement_html(session, statement)),
121
  gr.update(value=_checklist_values(session)),
122
+ gr.update(value=all_files(docs)),
123
  gr.update(value=str(zip_path), visible=True),
124
  ]
125
 
app/phases/recommendations.py CHANGED
@@ -100,7 +100,14 @@ def _unhcr_label(rec: dict) -> str:
100
 
101
  def _acceptance_label(rec: dict) -> str:
102
  rate = rec.get("acceptanceRate")
103
- return str(rate) if rate else "Not published"
 
 
 
 
 
 
 
104
 
105
 
106
  def _language_label(rec: dict) -> str:
@@ -117,7 +124,7 @@ def card_body_html(rec: dict) -> str:
117
  facts = [
118
  ("Processing time", _processing_label(rec)),
119
  ("UNHCR office", _unhcr_label(rec)),
120
- ("Acceptance (your profile)", _acceptance_label(rec)),
121
  ("Primary language", _language_label(rec)),
122
  ]
123
  facts_html = "".join(
 
100
 
101
  def _acceptance_label(rec: dict) -> str:
102
  rate = rec.get("acceptanceRate")
103
+ if rate in (None, "", "PENDING"):
104
+ return "Not published"
105
+ try:
106
+ pct = float(rate)
107
+ pct = pct * 100 if pct <= 1 else pct # stored as 0-1 fraction
108
+ return f"{round(pct)}%"
109
+ except (TypeError, ValueError):
110
+ return str(rate)
111
 
112
 
113
  def _language_label(rec: dict) -> str:
 
124
  facts = [
125
  ("Processing time", _processing_label(rec)),
126
  ("UNHCR office", _unhcr_label(rec)),
127
+ ("Recognition rate (recent)", _acceptance_label(rec)),
128
  ("Primary language", _language_label(rec)),
129
  ]
130
  facts_html = "".join(
requirements.txt CHANGED
@@ -5,8 +5,9 @@
5
  # UI framework
6
  gradio==6.15.2 # latest 6.16.0
7
 
8
- # Document generation (Phase 5)
9
  weasyprint==68.1 # latest 69.0
 
10
 
11
  # HTTP client (tool API calls; stubbed at this boundary in unit tests)
12
  httpx==0.28.0 # latest 0.28.1
 
5
  # UI framework
6
  gradio==6.15.2 # latest 6.16.0
7
 
8
+ # Document generation (Phase 5) — PDF + editable Word
9
  weasyprint==68.1 # latest 69.0
10
+ python-docx==1.1.2 # latest 1.2.0 — editable .docx export
11
 
12
  # HTTP client (tool API calls; stubbed at this boundary in unit tests)
13
  httpx==0.28.0 # latest 0.28.1
tests/integration/test_documents.py CHANGED
@@ -1,14 +1,14 @@
1
- """tests/integration/test_documents.py — document package generation (T053).
2
 
3
- Seeds a complete session (real Ethiopia/Sudan scenario) and generates the four
4
- PDFs with WeasyPrint. Asserts they are real, valid, non-empty, contain the
5
- person's real data, and contain no placeholder tokens.
6
  """
7
 
8
  from pathlib import Path
9
 
10
  from agent.tools.country_lookup import lookup_country
11
- from agent.tools.doc_generator import generate, zip_package
12
  from app.state.session import SessionState, State
13
 
14
 
@@ -17,15 +17,13 @@ def _complete_session(origin="Ethiopia") -> SessionState:
17
  for target in (State.INTAKE, State.SITUATION, State.HISTORY, State.GOALS,
18
  State.REVIEW, State.ASSESSMENT, State.RECOMMENDATIONS, State.DOCUMENTS):
19
  s.transition_to(target)
20
- s.language = "Amharic"
21
  s.interview.origin_country = origin
22
  s.interview.current_country = "Sudan"
23
- s.interview.persecution_types = ["Political", "Ethnic"]
24
  s.interview.immediate_danger = True
25
- s.interview.family_situation = "two children"
26
- s.interview.documents_available = ["passport"]
27
  s.interview.languages_spoken = ["Amharic", "Arabic"]
28
- s.interview.free_text_history = "Armed men came to our village in November."
29
  s.assessment.convention_grounds = ["Political opinion"]
30
  s.assessment.recommended_countries = [lookup_country("Kenya")]
31
  s.selected_country = "Kenya"
@@ -34,37 +32,36 @@ def _complete_session(origin="Ethiopia") -> SessionState:
34
 
35
  def _pdf_text(path: Path) -> str:
36
  from pypdf import PdfReader
37
-
38
- reader = PdfReader(str(path))
39
- return "\n".join(page.extract_text() or "" for page in reader.pages)
40
 
41
 
42
- def test_generates_four_nonempty_valid_pdfs(tmp_path):
43
  docs = generate(_complete_session(), out_dir=tmp_path)
44
  assert len(docs) == 4
45
  for d in docs:
46
- assert d.path.exists()
47
- data = d.path.read_bytes()
48
- assert len(data) > 1000 # non-trivial
49
- assert data[:5] == b"%PDF-" # valid PDF header
 
50
 
51
 
52
  def test_personal_statement_contains_real_origin(tmp_path):
53
  docs = generate(_complete_session(), out_dir=tmp_path)
54
- statement = next(d for d in docs if d.key == "personal_statement")
55
- assert "Ethiopia" in _pdf_text(statement.path) # SC-039: real data
56
 
57
 
58
- def test_no_placeholder_tokens(tmp_path):
59
  docs = generate(_complete_session(), out_dir=tmp_path)
60
  for d in docs:
61
- text = _pdf_text(d.path)
62
  for forbidden in ("PLACEHOLDER", "[NAME]", "[COUNTRY]"):
63
- assert forbidden not in text, f"{forbidden} found in {d.key}"
64
 
65
 
66
- def test_download_all_zip(tmp_path):
67
  docs = generate(_complete_session(), out_dir=tmp_path)
 
68
  zip_path = zip_package(docs, out_dir=tmp_path)
69
- assert zip_path.exists()
70
- assert zip_path.stat().st_size > 1000
 
1
+ """tests/integration/test_documents.py — document package (PDF + editable Word).
2
 
3
+ Seeds a complete session and generates the package with the deterministic
4
+ fallback statement (no model needed). Asserts real, valid, non-empty PDF + DOCX
5
+ files containing the person's real data and no forbidden placeholder tokens.
6
  """
7
 
8
  from pathlib import Path
9
 
10
  from agent.tools.country_lookup import lookup_country
11
+ from agent.tools.doc_generator import all_files, generate, zip_package
12
  from app.state.session import SessionState, State
13
 
14
 
 
17
  for target in (State.INTAKE, State.SITUATION, State.HISTORY, State.GOALS,
18
  State.REVIEW, State.ASSESSMENT, State.RECOMMENDATIONS, State.DOCUMENTS):
19
  s.transition_to(target)
20
+ s.language = "English"
21
  s.interview.origin_country = origin
22
  s.interview.current_country = "Sudan"
23
+ s.interview.free_text_history = "Armed men came to our village in November."
24
  s.interview.immediate_danger = True
25
+ s.interview.documents_available = ["Passport"]
 
26
  s.interview.languages_spoken = ["Amharic", "Arabic"]
 
27
  s.assessment.convention_grounds = ["Political opinion"]
28
  s.assessment.recommended_countries = [lookup_country("Kenya")]
29
  s.selected_country = "Kenya"
 
32
 
33
  def _pdf_text(path: Path) -> str:
34
  from pypdf import PdfReader
35
+ return "\n".join(page.extract_text() or "" for page in PdfReader(str(path)).pages)
 
 
36
 
37
 
38
+ def test_generates_four_docs_each_pdf_and_docx(tmp_path):
39
  docs = generate(_complete_session(), out_dir=tmp_path)
40
  assert len(docs) == 4
41
  for d in docs:
42
+ assert d.pdf_path.exists() and d.docx_path.exists()
43
+ pdf = d.pdf_path.read_bytes()
44
+ assert pdf[:5] == b"%PDF-" and len(pdf) > 1000
45
+ docx = d.docx_path.read_bytes()
46
+ assert docx[:2] == b"PK" and len(docx) > 1000 # .docx is a zip
47
 
48
 
49
  def test_personal_statement_contains_real_origin(tmp_path):
50
  docs = generate(_complete_session(), out_dir=tmp_path)
51
+ ps = next(d for d in docs if d.key == "personal_statement")
52
+ assert "Ethiopia" in _pdf_text(ps.pdf_path)
53
 
54
 
55
+ def test_no_forbidden_placeholder_tokens(tmp_path):
56
  docs = generate(_complete_session(), out_dir=tmp_path)
57
  for d in docs:
58
+ text = _pdf_text(d.pdf_path)
59
  for forbidden in ("PLACEHOLDER", "[NAME]", "[COUNTRY]"):
60
+ assert forbidden not in text, f"{forbidden} in {d.key}"
61
 
62
 
63
+ def test_download_all_zip_has_eight_files(tmp_path):
64
  docs = generate(_complete_session(), out_dir=tmp_path)
65
+ assert len(all_files(docs)) == 8 # 4 docs × (pdf + docx)
66
  zip_path = zip_package(docs, out_dir=tmp_path)
67
+ assert zip_path.exists() and zip_path.stat().st_size > 2000
 
tests/unit/test_doc_fields.py CHANGED
@@ -1,49 +1,40 @@
1
- """tests/unit/test_doc_fields.py — pre-fill field rules (T055).
2
 
3
- Pre-filled values trace to real session keys and are amber-highlighted; missing
4
- fields render as a blank line (never a crash, never an invented value).
 
5
  """
6
 
7
- from agent.tools.doc_generator import build_html, fill
 
8
  from app.state.session import SessionState, State
9
 
10
 
11
- def test_fill_present_value_is_highlighted():
12
- out = fill("Ethiopia", "interview.origin_country")
13
- assert "Ethiopia" in out
14
- assert 'class="fill"' in out
15
-
16
-
17
- def test_fill_missing_value_is_blank_not_placeholder():
18
- out = fill(None, "interview.origin_country")
19
- assert 'class="blank"' in out
20
- assert "Ethiopia" not in out
21
- assert "None" not in out # never leak the literal None
22
- assert "PLACEHOLDER" not in out and "[NAME]" not in out
23
-
24
-
25
- def test_fill_list_is_joined_and_escaped():
26
- out = fill(["Political", "Ethnic"], "interview.persecution_types")
27
- assert "Political, Ethnic" in out
28
- safe = fill("<script>x</script>", "interview.free_text_history")
29
- assert "<script>" not in safe # HTML-escaped
30
-
31
-
32
- def _min_session() -> SessionState:
33
  s = SessionState()
34
  for t in (State.INTAKE, State.SITUATION, State.HISTORY, State.GOALS, State.REVIEW,
35
  State.ASSESSMENT, State.RECOMMENDATIONS, State.DOCUMENTS):
36
  s.transition_to(t)
 
 
 
37
  return s
38
 
39
 
40
- def test_build_html_with_no_data_does_not_crash():
41
- # origin_country is None and nothing is filled — must render blanks, not crash.
42
- s = _min_session()
43
- htmls = build_html(s)
44
- assert set(htmls) == {
45
- "personal_statement", "action_plan", "emergency_contacts", "rights_summary_card"
46
- }
47
- statement = htmls["personal_statement"]
48
- assert 'class="blank"' in statement # origin rendered as a blank line
49
- assert "None" not in statement
 
 
 
 
 
 
 
 
1
+ """tests/unit/test_doc_fields.py — document drafting field rules.
2
 
3
+ The personal statement is drafted (LLM at runtime; deterministic fallback here);
4
+ pre-filled values trace to real session data; missing specifics become clearly
5
+ marked [placeholders]; nothing is fabricated.
6
  """
7
 
8
+ from agent.drafting import fallback_statement
9
+ from agent.tools.doc_generator import _html_with_placeholders, preview_statement_html
10
  from app.state.session import SessionState, State
11
 
12
 
13
+ def _session(origin="Ethiopia"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  s = SessionState()
15
  for t in (State.INTAKE, State.SITUATION, State.HISTORY, State.GOALS, State.REVIEW,
16
  State.ASSESSMENT, State.RECOMMENDATIONS, State.DOCUMENTS):
17
  s.transition_to(t)
18
+ s.interview.origin_country = origin
19
+ s.interview.current_country = "Sudan"
20
+ s.interview.free_text_history = "Armed men came to our village."
21
  return s
22
 
23
 
24
+ def test_fallback_statement_uses_real_data_and_placeholders():
25
+ out = fallback_statement(_session())
26
+ assert "Ethiopia" in out # real data
27
+ assert "[your full name]" in out # placeholder for the person to fill
28
+ assert "Armed men came to our village." in out
29
+
30
+
31
+ def test_placeholders_highlighted_in_html():
32
+ html = _html_with_placeholders("My name is [your full name].")
33
+ assert 'class="fill"' in html and "[your full name]" in html
34
+
35
+
36
+ def test_preview_renders_without_crash_on_empty_session():
37
+ s = SessionState()
38
+ html = preview_statement_html(s) # no data -> placeholders, no crash
39
+ assert "Personal Statement" in html
40
+ assert "None" not in html