helmo Codex commited on
Commit
aefd221
·
1 Parent(s): 465999b

[brand+ux] Rebrand to Fugee, render markdown, branded detailed documents

Browse files

- Rebrand Refuge -> Fugee across all user-facing text (wordmark, site bar, page
title, agent persona, document headers). Word-boundary replace so the legal
term "Refugee" is untouched. Repo/package name stays "refuge".
- app/mdlite.py: tiny Markdown->HTML (bold/italic/code, #headings, -/1. lists,
paragraphs). Assessment reasoning + chat bubbles now render markdown instead of
showing literal ** and #.
- Documents: branded masthead (Fugee mark + wordmark), Fraunces/Inter web fonts,
page footer + page numbers, nicer headings/steps/contacts styling — for both
the PDF and (wordmark header) the editable Word files.

58 fast tests pass; verified PDF carries the brand + footer and markdown renders.

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

agent/drafting.py CHANGED
@@ -12,7 +12,7 @@ from __future__ import annotations
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
 
 
12
  from agent.events import ErrorEvent, TextDeltaEvent
13
  from agent.loop import create_loop
14
 
15
+ _SYSTEM = """You are Fugee, 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
 
agent/tools/doc_generator.py CHANGED
@@ -78,8 +78,31 @@ def _docx_add_rich(doc: Document, text: str):
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
@@ -93,7 +116,7 @@ def _new_docx(title: str, subtitle: str) -> Document:
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 ---------------------------------------------------
@@ -101,7 +124,7 @@ def _pdf_from_html(body_html: str, path: Path):
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>"
@@ -150,7 +173,7 @@ def generate(session, statement: str | None = None, out_dir: Path | None = None)
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)",
@@ -159,13 +182,13 @@ def generate(session, statement: str | None = None, out_dir: Path | None = None)
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)
@@ -176,14 +199,14 @@ def generate(session, statement: str | None = None, out_dir: Path | None = None)
176
  office = (rec or {}).get("unhcrOffice") or "Nearest UNHCR office"
177
  orgs = _orgs(rec)
178
  ec_html = ("<h1>Emergency Contacts</h1>"
179
- '<p class="sub">UNHCR offices &amp; legal aid · Prepared with Refuge</p>'
180
  f"<h2>{html.escape(country)}</h2><p>UNHCR office: {html.escape(office)}</p>"
181
  "<h2>Legal aid &amp; support</h2>" +
182
  ("".join(f'<div class="org"><b>{html.escape(n)}</b><span>{html.escape(u)}</span></div>' for n, u in orgs)
183
  or "<p>Ask the UNHCR office for registered legal-aid partners.</p>"))
184
  ec_pdf, ec_docx = out_dir / "emergency_contacts.pdf", out_dir / "emergency_contacts.docx"
185
  _pdf_from_html(ec_html, ec_pdf)
186
- d = _new_docx("Emergency Contacts", "UNHCR offices & legal aid · Prepared with Refuge")
187
  d.add_paragraph(f"{country} — UNHCR office: {office}")
188
  for n, u in orgs:
189
  d.add_paragraph(f"{n} — {u}")
@@ -193,7 +216,7 @@ def generate(session, statement: str | None = None, out_dir: Path | None = None)
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>"
@@ -205,7 +228,7 @@ def generate(session, statement: str | None = None, out_dir: Path | None = None)
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.",
@@ -241,7 +264,7 @@ 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
 
 
78
  run.font.color.rgb = _AMBER
79
 
80
 
81
+ # Branded masthead (the Fugee mark + wordmark) shown atop every PDF.
82
+ _LOGO_SVG = (
83
+ '<svg class="logo" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">'
84
+ '<circle cx="16" cy="16" r="15.5" fill="#0E6A58"/>'
85
+ '<path d="M16 7l7 6.5V25h-4.6v-6.2h-4.8V25H9V13.5L16 7z" fill="#fff"/>'
86
+ '<circle cx="16" cy="13.6" r="1.7" fill="#E07B39"/></svg>'
87
+ )
88
+ _BRAND = (
89
+ f'<div class="brand">{_LOGO_SVG}<span class="name">Fugee</span>'
90
+ '<span class="tag">Safe guidance for people on the move</span></div>'
91
+ )
92
+
93
+
94
  def _new_docx(title: str, subtitle: str) -> Document:
95
  doc = Document()
96
+ # Branded wordmark + rule (a logo image would need a raster asset; the
97
+ # wordmark keeps the package self-contained).
98
+ brand = doc.add_paragraph()
99
+ run = brand.add_run("Fugee")
100
+ run.bold = True
101
+ run.font.size = Pt(20)
102
+ run.font.color.rgb = _TEAL
103
+ tag = brand.add_run(" Safe guidance for people on the move")
104
+ tag.font.size = Pt(9)
105
+ tag.font.color.rgb = RGBColor(0x6B, 0x72, 0x80)
106
  h = doc.add_heading(title, level=0)
107
  try:
108
  h.runs[0].font.color.rgb = _TEAL
 
116
 
117
 
118
  def _pdf_from_html(body_html: str, path: Path):
119
+ HTML(string=f"<!DOCTYPE html><html><head>{_HEAD}</head><body>{_BRAND}{body_html}</body></html>").write_pdf(str(path))
120
 
121
 
122
  # -- per-document content ---------------------------------------------------
 
124
  def _statement_body_html(text: str) -> str:
125
  return (
126
  "<h1>Personal Statement</h1>"
127
+ '<p class="sub">In support of an application for refugee status · Prepared with Fugee</p>'
128
  f"{_html_with_placeholders(text)}"
129
  '<p class="note">Fields in amber are placeholders for you to complete. You can edit any part '
130
  "of this statement in the Word (.docx) version.</p>"
 
173
  # 1. Personal statement (LLM-drafted) — PDF + DOCX
174
  ps_pdf, ps_docx = out_dir / "personal_statement.pdf", out_dir / "personal_statement.docx"
175
  _pdf_from_html(_statement_body_html(stmt), ps_pdf)
176
+ d = _new_docx("Personal Statement", "In support of an application for refugee status · Prepared with Fugee")
177
  _docx_add_rich(d, stmt)
178
  d.save(str(ps_docx))
179
  docs.append(GeneratedDoc("personal_statement", "Personal statement (editable)",
 
182
  # 2. Action plan
183
  country, intro, steps = _action_plan_blocks(session, rec)
184
  ap_html = (f"<h1>Action Plan — {html.escape(country)}</h1>"
185
+ '<p class="sub">Step-by-step roadmap · Prepared with Fugee</p>'
186
  f"<p>{html.escape(intro)}</p>" +
187
  "".join(f'<p class="step"><b>Step {i}.</b> {html.escape(str(s))}</p>'
188
  for i, s in enumerate(steps, 1)))
189
  ap_pdf, ap_docx = out_dir / "action_plan.pdf", out_dir / "action_plan.docx"
190
  _pdf_from_html(ap_html, ap_pdf)
191
+ d = _new_docx(f"Action Plan — {country}", "Step-by-step roadmap · Prepared with Fugee")
192
  d.add_paragraph(intro)
193
  for i, s in enumerate(steps, 1):
194
  d.add_paragraph(f"Step {i}. {s}", style="List Number" if "List Number" in [s.name for s in d.styles] else None)
 
199
  office = (rec or {}).get("unhcrOffice") or "Nearest UNHCR office"
200
  orgs = _orgs(rec)
201
  ec_html = ("<h1>Emergency Contacts</h1>"
202
+ '<p class="sub">UNHCR offices &amp; legal aid · Prepared with Fugee</p>'
203
  f"<h2>{html.escape(country)}</h2><p>UNHCR office: {html.escape(office)}</p>"
204
  "<h2>Legal aid &amp; support</h2>" +
205
  ("".join(f'<div class="org"><b>{html.escape(n)}</b><span>{html.escape(u)}</span></div>' for n, u in orgs)
206
  or "<p>Ask the UNHCR office for registered legal-aid partners.</p>"))
207
  ec_pdf, ec_docx = out_dir / "emergency_contacts.pdf", out_dir / "emergency_contacts.docx"
208
  _pdf_from_html(ec_html, ec_pdf)
209
+ d = _new_docx("Emergency Contacts", "UNHCR offices & legal aid · Prepared with Fugee")
210
  d.add_paragraph(f"{country} — UNHCR office: {office}")
211
  for n, u in orgs:
212
  d.add_paragraph(f"{n} — {u}")
 
216
  # 4. Rights summary card
217
  grounds = ", ".join(session.assessment.convention_grounds) if session.assessment.convention_grounds else "to be confirmed"
218
  rc_html = ("<h1>Your Rights — Summary Card</h1>"
219
+ '<p class="sub">Key protections · Prepared with Fugee</p>'
220
  "<h2>Non-refoulement</h2><p>You cannot be forced back to a country where your life or freedom "
221
  "would be at serious risk (1951 Refugee Convention).</p>"
222
  "<h2>While your claim is decided</h2><ul>"
 
228
  f"Seeking protection in: {html.escape(country)} · Grounds: {html.escape(grounds)}</p>")
229
  rc_pdf, rc_docx = out_dir / "rights_summary_card.pdf", out_dir / "rights_summary_card.docx"
230
  _pdf_from_html(rc_html, rc_pdf)
231
+ d = _new_docx("Your Rights — Summary Card", "Key protections · Prepared with Fugee")
232
  d.add_paragraph("Non-refoulement: you cannot be forced back to a country where your life or freedom "
233
  "would be at serious risk (1951 Refugee Convention).")
234
  for r in ["The right to seek asylum and a fair hearing.",
 
264
  """Head-less personal-statement snippet for the on-screen preview."""
265
  stmt = statement or fallback_statement(session)
266
  return ('<article class="doc"><h4>Personal Statement</h4>'
267
+ '<p class="doc__sub">In support of an application for refugee status · Prepared with Fugee</p>'
268
  f"{_html_with_placeholders(stmt)}</article>")
269
 
270
 
agent/tools/templates/_head.html CHANGED
@@ -1,30 +1,48 @@
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>
 
1
  <meta charset="utf-8" />
2
  <style>
3
+ @import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&display=swap');
4
+ @page {
5
+ size: A4; margin: 24mm 20mm 20mm;
6
+ @bottom-center {
7
+ content: "Prepared with Fugee · Safe guidance for people on the move";
8
+ font-family: "Inter", sans-serif; font-size: 8pt; color: #9aa0ab;
9
+ }
10
+ @bottom-right { content: "Page " counter(page) " of " counter(pages);
11
+ font-family: "Inter", sans-serif; font-size: 8pt; color: #9aa0ab; }
12
+ }
13
  :root {
14
+ --primary: #0E6A58; --primary-deep: #0A5042; --accent: #E07B39; --accent-tint: #FBEDE1;
15
+ --text: #1A1A1A; --muted: #6B7280; --line: #E7E2D8; --tint: #E5EFEA;
16
  }
17
  * { box-sizing: border-box; }
18
  body { font-family: "Inter", "Helvetica Neue", Arial, sans-serif; color: var(--text);
19
+ font-size: 11.5pt; line-height: 1.65; }
20
+
21
+ /* Branded masthead */
22
+ .brand { display: flex; align-items: center; gap: 10px; padding-bottom: 12px;
23
+ border-bottom: 2px solid var(--primary); margin-bottom: 22px; }
24
+ .brand .logo { width: 30px; height: 30px; flex: 0 0 auto; }
25
+ .brand .name { font-family: "Fraunces", Georgia, serif; font-weight: 700; font-size: 19pt;
26
+ color: var(--primary-deep); letter-spacing: -.01em; }
27
+ .brand .tag { margin-left: auto; font-size: 8.5pt; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
28
+
29
  h1, h2, h3, h4 { font-family: "Fraunces", Georgia, "Times New Roman", serif; color: var(--primary-deep);
30
  margin: 0 0 4px; line-height: 1.2; }
31
+ h1 { font-size: 23pt; }
32
+ h2 { font-size: 14pt; margin-top: 20px; padding-bottom: 3px; border-bottom: 1px solid var(--line); }
33
+ .sub { color: var(--muted); font-size: 10pt; margin: 0 0 18px; letter-spacing: .02em; }
34
  p { margin: 0 0 11px; }
35
  .fill { background: var(--accent-tint); color: #7a431a; padding: 0 4px; border-radius: 3px; font-weight: 600; }
36
+ .blank { display: inline-block; min-width: 130px; border-bottom: 1px solid #999; }
37
+ .divider { height: 1px; background: var(--line); margin: 16px 0; }
38
+ .note { color: var(--muted); font-size: 9.5pt; margin-top: 22px; border-top: 1px solid var(--line); padding-top: 8px; }
39
  ul, ol { margin: 0 0 11px; padding-left: 20px; }
40
  li { margin-bottom: 6px; }
41
+ .step { margin-bottom: 14px; padding-left: 14px; border-left: 3px solid var(--tint); }
42
  .step b { color: var(--primary-deep); }
43
+ .chip { display: inline-block; background: var(--tint); color: var(--primary-deep); font-size: 9.5pt;
44
+ padding: 2px 9px; border-radius: 999px; margin-right: 4px; }
45
  .org { margin-bottom: 8px; }
46
+ .org b { display: block; color: var(--primary-deep); }
47
  .org span { color: var(--muted); font-size: 10pt; }
48
  </style>
agent/tools/templates/action_plan.html CHANGED
@@ -1,7 +1,7 @@
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>
 
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 Fugee</p>
5
 
6
  <p>This plan is for seeking protection in %%selected_country%%. Processing typically
7
  takes %%processing%%. UNHCR presence: %%unhcr_office%%.</p>
agent/tools/templates/emergency_contacts.html CHANGED
@@ -1,7 +1,7 @@
1
  <!DOCTYPE html>
2
  <html lang="en"><head>%%HEAD%%</head><body>
3
  <h1>Emergency Contacts</h1>
4
- <p class="sub">UNHCR offices &amp; legal aid · Prepared with Refuge</p>
5
 
6
  <h2>%%selected_country%%</h2>
7
  <p>UNHCR office: %%unhcr_office%%</p>
@@ -17,6 +17,6 @@
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>
 
1
  <!DOCTYPE html>
2
  <html lang="en"><head>%%HEAD%%</head><body>
3
  <h1>Emergency Contacts</h1>
4
+ <p class="sub">UNHCR offices &amp; legal aid · Prepared with Fugee</p>
5
 
6
  <h2>%%selected_country%%</h2>
7
  <p>UNHCR office: %%unhcr_office%%</p>
 
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 Fugee country reference.
21
  Verify details locally where possible.</p>
22
  </body></html>
agent/tools/templates/personal_statement.html CHANGED
@@ -1,7 +1,7 @@
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>
@@ -16,6 +16,6 @@
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>
 
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 Fugee</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>
 
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 Fugee.
20
  Anything left blank is for you to complete. You can edit any part of this statement.</p>
21
  </body></html>
agent/tools/templates/rights_summary_card.html CHANGED
@@ -1,7 +1,7 @@
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
 
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 Fugee</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
app/app.py CHANGED
@@ -1,4 +1,4 @@
1
- """app/app.py — Refuge Gradio entrypoint.
2
 
