"""Support Plan / clinician-handoff export. Renders a Markdown summary of a conversation that the student can save and optionally bring to a UMD CAPS counselor, ISSS advisor, ADS coordinator, or graduate Ombuds. The plan is the student's record, never auto-shared. Sections: * Header — timestamp, anonymized session id, scope disclaimer * What I'm working on — paraphrase of the user's first message + later messages * What I've tried — placeholder; user fills in by hand * What the navigator surfaced — routes/tiers/recommended action across turns * Resources mentioned — deduped list with URLs Tone is plain and stigma-free; never uses clinical labels and never claims to have diagnosed or treated anything. The deterministic planner already enforces this; the export simply preserves it. """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime from typing import Iterable @dataclass class TurnLogEntry: turn_index: int timestamp: str user_message: str route_label: str safety_tier: str conversation_stage: str recommended_action: str international_concern: bool intl_topic: str retrieved_sources: list[dict] _PRETTY_ROUTE = { "academic_setback": "Academic setback", "exam_stress": "Exam or test stress", "accessibility_ads": "Accessibility / accommodations", "advisor_conflict": "Advisor or graduate conflict", "counseling_navigation": "Counseling navigation", "basic_needs": "Basic needs", "care_violence_confidential": "Confidential CARE support", "peer_helper": "Helping someone else", "loneliness_isolation": "Loneliness or isolation", "anxiety_panic": "Anxiety or panic", "low_mood": "Low mood", "crisis_immediate": "Immediate safety handoff", "general_student_support": "General student support", "out_of_scope": "Outside support scope", } def build_support_plan_markdown( turn_log: Iterable[dict | TurnLogEntry], started_at: datetime | None = None, ) -> str: """Build the Markdown support-plan body. Accepts dicts (the demo's session-state shape) or TurnLogEntry objects. """ turns: list[dict] = [_normalize(t) for t in turn_log] if not turns: return _empty_plan(started_at) started = (started_at or datetime.utcnow()).strftime("%Y-%m-%d %H:%M UTC") user_msgs = [t["user_message"] for t in turns if t.get("user_message")] routes = _dedupe_in_order([t["route_label"] for t in turns if t.get("route_label")]) intl_topics = _dedupe_in_order([t["intl_topic"] for t in turns if t.get("intl_topic")]) actions = _dedupe_in_order([t["recommended_action"] for t in turns if t.get("recommended_action")]) resources = _dedupe_resources(turns) lines: list[str] = [] lines.append("# Support plan") lines.append("") lines.append(f"_Generated by EmpathRAG, a UMD support-navigation prototype._") lines.append(f"_Created: {started}._") lines.append("") lines.append("> This is your record. EmpathRAG is not therapy, diagnosis, or emergency care.") lines.append("> The navigator only points to UMD resources; it does not interpret policy on your behalf.") lines.append("") lines.append("## What I'm working on") lines.append("") if user_msgs: lines.append("In my own words:") lines.append("") for m in user_msgs: lines.append(f"- {m.strip()}") else: lines.append("_No messages recorded yet._") lines.append("") lines.append("## What I've tried") lines.append("") lines.append("- _Fill in by hand: things I've already attempted, who I've already talked to, what helped or didn't._") lines.append("") lines.append("## What the navigator surfaced") lines.append("") if routes: pretty = ", ".join(_PRETTY_ROUTE.get(r, r.replace("_", " ").title()) for r in routes) lines.append(f"- Topics identified: {pretty}") if intl_topics: pretty = ", ".join(t.replace("_", " ") for t in intl_topics) lines.append(f"- F-1 / international sub-topics: {pretty}") if actions: lines.append("- Suggested next steps:") for a in actions: lines.append(f" - {a.strip().rstrip('.')}") lines.append("") lines.append("## Resources mentioned") lines.append("") if resources: for r in resources: line = f"- **{r['source_name']}**" if r.get("title"): line += f" — {r['title']}" if r.get("url") and r["url"] not in {"", "N/A"}: line += f" ({r['url']})" lines.append(line) else: lines.append("_None surfaced yet — keep talking to see relevant resources._") lines.append("") lines.append("---") lines.append("") lines.append("_If you bring this to a counselor or ISSS advisor: feel free to paste their notes below._") lines.append("") return "\n".join(lines) def _locate_unicode_font() -> tuple[str | None, str | None]: """Return (regular_ttf, bold_ttf) paths for a Unicode-capable font, or (None, None) if we can't find one. We try DejaVu Sans (bundled with matplotlib in this venv), then DejaVu Sans installed system-wide, then Arial (Windows). Returns None paths if nothing usable is found; the PDF builder then degrades to the built-in Helvetica (latin-1 only).""" from pathlib import Path as _P import sys as _sys # matplotlib bundles DejaVu Sans in this venv; it covers Latin, Cyrillic, # Greek, IPA, common accented characters. Doesn't cover CJK but is # sufficient for the common case (accented Western names). try: import matplotlib # type: ignore mpl_fonts = _P(matplotlib.__file__).parent / "mpl-data" / "fonts" / "ttf" reg = mpl_fonts / "DejaVuSans.ttf" bold = mpl_fonts / "DejaVuSans-Bold.ttf" if reg.exists() and bold.exists(): return str(reg), str(bold) except Exception: pass # Windows fallback if _sys.platform.startswith("win"): win_fonts = _P("C:/Windows/Fonts") for reg_name, bold_name in [("arial.ttf", "arialbd.ttf"), ("calibri.ttf", "calibrib.ttf")]: reg = win_fonts / reg_name bold = win_fonts / bold_name if reg.exists() and bold.exists(): return str(reg), str(bold) return None, None def build_support_plan_pdf( turn_log: Iterable[dict | TurnLogEntry], out_path: str, started_at: datetime | None = None, ) -> str: """Render the support plan as a counselor-friendly PDF. Uses fpdf2 (small, pure-Python). Loads a Unicode-capable TTF (DejaVu Sans, bundled with matplotlib) so accented characters in student names — "José", "Müller", "李" — render correctly instead of being substituted with "?". Falls back to Helvetica + latin-1 encode if no Unicode font is available; that path still works for ASCII names but garbles accents. """ from fpdf import FPDF turns = [_normalize(t) for t in turn_log] started = (started_at or datetime.utcnow()).strftime("%Y-%m-%d %H:%M UTC") pdf = FPDF(orientation="P", unit="mm", format="A4") pdf.set_auto_page_break(auto=True, margin=18) pdf.add_page() pdf.set_margins(left=18, top=18, right=18) # Try to register a Unicode font. If it works, use "Body" / "Body-B" as # font names below. If it fails, fall back to built-in Helvetica. reg_path, bold_path = _locate_unicode_font() use_unicode = False if reg_path and bold_path: try: pdf.add_font("Body", "", reg_path, uni=True) pdf.add_font("Body", "B", bold_path, uni=True) pdf.add_font("Body", "I", reg_path, uni=True) # italic falls back to regular use_unicode = True except Exception: use_unicode = False family = "Body" if use_unicode else "Helvetica" def _safe(text: str) -> str: """If we're stuck with Helvetica (latin-1 only), strip characters outside latin-1 so fpdf2 doesn't crash. With the Unicode font, pass-through unchanged.""" if use_unicode: return text return text.encode("latin-1", errors="replace").decode("latin-1") # Title block pdf.set_font(family, "B", 18) pdf.set_text_color(15, 23, 42) pdf.cell(0, 9, "Support plan", ln=True) pdf.set_font(family, "", 10) pdf.set_text_color(90, 100, 110) pdf.cell(0, 5, "Generated by EmpathRAG, a UMD support-navigation prototype.", ln=True) pdf.cell(0, 5, f"Created: {started}.", ln=True) pdf.ln(4) # Scope disclaimer box pdf.set_fill_color(240, 250, 248) pdf.set_draw_color(94, 234, 212) pdf.set_text_color(20, 60, 50) pdf.set_font(family, "", 10) pdf.multi_cell( 0, 5, "This is your record. EmpathRAG is not therapy, diagnosis, or " "emergency care. The navigator only points to UMD resources; it does " "not interpret policy on your behalf.", border=1, fill=True, ) pdf.ln(4) pdf.set_text_color(15, 23, 42) def section(title: str) -> None: pdf.set_font(family, "B", 12) pdf.set_text_color(20, 130, 110) pdf.cell(0, 7, title, ln=True) pdf.set_font(family, "", 11) pdf.set_text_color(15, 23, 42) def bullet(text: str) -> None: pdf.set_x(22) # ASCII bullet (fpdf2's default helvetica covers ASCII reliably; we # strip any chars outside latin-1 to avoid encoding errors). pdf.multi_cell(0, 5.5, f"- {_safe(text)}") # What I'm working on section("What I'm working on") user_msgs = [t["user_message"] for t in turns if t.get("user_message")] if user_msgs: pdf.set_font(family, "", 11) pdf.cell(0, 5.5, "In my own words:", ln=True) pdf.ln(1) for m in user_msgs: bullet(m.strip()) else: pdf.set_font(family, "I", 11) pdf.set_text_color(120, 130, 140) pdf.cell(0, 5.5, "No messages recorded yet.", ln=True) pdf.set_text_color(15, 23, 42) pdf.ln(3) # What I've tried section("What I've tried") pdf.set_font(family, "I", 11) pdf.set_text_color(120, 130, 140) pdf.multi_cell( 0, 5.5, "Fill in by hand: things I've already attempted, who I've already " "talked to, what helped or didn't.", ) pdf.set_text_color(15, 23, 42) pdf.ln(3) # What the navigator surfaced section("What the navigator surfaced") routes = _dedupe_in_order([t["route_label"] for t in turns if t.get("route_label")]) intl_topics = _dedupe_in_order([t["intl_topic"] for t in turns if t.get("intl_topic")]) actions = _dedupe_in_order([t["recommended_action"] for t in turns if t.get("recommended_action")]) if routes: pretty = ", ".join(_PRETTY_ROUTE.get(r, r.replace("_", " ").title()) for r in routes) bullet(f"Topics identified: {pretty}") if intl_topics: pretty = ", ".join(t.replace("_", " ") for t in intl_topics) bullet(f"F-1 / international sub-topics: {pretty}") if actions: pdf.set_x(22) pdf.set_font(family, "", 11) pdf.cell(0, 5.5, "- Suggested next steps:", ln=True) for a in actions: pdf.set_x(28) pdf.multi_cell(0, 5.5, f" - {_safe(a.strip().rstrip('.'))}") pdf.ln(3) # Resources mentioned section("Resources mentioned") resources = _dedupe_resources(turns) if resources: for r in resources: line = r["source_name"] if r.get("title"): line += f" - {r['title']}" url = r.get("url") or "" if url and url not in {"", "N/A"}: line += f" ({url})" bullet(line) else: pdf.set_font(family, "I", 11) pdf.set_text_color(120, 130, 140) pdf.cell(0, 5.5, "None surfaced yet - keep talking to see relevant resources.", ln=True) pdf.set_text_color(15, 23, 42) pdf.ln(6) # Footer / counselor handoff line pdf.set_font(family, "I", 9) pdf.set_text_color(120, 130, 140) pdf.multi_cell( 0, 4.5, "If you bring this to a counselor or ISSS advisor: feel free to write " "their notes alongside.", ) pdf.output(out_path) return out_path def _empty_plan(started_at: datetime | None) -> str: started = (started_at or datetime.utcnow()).strftime("%Y-%m-%d %H:%M UTC") return ( "# Support plan\n\n" f"_Generated: {started}._\n\n" "_No conversation yet. Send a message first, then download._\n" ) def _normalize(t: dict | TurnLogEntry) -> dict: if isinstance(t, TurnLogEntry): return { "turn_index": t.turn_index, "timestamp": t.timestamp, "user_message": t.user_message, "route_label": t.route_label, "safety_tier": t.safety_tier, "conversation_stage": t.conversation_stage, "recommended_action": t.recommended_action, "international_concern": t.international_concern, "intl_topic": t.intl_topic, "retrieved_sources": t.retrieved_sources, } return dict(t) def _dedupe_in_order(items: list[str]) -> list[str]: seen: set[str] = set() out: list[str] = [] for x in items: if not x: continue if x in seen: continue seen.add(x) out.append(x) return out def _dedupe_resources(turns: list[dict]) -> list[dict]: """Collapse retrieved_sources across turns by (source_name, title).""" seen: set[tuple[str, str]] = set() out: list[dict] = [] for t in turns: for r in t.get("retrieved_sources", []) or []: key = (r.get("source_name", ""), r.get("title", "")) if not key[0]: continue if key in seen: continue seen.add(key) out.append(r) return out