Spaces:
Sleeping
[agentic] Port pi loop hooks + LLM-drafted editable (Word) documents
Browse filesAudited /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 +84 -0
- agent/loop.py +78 -26
- agent/tools/doc_generator.py +193 -155
- app/app.py +2 -2
- app/phases/assessment.py +23 -4
- app/phases/documents.py +18 -9
- app/phases/recommendations.py +9 -2
- requirements.txt +2 -1
- tests/integration/test_documents.py +23 -26
- tests/unit/test_doc_fields.py +27 -36
|
@@ -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"]
|
|
@@ -23,9 +23,11 @@ Guarantees:
|
|
| 23 |
from __future__ import annotations
|
| 24 |
|
| 25 |
import asyncio
|
|
|
|
| 26 |
import json
|
| 27 |
import os
|
| 28 |
-
from
|
|
|
|
| 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
|
| 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 +
|
| 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 |
-
|
| 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 |
-
|
| 211 |
for call in serialised_calls:
|
| 212 |
name = call["function"]["name"]
|
| 213 |
args = call["function"]["arguments"]
|
| 214 |
yield ToolStartEvent(name=name, args=args)
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
yield ToolEndEvent(name=name, result=result)
|
| 217 |
-
history.append(
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
continue
|
| 226 |
|
| 227 |
-
# No tool calls:
|
| 228 |
history.append(assistant_message)
|
| 229 |
yield TurnEndEvent(message=assistant_message)
|
| 230 |
|
| 231 |
-
|
|
|
|
| 232 |
if not self.steering_queue.empty():
|
| 233 |
-
|
| 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"]
|
|
@@ -1,32 +1,37 @@
|
|
| 1 |
-
"""agent/tools/doc_generator.py — Phase 5 document package
|
| 2 |
-
|
| 3 |
-
Generates
|
| 4 |
-
(agent/
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 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 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
return
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
# -- per-document HTML builders --------------------------------------------
|
| 64 |
|
| 65 |
-
def
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 97 |
-
|
| 98 |
-
|
| 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
|
| 109 |
-
|
| 110 |
-
|
| 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 |
-
"""
|
|
|
|
| 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 |
-
|
| 188 |
-
|
| 189 |
docs: list[GeneratedDoc] = []
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
return docs
|
| 196 |
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
def zip_package(docs: list[GeneratedDoc], out_dir: Path | None = None) -> Path:
|
| 199 |
-
|
| 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
|
| 204 |
-
zf.write(
|
| 205 |
return zip_path
|
| 206 |
|
| 207 |
|
| 208 |
-
|
| 209 |
-
"
|
| 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 & legal aid · Prepared with Refuge</p>'
|
| 180 |
+
f"<h2>{html.escape(country)}</h2><p>UNHCR office: {html.escape(office)}</p>"
|
| 181 |
+
"<h2>Legal aid & 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"]
|
|
@@ -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)
|
|
@@ -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 |
-
("
|
| 35 |
("Immediate danger", "immediate_danger", "bool"),
|
| 36 |
-
("
|
|
|
|
| 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
|
|
@@ -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 (
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
|
|
@@ -100,7 +100,14 @@ def _unhcr_label(rec: dict) -> str:
|
|
| 100 |
|
| 101 |
def _acceptance_label(rec: dict) -> str:
|
| 102 |
rate = rec.get("acceptanceRate")
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
("
|
| 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(
|
|
@@ -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
|
|
@@ -1,14 +1,14 @@
|
|
| 1 |
-
"""tests/integration/test_documents.py — document package
|
| 2 |
|
| 3 |
-
Seeds a complete session
|
| 4 |
-
|
| 5 |
-
person's real data
|
| 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 = "
|
| 21 |
s.interview.origin_country = origin
|
| 22 |
s.interview.current_country = "Sudan"
|
| 23 |
-
s.interview.
|
| 24 |
s.interview.immediate_danger = True
|
| 25 |
-
s.interview.
|
| 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
|
| 43 |
docs = generate(_complete_session(), out_dir=tmp_path)
|
| 44 |
assert len(docs) == 4
|
| 45 |
for d in docs:
|
| 46 |
-
assert d.
|
| 47 |
-
|
| 48 |
-
assert len(
|
| 49 |
-
|
|
|
|
| 50 |
|
| 51 |
|
| 52 |
def test_personal_statement_contains_real_origin(tmp_path):
|
| 53 |
docs = generate(_complete_session(), out_dir=tmp_path)
|
| 54 |
-
|
| 55 |
-
assert "Ethiopia" in _pdf_text(
|
| 56 |
|
| 57 |
|
| 58 |
-
def
|
| 59 |
docs = generate(_complete_session(), out_dir=tmp_path)
|
| 60 |
for d in docs:
|
| 61 |
-
text = _pdf_text(d.
|
| 62 |
for forbidden in ("PLACEHOLDER", "[NAME]", "[COUNTRY]"):
|
| 63 |
-
assert forbidden not in text, f"{forbidden}
|
| 64 |
|
| 65 |
|
| 66 |
-
def
|
| 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
|
|
|
|
@@ -1,49 +1,40 @@
|
|
| 1 |
-
"""tests/unit/test_doc_fields.py —
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
from agent.
|
|
|
|
| 8 |
from app.state.session import SessionState, State
|
| 9 |
|
| 10 |
|
| 11 |
-
def
|
| 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
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
assert
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
assert "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|