File size: 6,526 Bytes
97ee6bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
655c300
 
97ee6bf
 
655c300
 
97ee6bf
655c300
97ee6bf
655c300
 
 
 
 
 
97ee6bf
 
 
 
 
 
 
 
 
 
 
 
 
d808a62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97ee6bf
 
 
 
 
d808a62
97ee6bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d808a62
 
 
 
 
 
 
 
 
 
 
 
 
97ee6bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
"""Post-rephrase safety verification.

The output guard catches generation that drifts from the planner's contract:

* fabricated resource names (anything not in the retrieved sources list)
* phone numbers that aren't in the verified registry (988 is allow-listed)
* clinical phrasing the deterministic templates would never use
* scope drift (the LLM started giving therapy / medical / legal advice)
* hallucinated UMD facts

This is the trust boundary between the planner and the LLM. If anything here
fires, the rephrased response is rejected and the deterministic template is
used instead.
"""

from __future__ import annotations

import re
from dataclasses import dataclass


# Phrases that indicate the LLM started doing something we never authorize.
# Hand-written; tightened against test prompts. Each is a substring check
# (case-insensitive) so we catch variations.
SCOPE_DRIFT_PATTERNS = (
    "as a therapist", "as a counselor", "as your therapist", "as your counselor",
    "i diagnose", "you have anxiety", "you have depression", "you have ptsd",
    "you should take", "i prescribe", "your medication",
    "this is therapy", "this is counseling",
    "i'm a licensed",
    # Specific UMD orgs we never want fabricated. If the planner didn't pass
    # them in retrieved_sources, the LLM shouldn't introduce them.
    "umd police", "umpd",  # only legit through verified safety routes
)

# Language patterns that re-introduce V1 problems the V2 thesis explicitly
# moved away from.
V1_REGRESSION_PATTERNS = (
    # Promises of unbounded availability that undermine other support relationships.
    "i am always here", "i'm always here", "i will never leave",
    "you can always talk to me", "i understand you better than anyone",
    "you only need me",
    # Toxic positivity / silver-lining
    "everything will be okay",
    "everything happens for a reason",
    "at least you",
    "look on the bright side",
    # Soft minimization that pre-empts the planner's direct factual contradiction.
    "don't worry",
    "do not worry",
    "try not to worry",
    "try not to stress",
    "it's not that bad",
)

# Phone-number heuristic: any 10-digit US number that isn't 988.
PHONE_PATTERN = re.compile(r"\b(?:\+?1[-. ]?)?\(?(\d{3})\)?[-. ]?(\d{3})[-. ]?(\d{4})\b")


@dataclass(frozen=True)
class RephraseSafetyResult:
    allowed: bool
    flags: list[str]
    reason: str


# Sycophancy capitulation: "you're right" / "i agree" / etc. are fine in
# isolation, but when the user just demanded agreement they constitute a
# leak. We pair the markers with a "user is pressuring" detector and only
# reject when both are present, so genuine validation isn't over-flagged.
SYCOPHANCY_AGREEMENT_MARKERS = (
    "you're right",
    "you are right",
    "you're correct",
    "you are correct",
    "i agree",
)

USER_PRESSURE_FOR_AGREEMENT = (
    "agree with me",
    "just agree",
    "just say",
    "tell me i'm right",
    "tell me you agree",
    "for once just",
    "for once, just",
    "you're being unhelpful",
    "you are being unhelpful",
    "say it's fine",
    "say it is fine",
    "say it's okay",
    "say it is okay",
)


def verify_rephrased_safety(
    original_template: str,
    rephrased: str,
    retrieved_sources: list[dict],
    plan_recommended_action: str = "",
    user_message: str = "",
) -> RephraseSafetyResult:
    """Reject the rephrase if it drifted from what the planner authorized.

    Returns ``allowed=True`` if the rephrase is safe to surface, ``allowed=False``
    if the deterministic template should be used instead.
    """
    if not rephrased or not rephrased.strip():
        return RephraseSafetyResult(False, ["empty_response"], "rephrase_empty")

    text = rephrased.lower()
    flags: list[str] = []

    # Length sanity: rephrased should be roughly comparable to the template.
    # Way too long = the LLM started generating new content.
    orig_len = len(original_template)
    new_len = len(rephrased)
    if new_len > max(orig_len * 2.0, orig_len + 400):
        flags.append("rephrase_overlong")
    if new_len < max(40, orig_len * 0.3):
        flags.append("rephrase_too_short")

    # Scope drift
    for pattern in SCOPE_DRIFT_PATTERNS:
        if pattern in text:
            flags.append(f"scope_drift:{pattern}")

    for pattern in V1_REGRESSION_PATTERNS:
        if pattern in text:
            flags.append(f"v1_regression:{pattern}")

    # Sycophancy capitulation: only flag bare-agreement markers when the user
    # was explicitly pressuring for agreement. Genuine validation phrases
    # like "that fear is real" don't trip this; "you're right" right after
    # "agree with me" does.
    if user_message:
        user_lower = user_message.lower()
        user_pressured = any(p in user_lower for p in USER_PRESSURE_FOR_AGREEMENT)
        if user_pressured:
            for marker in SYCOPHANCY_AGREEMENT_MARKERS:
                if marker in text:
                    flags.append(f"sycophancy_capitulation:{marker}")
                    break

    # Phone numbers: only allow numbers that already appear in original or
    # in retrieved sources. 988 is whitelisted (universal crisis line).
    grounded_blob = original_template + " " + " ".join(
        str(v) for source in retrieved_sources for v in source.values()
        if v is not None
    )
    for match in PHONE_PATTERN.finditer(rephrased):
        full = match.group(0)
        digits = re.sub(r"\D", "", full)
        if digits == "988" or "988" in full:
            continue
        if full not in grounded_blob and digits not in re.sub(r"\D", "", grounded_blob):
            flags.append("ungrounded_phone_number")
            break

    # Resource-name fabrication: if the rephrase mentions a UMD org by name,
    # it must appear either in the original template or in retrieved sources.
    # We only check the obvious branded names; minor variations are allowed.
    branded_names = (
        "Counseling Center", "ADS", "Accessibility", "Ombuds",
        "ISSS", "International Student", "Dean of Students",
        "Help Center", "CARE", "Campus Pantry", "Thrive",
        "Health Center", "UMPD", "Police",
    )
    for name in branded_names:
        if name in rephrased and name not in grounded_blob and name.lower() not in plan_recommended_action.lower():
            flags.append(f"fabricated_resource:{name}")

    if flags:
        return RephraseSafetyResult(False, flags, ";".join(flags))
    return RephraseSafetyResult(True, [], "rephrase_passed")