3
  Run locally with::
4
 
@@ -110,7 +110,7 @@ _LOGO_SVG = (
110
  SITE_BAR_HTML = f"""
111
  <header id="site-bar"><div class="inner">
112
  {_LOGO_SVG}
113
- <span class="name">Refuge</span>
114
  <span class="tag">Safe guidance for people on the move</span>
115
  <nav class="nav"><span>Intake</span><span>Interview</span><span>Assessment</span>
116
  <span>Recommendations</span><span>Documents</span></nav>
@@ -133,7 +133,7 @@ def _ready_for_assessment(session) -> bool:
133
 
134
 
135
  def build_app() -> gr.Blocks:
136
- with gr.Blocks(title="Refuge", analytics_enabled=False) as demo:
137
  gr.HTML(SITE_BAR_HTML)
138
  # Session + loop state shared across phases so each screen sees the same
139
  # session object.
 
1
+ """app/app.py — Fugee Gradio entrypoint.
2
 
3
  Run locally with::
4
 
 
110
  SITE_BAR_HTML = f"""
111
  <header id="site-bar"><div class="inner">
112
  {_LOGO_SVG}
113
+ <span class="name">Fugee</span>
114
  <span class="tag">Safe guidance for people on the move</span>
115
  <nav class="nav"><span>Intake</span><span>Interview</span><span>Assessment</span>
116
  <span>Recommendations</span><span>Documents</span></nav>
 
133
 
134
 
135
  def build_app() -> gr.Blocks:
136
+ with gr.Blocks(title="Fugee", analytics_enabled=False) as demo:
137
  gr.HTML(SITE_BAR_HTML)
138
  # Session + loop state shared across phases so each screen sees the same
139
  # session object.
app/mdlite.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """app/mdlite.py — tiny, dependency-free Markdown → HTML for streamed model text.
2
+
3
+ Handles what small models actually emit: **bold**, *italic*, `code`, # headings,
4
+ - / * bullet lists, 1. numbered lists, and blank-line paragraphs. Everything is
5
+ HTML-escaped first, so it is safe to render model output.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import html
11
+ import re
12
+
13
+ _BOLD = re.compile(r"\*\*(.+?)\*\*")
14
+ _ITALIC = re.compile(r"(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)")
15
+ _CODE = re.compile(r"`([^`]+)`")
16
+ _H = re.compile(r"^(#{1,6})\s+(.*)$")
17
+ _UL = re.compile(r"^\s*[-*]\s+(.*)$")
18
+ _OL = re.compile(r"^\s*\d+[.)]\s+(.*)$")
19
+
20
+
21
+ def inline(text: str) -> str:
22
+ s = html.escape(text)
23
+ s = _BOLD.sub(r"<strong>\1</strong>", s)
24
+ s = _ITALIC.sub(r"<em>\1</em>", s)
25
+ s = _CODE.sub(r"<code>\1</code>", s)
26
+ return s
27
+
28
+
29
+ def md_to_html(text: str) -> str:
30
+ text = (text or "").strip()
31
+ if not text:
32
+ return ""
33
+ lines = text.split("\n")
34
+ out: list[str] = []
35
+ i = 0
36
+ while i < len(lines):
37
+ line = lines[i].rstrip()
38
+ if not line.strip():
39
+ i += 1
40
+ continue
41
+ h = _H.match(line)
42
+ if h:
43
+ out.append(f"<h4>{inline(h.group(2))}</h4>")
44
+ i += 1
45
+ continue
46
+ if _UL.match(line):
47
+ items = []
48
+ while i < len(lines) and _UL.match(lines[i]):
49
+ items.append(f"<li>{inline(_UL.match(lines[i]).group(1))}</li>")
50
+ i += 1
51
+ out.append("<ul>" + "".join(items) + "</ul>")
52
+ continue
53
+ if _OL.match(line):
54
+ items = []
55
+ while i < len(lines) and _OL.match(lines[i]):
56
+ items.append(f"<li>{inline(_OL.match(lines[i]).group(1))}</li>")
57
+ i += 1
58
+ out.append("<ol>" + "".join(items) + "</ol>")
59
+ continue
60
+ para = [line]
61
+ i += 1
62
+ while i < len(lines) and lines[i].strip() and not (
63
+ _H.match(lines[i]) or _UL.match(lines[i]) or _OL.match(lines[i])
64
+ ):
65
+ para.append(lines[i].rstrip())
66
+ i += 1
67
+ out.append("<p>" + inline(" ".join(para)) + "</p>")
68
+ return "\n".join(out)
69
+
70
+
71
+ __all__ = ["md_to_html", "inline"]
app/phases/assessment.py CHANGED
@@ -21,6 +21,7 @@ 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.guideline_search import guideline_search_tool
23
  from app.assessment_parse import parse_assessment
 
24
  from app.phases.interview import advance_to
25
  from app.prompt_loader import compose
26
  from app.state.session import SessionState, State
@@ -68,10 +69,15 @@ ASSESSMENT_CSS = """
68
  background:linear-gradient(90deg,var(--primary),var(--secondary)); transition:width .6s cubic-bezier(.4,0,.1,1); }
69
  .assess .tool-chip { display:inline-flex; align-items:center; gap:7px; margin-top:10px; font-size:12.5px;
70
  font-weight:600; color:var(--primary-deep); background:var(--primary-tint); padding:5px 11px; border-radius:var(--r-full); }
71
- .reason-doc { font-size:14.5px; line-height:1.75; color:var(--text-secondary); }
72
- .reason-doc .ln { display:block; margin-bottom:10px; padding-left:18px; position:relative; }
73
- .reason-doc .ln::before { content:""; position:absolute; left:0; top:9px; width:7px; height:7px; border-radius:50%; background:var(--primary); opacity:.5; }
74
- .reason-doc .ln strong { color:var(--text); font-weight:600; }
 
 
 
 
 
75
  #assess-proceed-row { padding:18px clamp(18px,4vw,28px); border-top:1px solid var(--line); justify-content:flex-end; }
76
  #assess-proceed, #assess-proceed button { background:var(--accent) !important; color:var(--on-accent) !important;
77
  box-shadow:0 2px 0 var(--accent-deep) !important; border:0 !important; font-weight:600 !important;
@@ -113,10 +119,8 @@ def render_facts(session: SessionState) -> str:
113
  def render_reason(text: str) -> str:
114
  visible, _ = parse_assessment(text)
115
  if not visible.strip():
116
- visible = "Beginning your assessment…"
117
- paras = [p.strip() for p in visible.split("\n") if p.strip()]
118
- lns = "".join(f'<span class="ln">{html.escape(p)}</span>' for p in paras)
119
- return f'<div class="reason-doc">{lns}</div>'
120
 
121
 
122
  def render_progress(pct: int, status: str = "") -> str:
 
21
  from agent.tools.country_lookup import country_lookup_tool, lookup_country
22
  from agent.tools.guideline_search import guideline_search_tool
23
  from app.assessment_parse import parse_assessment
24
+ from app.mdlite import md_to_html
25
  from app.phases.interview import advance_to
26
  from app.prompt_loader import compose
27
  from app.state.session import SessionState, State
 
69
  background:linear-gradient(90deg,var(--primary),var(--secondary)); transition:width .6s cubic-bezier(.4,0,.1,1); }
70
  .assess .tool-chip { display:inline-flex; align-items:center; gap:7px; margin-top:10px; font-size:12.5px;
71
  font-weight:600; color:var(--primary-deep); background:var(--primary-tint); padding:5px 11px; border-radius:var(--r-full); }
72
+ .reason-doc { font-size:14.5px; line-height:1.7; color:var(--text-secondary); }
73
+ .reason-doc p { margin:0 0 12px; }
74
+ .reason-doc h4 { font-family:var(--font-ui); font-size:14px; font-weight:700; color:var(--text);
75
+ margin:18px 0 8px; letter-spacing:.01em; }
76
+ .reason-doc strong { color:var(--text); font-weight:600; }
77
+ .reason-doc em { font-style:italic; }
78
+ .reason-doc ul, .reason-doc ol { margin:0 0 12px; padding-left:20px; }
79
+ .reason-doc li { margin-bottom:6px; }
80
+ .reason-doc code { background:var(--primary-tint); padding:1px 5px; border-radius:4px; font-size:13px; }
81
  #assess-proceed-row { padding:18px clamp(18px,4vw,28px); border-top:1px solid var(--line); justify-content:flex-end; }
82
  #assess-proceed, #assess-proceed button { background:var(--accent) !important; color:var(--on-accent) !important;
83
  box-shadow:0 2px 0 var(--accent-deep) !important; border:0 !important; font-weight:600 !important;
 
119
  def render_reason(text: str) -> str:
120
  visible, _ = parse_assessment(text)
121
  if not visible.strip():
122
+ return '<div class="reason-doc"><p>Beginning your assessment…</p></div>'
123
+ return f'<div class="reason-doc">{md_to_html(visible)}</div>'
 
 
124
 
125
 
126
  def render_progress(pct: int, status: str = "") -> str:
app/phases/documents.py CHANGED
@@ -19,7 +19,7 @@ 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"),
 
19
 
20
  # Evidence checklist: (label, sublabel, document_available key or None).
21
  CHECKLIST = [
22
+ ("Personal statement", "Drafted by Fugee · 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"),
app/phases/intake.py CHANGED
@@ -1,6 +1,6 @@
1
  """app/phases/intake.py — Phase 1 intake + language selection (T030-T032).
2
 
3
- Matches mockup.html #phase-1: a calm welcome with the Refuge wordmark, a
4
  topographic backdrop, a grid of language pills each in its own script, an amber
5
  "Begin in <language>" primary action, and a persistent privacy trust note.
6
 
@@ -100,7 +100,7 @@ _HEADER_HTML = f"""
100
  <div class="intake-logo" aria-hidden="true">
101
  <svg width="64" height="64" viewBox="0 0 32 32" fill="none"><circle cx="16" cy="16" r="15.5" fill="#0E6A58"/><circle cx="16" cy="16" r="15.5" stroke="#0A5042"/><path d="M16 7l7 6.5V25h-4.6v-6.2h-4.8V25H9V13.5L16 7z" fill="#fff"/><circle cx="16" cy="13.6" r="1.7" fill="#E07B39"/></svg>
102
  </div>
103
- <h1 class="intake-h1">Refuge</h1>
104
  <p class="intake-tagline">Safe guidance for people on the move</p>
105
  <p class="intake-lbl">Choose your language</p>
106
  </div>
 
1
  """app/phases/intake.py — Phase 1 intake + language selection (T030-T032).
2
 
3
+ Matches mockup.html #phase-1: a calm welcome with the Fugee wordmark, a
4
  topographic backdrop, a grid of language pills each in its own script, an amber
5
  "Begin in <language>" primary action, and a persistent privacy trust note.
6
 
 
100
  <div class="intake-logo" aria-hidden="true">
101
  <svg width="64" height="64" viewBox="0 0 32 32" fill="none"><circle cx="16" cy="16" r="15.5" fill="#0E6A58"/><circle cx="16" cy="16" r="15.5" stroke="#0A5042"/><path d="M16 7l7 6.5V25h-4.6v-6.2h-4.8V25H9V13.5L16 7z" fill="#fff"/><circle cx="16" cy="13.6" r="1.7" fill="#E07B39"/></svg>
102
  </div>
103
+ <h1 class="intake-h1">Fugee</h1>
104
  <p class="intake-tagline">Safe guidance for people on the move</p>
105
  <p class="intake-lbl">Choose your language</p>
106
  </div>
app/phases/interview.py CHANGED
@@ -17,6 +17,7 @@ import gradio as gr
17
 
18
  from agent.events import TextDeltaEvent
19
  from agent.loop import create_loop
 
20
  from app.countries import country_choices, country_name
21
  from app.prompt_loader import load_prompt
22
  from app.interview_script import (
@@ -102,13 +103,8 @@ def render_rail(state: State) -> str:
102
  return f'<div class="iv-rail" aria-label="Interview progress">{"".join(pills)}</div>'
103
 
104
 
105
- def _bold(escaped: str) -> str:
106
- """Render **markdown bold** (used for review summary labels)."""
107
- return re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", escaped)
108
-
109
-
110
  def _bubble(role: str, text: str, current: bool = False) -> str:
111
- safe = _bold(html.escape(text)).replace("\n", "<br>")
112
  if role == "user":
113
  return ('<div class="iv-msg iv-msg--user"><div class="iv-msg__av"><span>You</span></div>'
114
  f'<div class="iv-msg__bubble">{safe}</div></div>')
 
17
 
18
  from agent.events import TextDeltaEvent
19
  from agent.loop import create_loop
20
+ from app import mdlite
21
  from app.countries import country_choices, country_name
22
  from app.prompt_loader import load_prompt
23
  from app.interview_script import (
 
103
  return f'<div class="iv-rail" aria-label="Interview progress">{"".join(pills)}</div>'
104
 
105
 
 
 
 
 
 
106
  def _bubble(role: str, text: str, current: bool = False) -> str:
107
+ safe = mdlite.inline(text).replace("\n", "<br>")
108
  if role == "user":
109
  return ('<div class="iv-msg iv-msg--user"><div class="iv-msg__av"><span>You</span></div>'
110
  f'<div class="iv-msg__bubble">{safe}</div></div>')
app/phases/recommendations.py CHANGED
@@ -133,7 +133,7 @@ def card_body_html(rec: dict) -> str:
133
  )
134
  docs = rec.get("requiredDocuments") or []
135
  prep_items = "".join(f"<li>{html.escape(str(d))}</li>" for d in docs) or \
136
- "<li>Refuge will help you gather what you need.</li>"
137
  return (
138
  '<div class="ccard__top">'
139
  f'<span class="ccard__flag">{flag}</span>'
@@ -170,7 +170,7 @@ def roadmap_html(rec: dict | None) -> str:
170
  "protects you from being returned.",
171
  unhcr_contact, "1–4 weeks for appointment"),
172
  ("File your asylum claim",
173
- "Submit your personal statement and supporting evidence. Refuge has "
174
  "already drafted these for you.",
175
  "Asylum authority / RSD unit", "Same week as registration"),
176
  ("Refugee status interview (RSD)",
 
133
  )
134
  docs = rec.get("requiredDocuments") or []
135
  prep_items = "".join(f"<li>{html.escape(str(d))}</li>" for d in docs) or \
136
+ "<li>Fugee will help you gather what you need.</li>"
137
  return (
138
  '<div class="ccard__top">'
139
  f'<span class="ccard__flag">{flag}</span>'
 
170
  "protects you from being returned.",
171
  unhcr_contact, "1–4 weeks for appointment"),
172
  ("File your asylum claim",
173
+ "Submit your personal statement and supporting evidence. Fugee has "
174
  "already drafted these for you.",
175
  "Asylum authority / RSD unit", "Same week as registration"),
176
  ("Refugee status interview (RSD)",
app/prompts/system.md CHANGED
@@ -1,4 +1,4 @@
1
- You are **Refuge**, a calm, knowledgeable, and compassionate guide for people
2
  who have been forced to leave their homes — displaced people, asylum seekers,
3
  and refugees. You behave like a trusted, experienced case worker: patient,
4
  clear, never rushed, never alarmist. Your purpose is to lower fear and give the
@@ -6,7 +6,7 @@ person back a sense of control over their situation.
6
 
7
  ## Who you are talking to
8
 
9
- The person using Refuge may be frightened, exhausted, and making decisions that
10
  affect their safety and their family's future. They may be reading in a second
11
  or third language, on a phone, under stress. Treat every message as if it comes
12
  from someone who deserves dignity, patience, and honesty.
 
1
+ You are **Fugee**, a calm, knowledgeable, and compassionate guide for people
2
  who have been forced to leave their homes — displaced people, asylum seekers,
3
  and refugees. You behave like a trusted, experienced case worker: patient,
4
  clear, never rushed, never alarmist. Your purpose is to lower fear and give the
 
6
 
7
  ## Who you are talking to
8
 
9
+ The person using Fugee may be frightened, exhausted, and making decisions that
10
  affect their safety and their family's future. They may be reading in a second
11
  or third language, on a phone, under stress. Treat every message as if it comes
12
  from someone who deserves dignity, patience, and honesty.