| """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 |
| |
| |
| |
| try: |
| import matplotlib |
| 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 |
| |
| 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) |
|
|
| |
| |
| 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) |
| 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") |
|
|
| |
| 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) |
|
|
| |
| 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) |
| |
| |
| pdf.multi_cell(0, 5.5, f"- {_safe(text)}") |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|