Spaces:
Running
[T049-T055 + UX] Phase 5 documents + single-model + responder/UX fixes
Browse filesPhase 5 — Document package:
- agent/tools/doc_generator.py + 4 WeasyPrint templates: real PDFs from session
data, amber-highlighted pre-fills, blanks (never [NAME]/PLACEHOLDER), logged
field sources; zip for "Download all"; head-less preview snippet
- app/phases/documents.py: preview + evidence checklist (pre-checked from
documents_available) + file list + Download all + Start over
- nav: Recommendations "Prepare my documents" -> Documents; Start over -> intake
- launch(allowed_paths=[tempdir]) so Gradio can serve the generated PDFs
- tests: 8 document tests (real PDF text via pypdf 6.12.2, pinned one-before)
Single LLM + UX fixes (from live testing):
- one model end-to-end: MODEL_ID=lfm2.5:8b (fast ~2s, tool-calling + good
multilingual); removed per-phase model split (no fallback, hackathon rule)
- languages limited to those lfm2.5:8b handles well (probe-verified): English,
French, Spanish, Portuguese, Arabic, Hindi, Chinese, Japanese, Korean, Russian
- responder: free-text box ALWAYS visible (never stuck if the model omits its
directive); pills/country shown in addition when offered; derive_answer falls
back to text; removed "Save and continue later"; fixed leaking control labels
- fixed button CSS selectors (Gradio puts elem_id on the <button>, so #id button
never matched -> buttons rendered unstyled/grey)
Co-authored-by: Codex <noreply@openai.com>
- .env.example +5 -0
- agent/tools/doc_generator.py +210 -1
- agent/tools/templates/_head.html +30 -0
- agent/tools/templates/action_plan.html +15 -0
- agent/tools/templates/emergency_contacts.html +22 -0
- agent/tools/templates/personal_statement.html +21 -0
- agent/tools/templates/rights_summary_card.html +24 -0
- app/app.py +48 -3
- app/phases/documents.py +122 -1
- app/phases/intake.py +11 -8
- app/phases/interview.py +29 -25
- app/phases/recommendations.py +12 -3
- requirements.txt +1 -0
- scripts/validate_design_tokens.py +1 -0
- tests/integration/test_documents.py +70 -1
- tests/unit/test_doc_fields.py +49 -1
- tests/unit/test_intake_state.py +2 -2
|
@@ -8,6 +8,11 @@ OLLAMA_HOST=http://127.0.0.1:11434
|
|
| 8 |
# Spec default is qwen2.5:7b; pick what your Ollama host actually has.
|
| 9 |
MODEL_ID=qwen2.5:7b
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
# LLM provider: "ollama" (default) or a litellm provider name.
|
| 12 |
MODEL_PROVIDER=ollama
|
| 13 |
|
|
|
|
| 8 |
# Spec default is qwen2.5:7b; pick what your Ollama host actually has.
|
| 9 |
MODEL_ID=qwen2.5:7b
|
| 10 |
|
| 11 |
+
# Per-phase model overrides (optional). The interview is fast Q&A; a small model
|
| 12 |
+
# keeps it snappy. The assessment is reasoning-heavy and can use a larger model.
|
| 13 |
+
INTERVIEW_MODEL_ID=qwen2.5:7b
|
| 14 |
+
ASSESSMENT_MODEL_ID=qwen2.5:7b
|
| 15 |
+
|
| 16 |
# LLM provider: "ollama" (default) or a litellm provider name.
|
| 17 |
MODEL_PROVIDER=ollama
|
| 18 |
|
|
@@ -1 +1,210 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 33 |
+
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"> </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 |
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<meta charset="utf-8" />
|
| 2 |
+
<style>
|
| 3 |
+
@page { size: A4; margin: 22mm 20mm; }
|
| 4 |
+
:root {
|
| 5 |
+
--primary: #0E6A58; --primary-deep: #0A5042; --accent-tint: #FBEDE1;
|
| 6 |
+
--text: #1A1A1A; --muted: #6B7280; --line: #E7E2D8;
|
| 7 |
+
}
|
| 8 |
+
* { box-sizing: border-box; }
|
| 9 |
+
body { font-family: "Inter", "Helvetica Neue", Arial, sans-serif; color: var(--text);
|
| 10 |
+
font-size: 12pt; line-height: 1.6; }
|
| 11 |
+
h1, h2, h3, h4 { font-family: "Fraunces", Georgia, "Times New Roman", serif; color: var(--primary-deep);
|
| 12 |
+
margin: 0 0 4px; line-height: 1.2; }
|
| 13 |
+
h1 { font-size: 22pt; }
|
| 14 |
+
h2 { font-size: 15pt; margin-top: 18px; }
|
| 15 |
+
.sub { color: var(--muted); font-size: 10pt; margin: 0 0 16px; letter-spacing: .02em; }
|
| 16 |
+
p { margin: 0 0 11px; }
|
| 17 |
+
.fill { background: var(--accent-tint); color: #7a431a; padding: 0 4px; border-radius: 3px; font-weight: 600; }
|
| 18 |
+
.blank { display: inline-block; min-width: 120px; border-bottom: 1px solid #999; }
|
| 19 |
+
.divider { height: 1px; background: var(--line); margin: 14px 0; }
|
| 20 |
+
.note { color: var(--muted); font-size: 9.5pt; margin-top: 20px; border-top: 1px solid var(--line); padding-top: 8px; }
|
| 21 |
+
ul, ol { margin: 0 0 11px; padding-left: 20px; }
|
| 22 |
+
li { margin-bottom: 6px; }
|
| 23 |
+
.step { margin-bottom: 12px; }
|
| 24 |
+
.step b { color: var(--primary-deep); }
|
| 25 |
+
.chip { display: inline-block; background: #E5EFEA; color: var(--primary-deep); font-size: 9.5pt;
|
| 26 |
+
padding: 2px 8px; border-radius: 999px; margin-right: 4px; }
|
| 27 |
+
.org { margin-bottom: 8px; }
|
| 28 |
+
.org b { display: block; }
|
| 29 |
+
.org span { color: var(--muted); font-size: 10pt; }
|
| 30 |
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en"><head>%%HEAD%%</head><body>
|
| 3 |
+
<h1>Action Plan — %%selected_country%%</h1>
|
| 4 |
+
<p class="sub">A step-by-step roadmap to seek asylum · Prepared with Refuge</p>
|
| 5 |
+
|
| 6 |
+
<p>This plan is for seeking protection in %%selected_country%%. Processing typically
|
| 7 |
+
takes %%processing%%. UNHCR presence: %%unhcr_office%%.</p>
|
| 8 |
+
|
| 9 |
+
<div class="divider"></div>
|
| 10 |
+
|
| 11 |
+
%%steps%%
|
| 12 |
+
|
| 13 |
+
<p class="note">Timeframes are typical estimates from UNHCR data and may vary.
|
| 14 |
+
Keep copies of every document you submit.</p>
|
| 15 |
+
</body></html>
|
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en"><head>%%HEAD%%</head><body>
|
| 3 |
+
<h1>Emergency Contacts</h1>
|
| 4 |
+
<p class="sub">UNHCR offices & legal aid · Prepared with Refuge</p>
|
| 5 |
+
|
| 6 |
+
<h2>%%selected_country%%</h2>
|
| 7 |
+
<p>UNHCR office: %%unhcr_office%%</p>
|
| 8 |
+
|
| 9 |
+
<h2>Legal aid & support organisations</h2>
|
| 10 |
+
%%orgs%%
|
| 11 |
+
|
| 12 |
+
<div class="divider"></div>
|
| 13 |
+
<h2>If you are in immediate danger</h2>
|
| 14 |
+
<ul>
|
| 15 |
+
<li>Contact the nearest UNHCR office or registration centre.</li>
|
| 16 |
+
<li>Ask for an asylum-seeker certificate — it protects you from being returned.</li>
|
| 17 |
+
<li>You have the right to request a translator and free legal assistance.</li>
|
| 18 |
+
</ul>
|
| 19 |
+
|
| 20 |
+
<p class="note">Contacts are drawn from the curated Refuge country reference.
|
| 21 |
+
Verify details locally where possible.</p>
|
| 22 |
+
</body></html>
|
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en"><head>%%HEAD%%</head><body>
|
| 3 |
+
<h1>Personal Statement</h1>
|
| 4 |
+
<p class="sub">In support of an application for refugee status · Prepared with Refuge</p>
|
| 5 |
+
|
| 6 |
+
<p>My name is %%full_name%%. I am a national of %%origin_country%%. I am currently
|
| 7 |
+
in %%current_country%%%%family_clause%%.</p>
|
| 8 |
+
|
| 9 |
+
<div class="divider"></div>
|
| 10 |
+
|
| 11 |
+
<p>I left my home because of %%persecution%%.</p>
|
| 12 |
+
|
| 13 |
+
%%history_para%%
|
| 14 |
+
|
| 15 |
+
<p>I fear that if I return I will be at risk of serious harm because of the
|
| 16 |
+
circumstances described above. I am asking for protection and the right not to be
|
| 17 |
+
returned to a place where my life or freedom would be threatened.</p>
|
| 18 |
+
|
| 19 |
+
<p class="note">Fields highlighted in amber were pre-filled from what you told Refuge.
|
| 20 |
+
Anything left blank is for you to complete. You can edit any part of this statement.</p>
|
| 21 |
+
</body></html>
|
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en"><head>%%HEAD%%</head><body>
|
| 3 |
+
<h1>Your Rights — Summary Card</h1>
|
| 4 |
+
<p class="sub">Key protections for people seeking asylum · Prepared with Refuge</p>
|
| 5 |
+
|
| 6 |
+
<h2>Non-refoulement</h2>
|
| 7 |
+
<p>You cannot be forced back to a country where your life or freedom would be at
|
| 8 |
+
serious risk. This is a core protection under the 1951 Refugee Convention.</p>
|
| 9 |
+
|
| 10 |
+
<h2>Your rights while your claim is decided</h2>
|
| 11 |
+
<ul>
|
| 12 |
+
<li>The right to seek asylum and have your claim heard fairly.</li>
|
| 13 |
+
<li>The right to an interpreter and to free legal assistance.</li>
|
| 14 |
+
<li>The right not to be detained arbitrarily.</li>
|
| 15 |
+
<li>The right to basic dignity: shelter, food, and emergency healthcare.</li>
|
| 16 |
+
</ul>
|
| 17 |
+
|
| 18 |
+
<h2>For your situation</h2>
|
| 19 |
+
<p>Origin: %%origin_country%% · Seeking protection in: %%selected_country%%</p>
|
| 20 |
+
<p>Convention grounds identified: %%grounds%%</p>
|
| 21 |
+
|
| 22 |
+
<p class="note">Carry this card with you. If anyone tries to return you, show it and
|
| 23 |
+
ask to speak to UNHCR.</p>
|
| 24 |
+
</body></html>
|
|
@@ -15,6 +15,7 @@ here (CLAUDE.md Design Rule 7).
|
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
|
|
|
| 18 |
import sys
|
| 19 |
from pathlib import Path
|
| 20 |
|
|
@@ -27,6 +28,7 @@ from agent.loop import create_loop # noqa: E402
|
|
| 27 |
from app.config import load_env # noqa: E402
|
| 28 |
from app.design_tokens import root_css # noqa: E402
|
| 29 |
from app.phases import assessment as assessment_phase # noqa: E402
|
|
|
|
| 30 |
from app.phases import intake as intake_phase # noqa: E402
|
| 31 |
from app.phases import interview as interview_phase # noqa: E402
|
| 32 |
from app.phases import recommendations as reco_phase # noqa: E402
|
|
@@ -35,6 +37,9 @@ from app.state.session import SessionState, State # noqa: E402
|
|
| 35 |
# Load .env (OLLAMA_HOST, MODEL_ID, …) before anything reads the environment.
|
| 36 |
load_env()
|
| 37 |
|
|
|
|
|
|
|
|
|
|
| 38 |
# Web fonts: Fraunces (display serif) + Inter (UI sans) per DESIGN.md §3.
|
| 39 |
FONT_IMPORT = (
|
| 40 |
"@import url('https://fonts.googleapis.com/css2?"
|
|
@@ -73,7 +78,8 @@ APP_CSS = (
|
|
| 73 |
+ intake_phase.INTAKE_CSS + "\n"
|
| 74 |
+ interview_phase.INTERVIEW_CSS + "\n"
|
| 75 |
+ assessment_phase.ASSESSMENT_CSS + "\n"
|
| 76 |
-
+ reco_phase.RECO_CSS
|
|
|
|
| 77 |
)
|
| 78 |
|
| 79 |
|
|
@@ -98,6 +104,7 @@ def build_app() -> gr.Blocks:
|
|
| 98 |
interview_ui = interview_phase.build(visible=False, session_st=session_st, loop_st=loop_st)
|
| 99 |
assess_ui = assessment_phase.build(visible=False, session_st=session_st, loop_st=loop_st)
|
| 100 |
reco_ui = reco_phase.build(visible=False, session_st=session_st)
|
|
|
|
| 101 |
|
| 102 |
async def begin(lang, session, loop):
|
| 103 |
if not lang:
|
|
@@ -121,7 +128,9 @@ def build_app() -> gr.Blocks:
|
|
| 121 |
# confirmed, then hands off to the assessment screen.
|
| 122 |
if not _confirmed_review(session):
|
| 123 |
return
|
| 124 |
-
|
|
|
|
|
|
|
| 125 |
yield (gr.update(visible=False), gr.update(visible=True),
|
| 126 |
facts_html, reason_html, progress_html, sess)
|
| 127 |
|
|
@@ -150,12 +159,48 @@ def build_app() -> gr.Blocks:
|
|
| 150 |
# Once the assessment stream finishes, show the recommendation cards.
|
| 151 |
assess_event.then(show_recommendations, inputs=[session_st], outputs=reco_outputs)
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
return demo
|
| 154 |
|
| 155 |
|
| 156 |
def main() -> None:
|
|
|
|
|
|
|
| 157 |
demo = build_app()
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
|
| 161 |
if __name__ == "__main__":
|
|
|
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
+
import os
|
| 19 |
import sys
|
| 20 |
from pathlib import Path
|
| 21 |
|
|
|
|
| 28 |
from app.config import load_env # noqa: E402
|
| 29 |
from app.design_tokens import root_css # noqa: E402
|
| 30 |
from app.phases import assessment as assessment_phase # noqa: E402
|
| 31 |
+
from app.phases import documents as documents_phase # noqa: E402
|
| 32 |
from app.phases import intake as intake_phase # noqa: E402
|
| 33 |
from app.phases import interview as interview_phase # noqa: E402
|
| 34 |
from app.phases import recommendations as reco_phase # noqa: E402
|
|
|
|
| 37 |
# Load .env (OLLAMA_HOST, MODEL_ID, …) before anything reads the environment.
|
| 38 |
load_env()
|
| 39 |
|
| 40 |
+
# One model for the whole flow (hackathon: single LLM, no fallback). Read from
|
| 41 |
+
# MODEL_ID via the loop factory.
|
| 42 |
+
|
| 43 |
# Web fonts: Fraunces (display serif) + Inter (UI sans) per DESIGN.md §3.
|
| 44 |
FONT_IMPORT = (
|
| 45 |
"@import url('https://fonts.googleapis.com/css2?"
|
|
|
|
| 78 |
+ intake_phase.INTAKE_CSS + "\n"
|
| 79 |
+ interview_phase.INTERVIEW_CSS + "\n"
|
| 80 |
+ assessment_phase.ASSESSMENT_CSS + "\n"
|
| 81 |
+
+ reco_phase.RECO_CSS + "\n"
|
| 82 |
+
+ documents_phase.DOCUMENTS_CSS
|
| 83 |
)
|
| 84 |
|
| 85 |
|
|
|
|
| 104 |
interview_ui = interview_phase.build(visible=False, session_st=session_st, loop_st=loop_st)
|
| 105 |
assess_ui = assessment_phase.build(visible=False, session_st=session_st, loop_st=loop_st)
|
| 106 |
reco_ui = reco_phase.build(visible=False, session_st=session_st)
|
| 107 |
+
docs_ui = documents_phase.build(visible=False, session_st=session_st)
|
| 108 |
|
| 109 |
async def begin(lang, session, loop):
|
| 110 |
if not lang:
|
|
|
|
| 128 |
# confirmed, then hands off to the assessment screen.
|
| 129 |
if not _confirmed_review(session):
|
| 130 |
return
|
| 131 |
+
# Fresh loop for the assessment turn (same model, its own steering/abort).
|
| 132 |
+
assess_loop = create_loop()
|
| 133 |
+
async for facts_html, reason_html, progress_html, sess in assess_ui.start_fn(session, assess_loop):
|
| 134 |
yield (gr.update(visible=False), gr.update(visible=True),
|
| 135 |
facts_html, reason_html, progress_html, sess)
|
| 136 |
|
|
|
|
| 159 |
# Once the assessment stream finishes, show the recommendation cards.
|
| 160 |
assess_event.then(show_recommendations, inputs=[session_st], outputs=reco_outputs)
|
| 161 |
|
| 162 |
+
# Recommendations -> Documents: generate the package for the chosen country.
|
| 163 |
+
docs_outputs = [reco_ui.column, docs_ui.column, *docs_ui.render_outputs]
|
| 164 |
+
|
| 165 |
+
def show_documents(session):
|
| 166 |
+
if session is None or not session.selected_country:
|
| 167 |
+
return [gr.update()] * len(docs_outputs)
|
| 168 |
+
if session.state < State.DOCUMENTS:
|
| 169 |
+
from app.phases.interview import advance_to
|
| 170 |
+
advance_to(session, State.DOCUMENTS)
|
| 171 |
+
updates = docs_ui.populate(session)
|
| 172 |
+
return [gr.update(visible=False), gr.update(visible=True), *updates]
|
| 173 |
+
|
| 174 |
+
reco_ui.proceed.click(show_documents, inputs=[session_st], outputs=docs_outputs)
|
| 175 |
+
|
| 176 |
+
# Start over: return to the intake screen for a fresh session.
|
| 177 |
+
def start_over():
|
| 178 |
+
return (
|
| 179 |
+
gr.update(visible=True), gr.update(visible=False),
|
| 180 |
+
gr.update(visible=False), gr.update(visible=False),
|
| 181 |
+
gr.update(visible=False), None, None,
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
docs_ui.start_over.click(
|
| 185 |
+
start_over,
|
| 186 |
+
inputs=[],
|
| 187 |
+
outputs=[intake_ui.column, interview_ui.column, assess_ui.column,
|
| 188 |
+
reco_ui.column, docs_ui.column, session_st, loop_st],
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
return demo
|
| 192 |
|
| 193 |
|
| 194 |
def main() -> None:
|
| 195 |
+
import tempfile
|
| 196 |
+
|
| 197 |
demo = build_app()
|
| 198 |
+
# allowed_paths lets Gradio serve the generated PDFs (written to the system
|
| 199 |
+
# temp dir) for download.
|
| 200 |
+
demo.launch(
|
| 201 |
+
server_name="0.0.0.0", css=APP_CSS, theme=gr.themes.Base(),
|
| 202 |
+
allowed_paths=[tempfile.gettempdir()],
|
| 203 |
+
)
|
| 204 |
|
| 205 |
|
| 206 |
if __name__ == "__main__":
|
|
@@ -1 +1,122 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""app/phases/documents.py — Phase 5 document package screen (T051/T052).
|
| 2 |
+
|
| 3 |
+
Matches mockup.html #phase-5: a document preview (with amber-tint highlights on
|
| 4 |
+
pre-filled fields), an evidence checklist (pre-checked from
|
| 5 |
+
``session.interview.documents_available``), the four generated files, a
|
| 6 |
+
"Download all" primary action, and a "Start over" ghost button.
|
| 7 |
+
|
| 8 |
+
The PDFs are generated by ``agent.tools.doc_generator`` from real session data.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
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).
|
| 21 |
+
CHECKLIST = [
|
| 22 |
+
("Personal statement", "Drafted by Refuge · ready", "_always"),
|
| 23 |
+
("Identity document or sworn statement", "Passport, national ID, or a sworn statement", "passport"),
|
| 24 |
+
("Proof of journey / entry", "Tickets, stamps, or a written account", "travel"),
|
| 25 |
+
("Photos or messages showing the threat", "Optional, but strengthens your claim", "evidence"),
|
| 26 |
+
("Names of witnesses", "People who can confirm what happened", "witnesses"),
|
| 27 |
+
("Medical or police reports", "If you have any", "medical"),
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
DOCUMENTS_CSS = """
|
| 31 |
+
#docs-screen { background: var(--surface); border: 1px solid var(--line);
|
| 32 |
+
border-radius: var(--r-lg); box-shadow: var(--shadow-md); overflow: hidden;
|
| 33 |
+
max-width: 1180px; margin: 0 auto; }
|
| 34 |
+
.pkg { display:grid; grid-template-columns:1.1fr .9fr; gap:0; }
|
| 35 |
+
@media(max-width:820px){ .pkg { grid-template-columns:1fr; } }
|
| 36 |
+
.pkg__preview { padding:clamp(18px,4vw,28px); border-right:1px solid var(--line); background:var(--surface-2); }
|
| 37 |
+
@media(max-width:820px){ .pkg__preview { border-right:0; border-bottom:1px solid var(--line); } }
|
| 38 |
+
.pkg__side { padding:clamp(18px,4vw,28px); background:var(--surface); }
|
| 39 |
+
.pkg__lbl { font-size:12px; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); font-weight:600; margin:0 0 12px; }
|
| 40 |
+
.doc { background:#fff; border:1px solid var(--line-strong); border-radius:var(--r-md); box-shadow:var(--shadow-sm);
|
| 41 |
+
padding:26px 28px; max-height:360px; overflow:hidden; position:relative; }
|
| 42 |
+
.doc::after { content:""; position:absolute; left:0; right:0; bottom:0; height:70px; background:linear-gradient(180deg,rgba(255,255,255,0),#fff); }
|
| 43 |
+
.doc h4 { font-family:var(--font-display); font-size:18px; font-weight:600; margin:0 0 2px; }
|
| 44 |
+
.doc .doc__sub { font-size:12px; color:var(--text-muted); margin:0 0 16px; }
|
| 45 |
+
.doc p { font-size:13px; line-height:1.7; color:#33373f; margin:0 0 11px; }
|
| 46 |
+
.doc .fill { background:var(--accent-tint); color:#7a431a; padding:0 4px; border-radius:3px; font-weight:600; }
|
| 47 |
+
.doc .blank { display:inline-block; min-width:90px; border-bottom:1px solid #aaa; }
|
| 48 |
+
.doc__divider { height:1px; background:var(--line); margin:14px 0; }
|
| 49 |
+
|
| 50 |
+
#docs-checklist .wrap label { font-size:14.5px !important; }
|
| 51 |
+
#docs-files .download-link, #docs-files label { font-size:13.5px; }
|
| 52 |
+
#docs-download, #docs-download button { background:var(--accent) !important; color:var(--on-accent) !important;
|
| 53 |
+
box-shadow:0 2px 0 var(--accent-deep) !important; border:0 !important; font-weight:600 !important;
|
| 54 |
+
border-radius:var(--r-md) !important; padding:14px 22px !important; }
|
| 55 |
+
#docs-download:hover, #docs-download button:hover { background:var(--accent-deep) !important; }
|
| 56 |
+
#docs-startover, #docs-startover button { background:transparent !important; color:var(--primary-deep) !important;
|
| 57 |
+
border:0 !important; box-shadow:none !important; font-weight:600 !important; }
|
| 58 |
+
#docs-startover:hover, #docs-startover button:hover { background:var(--primary-tint) !important; }
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass
|
| 63 |
+
class DocumentsUI:
|
| 64 |
+
column: gr.Column
|
| 65 |
+
populate: callable # (session) -> list of updates for render_outputs
|
| 66 |
+
render_outputs: list
|
| 67 |
+
start_over: gr.Button
|
| 68 |
+
session: gr.State
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _checklist_values(session: SessionState) -> list[str]:
|
| 72 |
+
"""Labels that should be pre-checked, from documents_available (SC-043)."""
|
| 73 |
+
available = {d.lower() for d in (session.interview.documents_available or [])}
|
| 74 |
+
checked = []
|
| 75 |
+
for label, _sub, key in CHECKLIST:
|
| 76 |
+
if key == "_always" or (key and key in available):
|
| 77 |
+
checked.append(label)
|
| 78 |
+
return checked
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def build(visible: bool = False, session_st: gr.State | None = None) -> DocumentsUI:
|
| 82 |
+
session_st = session_st or gr.State(None)
|
| 83 |
+
|
| 84 |
+
with gr.Column(elem_id="docs-screen", visible=visible) as column:
|
| 85 |
+
with gr.Row(elem_classes=["pkg"]):
|
| 86 |
+
with gr.Column(elem_classes=["pkg__preview"]):
|
| 87 |
+
gr.HTML('<p class="pkg__lbl">Preview · Personal statement</p>')
|
| 88 |
+
preview = gr.HTML('<article class="doc"></article>')
|
| 89 |
+
with gr.Column(elem_classes=["pkg__side"]):
|
| 90 |
+
gr.HTML('<p class="pkg__lbl">Evidence to gather</p>')
|
| 91 |
+
checklist = gr.CheckboxGroup(
|
| 92 |
+
choices=[label for label, _s, _k in CHECKLIST],
|
| 93 |
+
value=[], label="", elem_id="docs-checklist",
|
| 94 |
+
)
|
| 95 |
+
gr.HTML('<p class="pkg__lbl">Your files</p>')
|
| 96 |
+
files = gr.File(label="", interactive=False, elem_id="docs-files")
|
| 97 |
+
download_all = gr.DownloadButton(
|
| 98 |
+
"⤓ Download all (4 files)", elem_id="docs-download", visible=False
|
| 99 |
+
)
|
| 100 |
+
start_over = gr.Button(
|
| 101 |
+
"↺ Start over for a different country", elem_id="docs-startover"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
render_outputs = [preview, checklist, files, download_all]
|
| 105 |
+
|
| 106 |
+
def populate(session: SessionState) -> list:
|
| 107 |
+
docs = generate(session)
|
| 108 |
+
zip_path = zip_package(docs)
|
| 109 |
+
return [
|
| 110 |
+
gr.update(value=preview_statement_html(session)),
|
| 111 |
+
gr.update(value=_checklist_values(session)),
|
| 112 |
+
gr.update(value=[str(d.path) for d in docs]),
|
| 113 |
+
gr.update(value=str(zip_path), visible=True),
|
| 114 |
+
]
|
| 115 |
+
|
| 116 |
+
return DocumentsUI(
|
| 117 |
+
column=column, populate=populate, render_outputs=render_outputs,
|
| 118 |
+
start_over=start_over, session=session_st,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
__all__ = ["build", "DocumentsUI", "DOCUMENTS_CSS", "CHECKLIST"]
|
|
@@ -17,16 +17,19 @@ import gradio as gr
|
|
| 17 |
|
| 18 |
from app.state.session import SessionState, State
|
| 19 |
|
| 20 |
-
# (native script label, English gloss).
|
|
|
|
| 21 |
LANGUAGES = [
|
| 22 |
("English", "English"),
|
| 23 |
("Français", "French"),
|
| 24 |
-
("
|
| 25 |
-
("Kiswahili", "Swahili"),
|
| 26 |
-
("አማርኛ", "Amharic"),
|
| 27 |
-
("Soomaali", "Somali"),
|
| 28 |
-
("Hausa", "Hausa"),
|
| 29 |
("Português", "Portuguese"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
]
|
| 31 |
|
| 32 |
# Map a chosen pill label (native) to the English name stored on the session.
|
|
@@ -62,10 +65,10 @@ INTAKE_CSS = """
|
|
| 62 |
color:var(--on-primary) !important; box-shadow:var(--shadow-sm) !important; }
|
| 63 |
|
| 64 |
.intake-cta { margin-top:30px; }
|
| 65 |
-
#intake-begin button { background:var(--accent) !important; color:var(--on-accent) !important;
|
| 66 |
box-shadow:0 2px 0 var(--accent-deep) !important; border:0 !important; border-radius:var(--r-md) !important;
|
| 67 |
font-weight:600 !important; font-size:16px !important; padding:16px 28px !important; }
|
| 68 |
-
#intake-begin button:hover { background:var(--accent-deep) !important; box-shadow:0 2px 0 #a8531f !important; }
|
| 69 |
|
| 70 |
.intake-trust { margin-top:24px; display:inline-flex; align-items:center; gap:9px; font-size:13px;
|
| 71 |
color:var(--text-muted); background:var(--surface-2); border:1px solid var(--line);
|
|
|
|
| 17 |
|
| 18 |
from app.state.session import SessionState, State
|
| 19 |
|
| 20 |
+
# (native script label, English gloss). Limited to the languages the single
|
| 21 |
+
# model (lfm2.5:8b) handles reliably — verified by a language probe.
|
| 22 |
LANGUAGES = [
|
| 23 |
("English", "English"),
|
| 24 |
("Français", "French"),
|
| 25 |
+
("Español", "Spanish"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
("Português", "Portuguese"),
|
| 27 |
+
("العربية", "Arabic"),
|
| 28 |
+
("हिन्दी", "Hindi"),
|
| 29 |
+
("中文", "Chinese"),
|
| 30 |
+
("日本語", "Japanese"),
|
| 31 |
+
("한국어", "Korean"),
|
| 32 |
+
("Русский", "Russian"),
|
| 33 |
]
|
| 34 |
|
| 35 |
# Map a chosen pill label (native) to the English name stored on the session.
|
|
|
|
| 65 |
color:var(--on-primary) !important; box-shadow:var(--shadow-sm) !important; }
|
| 66 |
|
| 67 |
.intake-cta { margin-top:30px; }
|
| 68 |
+
#intake-begin, #intake-begin button { background:var(--accent) !important; color:var(--on-accent) !important;
|
| 69 |
box-shadow:0 2px 0 var(--accent-deep) !important; border:0 !important; border-radius:var(--r-md) !important;
|
| 70 |
font-weight:600 !important; font-size:16px !important; padding:16px 28px !important; }
|
| 71 |
+
#intake-begin:hover, #intake-begin button:hover { background:var(--accent-deep) !important; box-shadow:0 2px 0 #a8531f !important; }
|
| 72 |
|
| 73 |
.intake-trust { margin-top:24px; display:inline-flex; align-items:center; gap:9px; font-size:13px;
|
| 74 |
color:var(--text-muted); background:var(--surface-2); border:1px solid var(--line);
|
|
@@ -98,12 +98,9 @@ INTERVIEW_CSS = """
|
|
| 98 |
|
| 99 |
/* Native Gradio controls themed to match the responder */
|
| 100 |
#iv-choice .wrap label, #iv-multi .wrap label { border-radius:var(--r-full) !important; }
|
| 101 |
-
#iv-continue button { background:var(--primary) !important; color:var(--on-primary) !important;
|
| 102 |
box-shadow:0 2px 0 var(--primary-deep) !important; border:0 !important; font-weight:600 !important; }
|
| 103 |
-
#iv-continue button:hover { background:var(--primary-deep) !important; }
|
| 104 |
-
#iv-save button { background:transparent !important; color:var(--primary-deep) !important;
|
| 105 |
-
border:0 !important; box-shadow:none !important; font-weight:600 !important; }
|
| 106 |
-
#iv-save button:hover { background:var(--primary-tint) !important; }
|
| 107 |
"""
|
| 108 |
|
| 109 |
|
|
@@ -190,29 +187,38 @@ def advance_to(session: SessionState, target: "State | str | None") -> None:
|
|
| 190 |
|
| 191 |
|
| 192 |
def derive_answer(spec: ResponderSpec, radio_v, multi_v, country_v, text_v) -> str:
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
return (text_v or "").strip()
|
| 200 |
|
| 201 |
|
| 202 |
def responder_updates(spec: ResponderSpec):
|
| 203 |
-
"""gr.update() tuple for (radio, multi, country, text) given the spec.
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
is_country = spec.mode == "country"
|
| 207 |
-
is_text = spec.mode == "text"
|
| 208 |
return (
|
| 209 |
gr.update(visible=is_choice_single, choices=spec.options if is_choice_single else [], value=None),
|
| 210 |
gr.update(visible=is_choice_multi, choices=spec.options if is_choice_multi else [], value=[]),
|
| 211 |
gr.update(visible=is_country, value=None),
|
| 212 |
gr.update(
|
| 213 |
-
visible=
|
| 214 |
value="",
|
| 215 |
-
placeholder=spec.placeholder or "
|
| 216 |
),
|
| 217 |
)
|
| 218 |
|
|
@@ -256,19 +262,17 @@ def build(
|
|
| 256 |
|
| 257 |
with gr.Column(elem_id="iv-responder"):
|
| 258 |
gr.HTML('<div class="label-row">Your answer</div>')
|
| 259 |
-
radio = gr.Radio(choices=[], label="", visible=False, elem_id="iv-choice")
|
| 260 |
-
multi = gr.CheckboxGroup(choices=[], label="", visible=False, elem_id="iv-multi")
|
| 261 |
country = gr.Dropdown(
|
| 262 |
-
choices=country_choices(), label="", visible=False,
|
| 263 |
allow_custom_value=True, filterable=True, elem_id="iv-country",
|
| 264 |
)
|
| 265 |
text = gr.Textbox(
|
| 266 |
-
label="", lines=3, visible=
|
| 267 |
-
placeholder="
|
| 268 |
)
|
| 269 |
-
|
| 270 |
-
save = gr.Button("⤓ Save and continue later", elem_id="iv-save", scale=1)
|
| 271 |
-
cont = gr.Button("Continue →", elem_id="iv-continue", scale=1)
|
| 272 |
|
| 273 |
stream_outputs = [chat, rail, radio, multi, country, text, session_st, loop_st, spec_st]
|
| 274 |
|
|
|
|
| 98 |
|
| 99 |
/* Native Gradio controls themed to match the responder */
|
| 100 |
#iv-choice .wrap label, #iv-multi .wrap label { border-radius:var(--r-full) !important; }
|
| 101 |
+
#iv-continue, #iv-continue button { background:var(--primary) !important; color:var(--on-primary) !important;
|
| 102 |
box-shadow:0 2px 0 var(--primary-deep) !important; border:0 !important; font-weight:600 !important; }
|
| 103 |
+
#iv-continue:hover, #iv-continue button:hover { background:var(--primary-deep) !important; }
|
|
|
|
|
|
|
|
|
|
| 104 |
"""
|
| 105 |
|
| 106 |
|
|
|
|
| 187 |
|
| 188 |
|
| 189 |
def derive_answer(spec: ResponderSpec, radio_v, multi_v, country_v, text_v) -> str:
|
| 190 |
+
"""Read the answer, preferring a structured selection, else the text box.
|
| 191 |
+
|
| 192 |
+
The text box is always available, so it is the fallback whenever the
|
| 193 |
+
structured control (pills/country) wasn't used.
|
| 194 |
+
"""
|
| 195 |
+
if spec.mode == "choice" and spec.multi and multi_v:
|
| 196 |
+
return ", ".join(multi_v)
|
| 197 |
+
if spec.mode == "choice" and radio_v:
|
| 198 |
+
return radio_v
|
| 199 |
+
if spec.mode == "country" and country_v:
|
| 200 |
+
return country_name(country_v)
|
| 201 |
return (text_v or "").strip()
|
| 202 |
|
| 203 |
|
| 204 |
def responder_updates(spec: ResponderSpec):
|
| 205 |
+
"""gr.update() tuple for (radio, multi, country, text) given the spec.
|
| 206 |
+
|
| 207 |
+
The free-text box is ALWAYS visible so the person can always answer and
|
| 208 |
+
continue, even if the model omits or malforms its responder directive. Pills
|
| 209 |
+
or the country selector appear in addition when the model offers them.
|
| 210 |
+
"""
|
| 211 |
+
is_choice_single = spec.mode == "choice" and not spec.multi and bool(spec.options)
|
| 212 |
+
is_choice_multi = spec.mode == "choice" and spec.multi and bool(spec.options)
|
| 213 |
is_country = spec.mode == "country"
|
|
|
|
| 214 |
return (
|
| 215 |
gr.update(visible=is_choice_single, choices=spec.options if is_choice_single else [], value=None),
|
| 216 |
gr.update(visible=is_choice_multi, choices=spec.options if is_choice_multi else [], value=[]),
|
| 217 |
gr.update(visible=is_country, value=None),
|
| 218 |
gr.update(
|
| 219 |
+
visible=True, # safety net — always available
|
| 220 |
value="",
|
| 221 |
+
placeholder=spec.placeholder or "Type your answer here…",
|
| 222 |
),
|
| 223 |
)
|
| 224 |
|
|
|
|
| 262 |
|
| 263 |
with gr.Column(elem_id="iv-responder"):
|
| 264 |
gr.HTML('<div class="label-row">Your answer</div>')
|
| 265 |
+
radio = gr.Radio(choices=[], label="", show_label=False, visible=False, elem_id="iv-choice")
|
| 266 |
+
multi = gr.CheckboxGroup(choices=[], label="", show_label=False, visible=False, elem_id="iv-multi")
|
| 267 |
country = gr.Dropdown(
|
| 268 |
+
choices=country_choices(), label="", show_label=False, visible=False,
|
| 269 |
allow_custom_value=True, filterable=True, elem_id="iv-country",
|
| 270 |
)
|
| 271 |
text = gr.Textbox(
|
| 272 |
+
label="", show_label=False, lines=3, visible=True, elem_id="iv-text",
|
| 273 |
+
placeholder="Type your answer here…",
|
| 274 |
)
|
| 275 |
+
cont = gr.Button("Continue →", elem_id="iv-continue")
|
|
|
|
|
|
|
| 276 |
|
| 277 |
stream_outputs = [chat, rail, radio, multi, country, text, session_st, loop_st, spec_st]
|
| 278 |
|
|
@@ -47,10 +47,10 @@ details.prep ul { margin:11px 0 0; padding:0; list-style:none; display:flex; fle
|
|
| 47 |
details.prep li { font-size:13.5px; color:var(--text-secondary); display:flex; gap:9px; line-height:1.45; }
|
| 48 |
details.prep li::before { content:""; flex:0 0 auto; width:6px; height:6px; border-radius:50%; background:var(--accent); margin-top:7px; }
|
| 49 |
|
| 50 |
-
.ccard-slot .btn-card-select button { width:100% !important; border-radius:var(--r-md) !important; font-weight:600 !important;
|
| 51 |
background:var(--surface) !important; color:var(--primary-deep) !important; border:1px solid var(--line-strong) !important; box-shadow:none !important; }
|
| 52 |
-
.ccard-slot .btn-card-select button:hover { border-color:var(--primary) !important; background:var(--primary-tint) !important; }
|
| 53 |
-
.ccard-slot.is-selected .btn-card-select button { background:var(--primary) !important; color:var(--on-primary) !important;
|
| 54 |
border-color:var(--primary) !important; box-shadow:0 2px 0 var(--primary-deep) !important; }
|
| 55 |
|
| 56 |
#reco-roadmap { margin-top:30px; background:var(--surface-2); border:1px solid var(--line); border-radius:var(--r-lg); padding:clamp(18px,4vw,26px); }
|
|
@@ -67,6 +67,11 @@ details.prep li::before { content:""; flex:0 0 auto; width:6px; height:6px; bord
|
|
| 67 |
.step__body p { font-size:14px; color:var(--text-secondary); margin:0 0 7px; }
|
| 68 |
.step__meta { display:flex; gap:8px; flex-wrap:wrap; }
|
| 69 |
.tag-s { font-size:12px; font-weight:500; color:var(--text-secondary); background:var(--surface); border:1px solid var(--line); padding:3px 10px; border-radius:var(--r-full); display:inline-flex; align-items:center; gap:6px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
"""
|
| 71 |
|
| 72 |
|
|
@@ -215,6 +220,7 @@ class RecommendationsUI:
|
|
| 215 |
render_outputs: list # [slot,card,btn]*MAX_CARDS + [roadmap]
|
| 216 |
populate: callable # (session) -> list of updates for render_outputs
|
| 217 |
session: gr.State
|
|
|
|
| 218 |
|
| 219 |
|
| 220 |
def build(visible: bool = False, session_st: gr.State | None = None) -> RecommendationsUI:
|
|
@@ -236,6 +242,8 @@ def build(visible: bool = False, session_st: gr.State | None = None) -> Recommen
|
|
| 236 |
cards.append(card)
|
| 237 |
btns.append(btn)
|
| 238 |
roadmap = gr.HTML(roadmap_html(None))
|
|
|
|
|
|
|
| 239 |
|
| 240 |
render_outputs = []
|
| 241 |
for i in range(MAX_CARDS):
|
|
@@ -282,6 +290,7 @@ def build(visible: bool = False, session_st: gr.State | None = None) -> Recommen
|
|
| 282 |
|
| 283 |
return RecommendationsUI(
|
| 284 |
column=column, render_outputs=render_outputs, populate=populate, session=session_st,
|
|
|
|
| 285 |
)
|
| 286 |
|
| 287 |
|
|
|
|
| 47 |
details.prep li { font-size:13.5px; color:var(--text-secondary); display:flex; gap:9px; line-height:1.45; }
|
| 48 |
details.prep li::before { content:""; flex:0 0 auto; width:6px; height:6px; border-radius:50%; background:var(--accent); margin-top:7px; }
|
| 49 |
|
| 50 |
+
.ccard-slot .btn-card-select, .ccard-slot .btn-card-select button { width:100% !important; border-radius:var(--r-md) !important; font-weight:600 !important;
|
| 51 |
background:var(--surface) !important; color:var(--primary-deep) !important; border:1px solid var(--line-strong) !important; box-shadow:none !important; }
|
| 52 |
+
.ccard-slot .btn-card-select:hover, .ccard-slot .btn-card-select button:hover { border-color:var(--primary) !important; background:var(--primary-tint) !important; }
|
| 53 |
+
.ccard-slot.is-selected .btn-card-select, .ccard-slot.is-selected .btn-card-select button { background:var(--primary) !important; color:var(--on-primary) !important;
|
| 54 |
border-color:var(--primary) !important; box-shadow:0 2px 0 var(--primary-deep) !important; }
|
| 55 |
|
| 56 |
#reco-roadmap { margin-top:30px; background:var(--surface-2); border:1px solid var(--line); border-radius:var(--r-lg); padding:clamp(18px,4vw,26px); }
|
|
|
|
| 67 |
.step__body p { font-size:14px; color:var(--text-secondary); margin:0 0 7px; }
|
| 68 |
.step__meta { display:flex; gap:8px; flex-wrap:wrap; }
|
| 69 |
.tag-s { font-size:12px; font-weight:500; color:var(--text-secondary); background:var(--surface); border:1px solid var(--line); padding:3px 10px; border-radius:var(--r-full); display:inline-flex; align-items:center; gap:6px; }
|
| 70 |
+
#reco-proceed-row { margin-top:24px; justify-content:center; }
|
| 71 |
+
#reco-proceed, #reco-proceed button { background:var(--accent) !important; color:var(--on-accent) !important;
|
| 72 |
+
box-shadow:0 2px 0 var(--accent-deep) !important; border:0 !important; font-weight:600 !important;
|
| 73 |
+
border-radius:var(--r-md) !important; padding:14px 26px !important; }
|
| 74 |
+
#reco-proceed:hover, #reco-proceed button:hover { background:var(--accent-deep) !important; }
|
| 75 |
"""
|
| 76 |
|
| 77 |
|
|
|
|
| 220 |
render_outputs: list # [slot,card,btn]*MAX_CARDS + [roadmap]
|
| 221 |
populate: callable # (session) -> list of updates for render_outputs
|
| 222 |
session: gr.State
|
| 223 |
+
proceed: gr.Button # "Prepare my documents" -> Phase 5
|
| 224 |
|
| 225 |
|
| 226 |
def build(visible: bool = False, session_st: gr.State | None = None) -> RecommendationsUI:
|
|
|
|
| 242 |
cards.append(card)
|
| 243 |
btns.append(btn)
|
| 244 |
roadmap = gr.HTML(roadmap_html(None))
|
| 245 |
+
with gr.Row(elem_id="reco-proceed-row"):
|
| 246 |
+
proceed = gr.Button("Prepare my documents →", elem_id="reco-proceed")
|
| 247 |
|
| 248 |
render_outputs = []
|
| 249 |
for i in range(MAX_CARDS):
|
|
|
|
| 290 |
|
| 291 |
return RecommendationsUI(
|
| 292 |
column=column, render_outputs=render_outputs, populate=populate, session=session_st,
|
| 293 |
+
proceed=proceed,
|
| 294 |
)
|
| 295 |
|
| 296 |
|
|
@@ -20,6 +20,7 @@ litellm==1.87.1 # latest 1.88.0
|
|
| 20 |
# Testing
|
| 21 |
pytest==9.0.2 # latest 9.0.3
|
| 22 |
pytest-asyncio==1.3.0 # latest 1.4.0
|
|
|
|
| 23 |
|
| 24 |
# E2E browser driver (Phase 6) — uncomment when Phase 6 begins
|
| 25 |
# playwright==1.55.0
|
|
|
|
| 20 |
# Testing
|
| 21 |
pytest==9.0.2 # latest 9.0.3
|
| 22 |
pytest-asyncio==1.3.0 # latest 1.4.0
|
| 23 |
+
pypdf==6.12.2 # latest 6.13.0 — PDF text extraction in document tests
|
| 24 |
|
| 25 |
# E2E browser driver (Phase 6) — uncomment when Phase 6 begins
|
| 26 |
# playwright==1.55.0
|
|
@@ -32,6 +32,7 @@ CSS_SOURCES = [
|
|
| 32 |
REPO_ROOT / "app" / "phases" / "interview.py",
|
| 33 |
REPO_ROOT / "app" / "phases" / "assessment.py",
|
| 34 |
REPO_ROOT / "app" / "phases" / "recommendations.py",
|
|
|
|
| 35 |
]
|
| 36 |
|
| 37 |
_ROOT_DEF_RE = re.compile(r"--([a-z0-9-]+):\s*([^;]+);")
|
|
|
|
| 32 |
REPO_ROOT / "app" / "phases" / "interview.py",
|
| 33 |
REPO_ROOT / "app" / "phases" / "assessment.py",
|
| 34 |
REPO_ROOT / "app" / "phases" / "recommendations.py",
|
| 35 |
+
REPO_ROOT / "app" / "phases" / "documents.py",
|
| 36 |
]
|
| 37 |
|
| 38 |
_ROOT_DEF_RE = re.compile(r"--([a-z0-9-]+):\s*([^;]+);")
|
|
@@ -1 +1,70 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 15 |
+
def _complete_session(origin="Ethiopia") -> SessionState:
|
| 16 |
+
s = 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"
|
| 32 |
+
return s
|
| 33 |
+
|
| 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 +1,49 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -43,10 +43,10 @@ def test_begin_without_language_raises():
|
|
| 43 |
|
| 44 |
def test_begin_with_language_transitions_to_intake():
|
| 45 |
s = SessionState()
|
| 46 |
-
select_language(s, "
|
| 47 |
assert begin_interview(s) is State.INTAKE
|
| 48 |
assert s.state is State.INTAKE
|
| 49 |
-
assert s.language == "
|
| 50 |
|
| 51 |
|
| 52 |
def test_toggle_selection_single_and_deselect():
|
|
|
|
| 43 |
|
| 44 |
def test_begin_with_language_transitions_to_intake():
|
| 45 |
s = SessionState()
|
| 46 |
+
select_language(s, "Español")
|
| 47 |
assert begin_interview(s) is State.INTAKE
|
| 48 |
assert s.state is State.INTAKE
|
| 49 |
+
assert s.language == "Spanish"
|
| 50 |
|
| 51 |
|
| 52 |
def test_toggle_selection_single_and_deselect():
|