| """ |
| Output-side guardrails for V2.5. |
| |
| The goal is not to prove clinical correctness. The guard catches obvious demo |
| and safety failures: pure validation, dependency language, ungrounded contact |
| claims, unsafe agreement, and crisis responses that keep coaching normally. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| import re |
|
|
|
|
| @dataclass(frozen=True) |
| class OutputGuardResult: |
| allowed: bool |
| reason: str |
| flags: list[str] |
| fallback_required: bool |
| corrected_response: str | None = None |
|
|
|
|
| NEXT_ACTION_MARKERS = ( |
| "next step", |
| "next action", |
| "best next", |
| "recommended", |
| "contact", |
| "call", |
| "text", |
| "office hours", |
| "office-hours", |
| "email", |
| "use ", |
| "route", |
| "send ", |
| "take ", |
| "pick ", |
| "start ", |
| "write ", |
| "ask ", |
| "tell ", |
| "choose ", |
| "make ", |
| "decide ", |
| "identify ", |
| "do one", |
| "do a", |
| "stay ", |
| "involve ", |
| "try ", |
| "keep it", |
| "talk ", |
| "talk to", |
| "schedule ", |
| "review ", |
| "bring ", |
| "file ", |
| ) |
|
|
| DEPENDENCY_PATTERNS = ( |
| r"\bi am always here\b", |
| r"\bi will never leave\b", |
| r"\byou only need me\b", |
| r"\bi understand you better than anyone\b", |
| ) |
|
|
| HARMFUL_AGREEMENT_PATTERNS = ( |
| r"\byou are right[, ]+no one can help\b", |
| r"\byou are worthless\b", |
| r"\byour future is over\b", |
| r"\beveryone would be better off\b", |
| r"\bkeep this secret\b", |
| r"\bdon'?t tell anyone\b", |
| r"\bquit immediately\b", |
| r"\bdrop out right now\b", |
| ) |
|
|
| NORMAL_COACHING_IN_CRISIS = ( |
| "study plan", |
| "office hours", |
| "professor", |
| "time management", |
| "productivity", |
| ) |
|
|
|
|
| def validate_output( |
| response: str, |
| retrieved_sources: list[dict], |
| safety_tier: str, |
| route: str, |
| conversation_history: list[dict] | list[str] | None = None, |
| ) -> OutputGuardResult: |
| text = response.lower() |
| flags: list[str] = [] |
|
|
| if safety_tier == "imminent_safety" and any(marker in text for marker in NORMAL_COACHING_IN_CRISIS): |
| flags.append("normal_coaching_in_crisis") |
|
|
| if safety_tier != "imminent_safety" and not any(marker in text for marker in NEXT_ACTION_MARKERS): |
| flags.append("missing_recommended_next_action") |
|
|
| if _looks_like_pure_validation(text): |
| flags.append("pure_validation_no_redirect") |
|
|
| if any(re.search(pattern, text) for pattern in DEPENDENCY_PATTERNS): |
| flags.append("dependency_reinforcement") |
|
|
| if any(re.search(pattern, text) for pattern in HARMFUL_AGREEMENT_PATTERNS): |
| flags.append("harmful_or_sycophantic_agreement") |
|
|
| if _self_degrading_compliance(text): |
| flags.append("self_degrading_compliance") |
|
|
| if _has_ungrounded_contact_claim(response, retrieved_sources): |
| flags.append("ungrounded_contact_claim") |
|
|
| if _has_unsupported_resource_recommendation(response, retrieved_sources): |
| flags.append("unsupported_resource_recommendation") |
|
|
| if flags: |
| return OutputGuardResult( |
| allowed=False, |
| reason=";".join(flags), |
| flags=flags, |
| fallback_required=True, |
| corrected_response=_fallback_response(safety_tier, route), |
| ) |
|
|
| return OutputGuardResult( |
| allowed=True, |
| reason="passed_output_guard", |
| flags=[], |
| fallback_required=False, |
| ) |
|
|
|
|
| def _looks_like_pure_validation(text: str) -> bool: |
| validation_words = ("that sounds", "makes sense", "i hear", "valid", "understandable", "painful") |
| has_validation = any(word in text for word in validation_words) |
| has_action = any(marker in text for marker in NEXT_ACTION_MARKERS) |
| return has_validation and not has_action |
|
|
|
|
| def _self_degrading_compliance(text: str) -> bool: |
| return bool(re.search(r"\byou are (worthless|a failure|pathetic|stupid|unlovable)\b", text)) |
|
|
|
|
| def _has_ungrounded_contact_claim(response: str, retrieved_sources: list[dict]) -> bool: |
| phone_like = re.findall(r"\b(?:\+?1[-. ]?)?\(?\d{3}\)?[-. ]?\d{3}[-. ]?\d{4}\b|\b\d{3}\b", response) |
| if not phone_like: |
| return False |
| grounded_blob = " ".join( |
| str(value) |
| for source in retrieved_sources |
| for value in source.values() |
| if value is not None |
| ) |
| for claim in phone_like: |
| if claim == "988": |
| continue |
| if claim not in grounded_blob: |
| return True |
| return False |
|
|
|
|
| def _has_unsupported_resource_recommendation(response: str, retrieved_sources: list[dict]) -> bool: |
| text = response.lower() |
| if "will not invent" in text or "not invent" in text: |
| return False |
| if "source-grounded option:" not in text and "grounded support:" not in text: |
| return False |
| known_names = { |
| str(source.get("source_name", "")).lower() |
| for source in retrieved_sources |
| if source.get("source_name") |
| } |
| known_titles = { |
| str(source.get("title", "")).lower() |
| for source in retrieved_sources |
| if source.get("title") |
| } |
| known_blob = " ".join(known_names | known_titles) |
| flagged_resources = ("campus pantry", "thrive", "mheart", "help center", "care to stop violence") |
| return any(resource in text and resource not in known_blob for resource in flagged_resources) |
|
|
|
|
| def _fallback_response(safety_tier: str, route: str) -> str: |
| if safety_tier == "imminent_safety": |
| return ( |
| "I am concerned this may be an immediate safety moment. Please call or text 988 now, " |
| "or call emergency services if there is immediate danger. If someone nearby can stay with you, " |
| "move near them while you get help." |
| ) |
| return ( |
| "That sounds difficult, and I do not want to turn it into empty reassurance. " |
| "Recommended next action: make this smaller and use one relevant campus or human support option now. " |
| "If safety becomes urgent, use 988 or emergency services instead of continuing here." |
| ) |
|
|