|
1 | 1 | """ |
2 | 2 | Tests for llm_assessor.py - LLM-based deep risk assessment. |
3 | 3 | """ |
4 | | -import pytest |
5 | 4 | from decimal import Decimal |
6 | 5 | from uuid import uuid4 |
| 6 | + |
| 7 | +import pytest |
| 8 | + |
7 | 9 | from services.safety_service.src.ml.llm_assessor import ( |
8 | 10 | LLMAssessor, |
9 | 11 | LLMAssessorConfig, |
| 12 | + ProtectiveFactor, |
10 | 13 | RiskAssessment, |
11 | | - RiskLevel, |
12 | 14 | RiskDimension, |
13 | 15 | RiskFactor, |
14 | | - ProtectiveFactor, |
| 16 | + RiskLevel, |
15 | 17 | ) |
16 | 18 |
|
17 | 19 |
|
@@ -174,6 +176,79 @@ def test_generate_cache_key(self, assessor: LLMAssessor) -> None: |
174 | 176 | assert key1 == key2 # Same input = same key |
175 | 177 | assert key1 != key3 # Different input = different key |
176 | 178 |
|
| 179 | + def test_cache_key_includes_user_id(self, assessor: LLMAssessor) -> None: |
| 180 | + """H-05 regression: cache key must differ between users for identical text. |
| 181 | +
|
| 182 | + Before the fix, two users with the same message would collide on the |
| 183 | + same cache key, and user A could receive user B's risk assessment. |
| 184 | + This is a privacy + safety bug: a correctly non-crisis assessment |
| 185 | + for one user could be served for another user whose identical phrase |
| 186 | + actually indicates crisis given their context. |
| 187 | + """ |
| 188 | + text = "same utterance across users" |
| 189 | + context = {"intent": "discuss"} |
| 190 | + |
| 191 | + user_a = uuid4() |
| 192 | + user_b = uuid4() |
| 193 | + |
| 194 | + key_a = assessor._generate_cache_key(text, context, user_a) |
| 195 | + key_b = assessor._generate_cache_key(text, context, user_b) |
| 196 | + key_none = assessor._generate_cache_key(text, context) |
| 197 | + key_a_again = assessor._generate_cache_key(text, context, user_a) |
| 198 | + |
| 199 | + assert key_a != key_b, ( |
| 200 | + "H-05 regression: identical text for different users must not " |
| 201 | + "produce the same cache key. This risks cross-user PHI leakage." |
| 202 | + ) |
| 203 | + assert key_a != key_none, "user-scoped key must differ from anonymous key" |
| 204 | + assert key_a == key_a_again, "same user + same input must be reproducible" |
| 205 | + |
| 206 | + @pytest.mark.asyncio |
| 207 | + async def test_crisis_assessments_are_not_cached(self, assessor: LLMAssessor) -> None: |
| 208 | + """H-05 regression: HIGH/CRITICAL results must never be cached. |
| 209 | +
|
| 210 | + Caching a transient crisis state could mask a real-time intervention |
| 211 | + signal on a subsequent call. The assess() method skips caching when |
| 212 | + the assessment resolves to HIGH or CRITICAL. |
| 213 | + """ |
| 214 | + # Stub the LLM call to return a HIGH-risk assessment every time. |
| 215 | + # We return the JSON shape the real parser expects rather than a |
| 216 | + # RiskAssessment object — that way the full parse path runs. |
| 217 | + async def _fake_call_llm(system_prompt: str, user_prompt: str) -> str: |
| 218 | + # Round-trip the assessment through parse_llm_response by returning |
| 219 | + # a JSON object the real parser understands. |
| 220 | + return ( |
| 221 | + '{"risk_level":"HIGH","risk_score":0.75,"confidence":0.9,' |
| 222 | + '"clinical_summary":"simulated high risk",' |
| 223 | + '"risk_factors":[],"protective_factors":[],"immediate_risk":false,' |
| 224 | + '"recommended_actions":["monitor"],"warning_signs":[],' |
| 225 | + '"contextual_notes":""}' |
| 226 | + ) |
| 227 | + |
| 228 | + assessor._call_llm = _fake_call_llm # type: ignore[assignment,method-assign] |
| 229 | + |
| 230 | + user_id = uuid4() |
| 231 | + text = "I have been thinking about ending it all" |
| 232 | + |
| 233 | + # Two calls with identical inputs — if caching were active on HIGH, |
| 234 | + # we'd hit the cache and _call_llm would not run the second time. |
| 235 | + call_count = {"n": 0} |
| 236 | + original = _fake_call_llm |
| 237 | + |
| 238 | + async def _counting_call(sp: str, up: str) -> str: |
| 239 | + call_count["n"] += 1 |
| 240 | + return await original(sp, up) |
| 241 | + |
| 242 | + assessor._call_llm = _counting_call # type: ignore[assignment,method-assign] |
| 243 | + |
| 244 | + await assessor.assess(text, user_id=user_id) |
| 245 | + await assessor.assess(text, user_id=user_id) |
| 246 | + |
| 247 | + assert call_count["n"] == 2, ( |
| 248 | + "H-05 regression: HIGH-risk assessment must not be cached. " |
| 249 | + "The LLM should be called again on the second identical request." |
| 250 | + ) |
| 251 | + |
177 | 252 | def test_get_cached_assessment_miss(self, assessor: LLMAssessor) -> None: |
178 | 253 | """Test cache miss returns None.""" |
179 | 254 | cached = assessor._get_cached_assessment("nonexistent_key") |
|
0 commit comments