EmpathRAG / src /pipeline /support_plan.py
MukulRay's picture
V4.4 Tier 1: regex hardening, clarify-stage enum, stat caveats, PDF unicode, retry/backoff, reproducibility doc
4f20fa7
Raw
History Blame
14.1 kB
"""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