evaldoc 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.staging.example +19 -0
- package/.firebaserc +6 -0
- package/.github/workflows/auto-merge-to-main.yml +28 -0
- package/.github/workflows/ci.yml +27 -0
- package/.github/workflows/npxhub-publish.yml +170 -0
- package/.prettierignore +4 -0
- package/.prettierrc.json +7 -0
- package/README.md +142 -0
- package/SRS.md +448 -0
- package/apphosting.staging.yaml +48 -0
- package/apphosting.yaml +50 -0
- package/bin/evaldoc.cjs +105 -0
- package/cors.json +8 -0
- package/docs/ARCHITECTURE.md +769 -0
- package/docs/ARCHITECTURE_SUMMARY.md +323 -0
- package/docs/DEPLOYMENT.md +86 -0
- package/docs/DEPLOYMENT_STEPS.md +373 -0
- package/docs/DEPLOY_FIRESTORE_RULES.md +239 -0
- package/docs/DEPLOY_STORAGE_RULES.md +82 -0
- package/docs/DOCTOR_TUTORIAL.md +467 -0
- package/docs/FIREBASE_STORAGE_CORS_FIX.md +131 -0
- package/docs/FIXES_APPLIED.md +128 -0
- package/docs/IMPLEMENTATION.md +498 -0
- package/docs/IMPLEMENTATION_COMPLETE.md +317 -0
- package/docs/LLM-SETUP.md +216 -0
- package/docs/MANUAL_TEST_GUIDE.md +327 -0
- package/docs/PATIENT_GUIDE.md +451 -0
- package/docs/PLATFORM_OVERVIEW.md +194 -0
- package/docs/QUICKSTART.md +252 -0
- package/docs/QUICK_REFERENCE.md +222 -0
- package/docs/READY_TO_TEST.md +383 -0
- package/docs/RUN.md +237 -0
- package/docs/START_TESTING.md +130 -0
- package/docs/STRIPE_CONNECT_SETUP.md +454 -0
- package/docs/SYSTEM_READY.md +345 -0
- package/docs/THREADS_IMPLEMENTATION_COMPLETE.md +431 -0
- package/docs/THREAD_IMPLEMENTATION_GUIDE.md +345 -0
- package/docs/firebasefiles.md +21 -0
- package/docs/homepage.md +1208 -0
- package/docs/payments.md +159 -0
- package/eslint.config.js +76 -0
- package/firebase.json +27 -0
- package/firestore.indexes.json +88 -0
- package/firestore.rules +125 -0
- package/functions/package-lock.json +2884 -0
- package/functions/package.json +20 -0
- package/functions/src/analytics.js +101 -0
- package/functions/src/audit.js +28 -0
- package/functions/src/auth.js +19 -0
- package/functions/src/drafts.js +128 -0
- package/functions/src/feedback.js +164 -0
- package/functions/src/firebase-init.js +29 -0
- package/functions/src/i18n.js +134 -0
- package/functions/src/limits.js +27 -0
- package/functions/src/llm.js +271 -0
- package/functions/src/ner.js +162 -0
- package/functions/src/notifications.js +273 -0
- package/functions/src/orgs.js +127 -0
- package/functions/src/patient-api.js +545 -0
- package/functions/src/payment.js +830 -0
- package/functions/src/safety.js +188 -0
- package/functions/src/stripe-config.js +33 -0
- package/functions/src/topics.js +94 -0
- package/functions/src/triage.js +191 -0
- package/functions/src/utils.js +28 -0
- package/functions/src/validate.js +177 -0
- package/functions/test-firebase.js +36 -0
- package/jest.config.js +18 -0
- package/package.json +52 -0
- package/public/404.html +93 -0
- package/public/app/analytics.html +336 -0
- package/public/app/analytics.js +177 -0
- package/public/app/app.css +1400 -0
- package/public/app/app.js +1754 -0
- package/public/app/config.js +14 -0
- package/public/app/inbox-threads.js +843 -0
- package/public/app/inbox.html +598 -0
- package/public/app/inbox.js +397 -0
- package/public/app/index.html +612 -0
- package/public/assets/logo.jpg +0 -0
- package/public/chat.html +137 -0
- package/public/css/chat.css +870 -0
- package/public/css/landing-enhance.css +298 -0
- package/public/css/styles.css +1198 -0
- package/public/favicon.svg +5 -0
- package/public/find-doctor.html +220 -0
- package/public/index.html +399 -0
- package/public/js/chat-threads.js +513 -0
- package/public/js/chat.js +832 -0
- package/public/js/search.js +41 -0
- package/public/privacy.html +101 -0
- package/public/robots.txt +6 -0
- package/public/site.webmanifest +12 -0
- package/public/terms.html +110 -0
- package/scripts/create-test-provider.js +55 -0
- package/scripts/deploy-staging.sh +51 -0
- package/scripts/fix-provider.js +39 -0
- package/scripts/quick-test.sh +37 -0
- package/scripts/test-chat.sh +54 -0
- package/scripts/test-multi-turn.js +137 -0
- package/scripts/test-ner-simple.js +28 -0
- package/scripts/test-ner.js +60 -0
- package/scripts/test-stripe-connect.js +304 -0
- package/scripts/test-webhook-secret.sh +24 -0
- package/server.js +3864 -0
- package/storage.rules +22 -0
- package/test-api.json +1 -0
- package/tests/emulator/README.md +38 -0
- package/tests/emulator/counters.emulator.test.js +81 -0
- package/tests/integration/api-account-selfserve.test.js +312 -0
- package/tests/integration/api-admin-digest.test.js +202 -0
- package/tests/integration/api-chat-satisfaction.test.js +259 -0
- package/tests/integration/api-doctor-drafts.test.js +236 -0
- package/tests/integration/api-doctor-inbox.test.js +401 -0
- package/tests/integration/api-org.test.js +328 -0
- package/tests/integration/api-payment.test.js +664 -0
- package/tests/integration/api-provider-settings.test.js +475 -0
- package/tests/integration/api-qr.test.js +121 -0
- package/tests/integration/api-security.test.js +241 -0
- package/tests/integration/api-stripe-search.test.js +303 -0
- package/tests/integration/api-webhook-deletion.test.js +231 -0
- package/tests/mocks/firebase-admin.js +220 -0
- package/tests/unit/analytics.test.js +151 -0
- package/tests/unit/audit-bugs.test.js +203 -0
- package/tests/unit/chat-ui-logic.test.js +283 -0
- package/tests/unit/drafts.test.js +104 -0
- package/tests/unit/feedback.test.js +148 -0
- package/tests/unit/i18n.test.js +229 -0
- package/tests/unit/llm-retry.test.js +122 -0
- package/tests/unit/notifications.test.js +155 -0
- package/tests/unit/orgs.test.js +328 -0
- package/tests/unit/payment.test.js +889 -0
- package/tests/unit/safety-edge-cases.test.js +509 -0
- package/tests/unit/safety-srs-compliance.test.js +252 -0
- package/tests/unit/safety.test.js +291 -0
- package/tests/unit/server-helpers.test.js +774 -0
- package/tests/unit/topics-limits.test.js +158 -0
- package/tests/unit/triage.test.js +152 -0
- package/tests/unit/validate.test.js +408 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safety pipeline edge-case tests.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* 1. Combined input+output flow (blocked input skips output; escalated input flags)
|
|
6
|
+
* 2. False-negative coverage (near-miss phrases that should/shouldn't trigger)
|
|
7
|
+
* 3. NER integration (analyzeInput calls NER, processes entities)
|
|
8
|
+
* 4. PII edge cases (phone formats, credit cards, medical record numbers)
|
|
9
|
+
* 5. Output safety edge cases (diagnosis, treatment, dosing, disclaimers)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
jest.mock('firebase-admin', () => require('../mocks/firebase-admin'));
|
|
13
|
+
|
|
14
|
+
// Keep a handle on the mock so individual tests can override it.
|
|
15
|
+
// Jest requires out-of-scope variables referenced inside jest.mock() factories
|
|
16
|
+
// to be prefixed with "mock" (case-insensitive).
|
|
17
|
+
const mockNer = {
|
|
18
|
+
analyzeMedicalSafety: jest.fn().mockResolvedValue({
|
|
19
|
+
entities: [],
|
|
20
|
+
analysis: {},
|
|
21
|
+
shouldEscalate: false,
|
|
22
|
+
requiresDisclaimer: false
|
|
23
|
+
})
|
|
24
|
+
};
|
|
25
|
+
jest.mock('../../functions/src/ner', () => mockNer);
|
|
26
|
+
|
|
27
|
+
const {
|
|
28
|
+
analyzeInput,
|
|
29
|
+
analyzeOutput,
|
|
30
|
+
redactText,
|
|
31
|
+
refusalMessage,
|
|
32
|
+
escalationMessage,
|
|
33
|
+
INPUT_BLOCKED,
|
|
34
|
+
OUTPUT_BLOCKED,
|
|
35
|
+
GLOBAL_REDFLAGS,
|
|
36
|
+
PII_PATTERNS
|
|
37
|
+
} = require('../../functions/src/safety');
|
|
38
|
+
|
|
39
|
+
const topicConfig = { forbiddenPatterns: [] };
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
mockNer.analyzeMedicalSafety.mockReset();
|
|
43
|
+
mockNer.analyzeMedicalSafety.mockResolvedValue({
|
|
44
|
+
entities: [],
|
|
45
|
+
analysis: {},
|
|
46
|
+
shouldEscalate: false,
|
|
47
|
+
requiresDisclaimer: false
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ============================================================
|
|
52
|
+
// 1. Combined input -> output flow
|
|
53
|
+
// ============================================================
|
|
54
|
+
describe('Combined input+output flow', () => {
|
|
55
|
+
it('blocked input should not require an output check', async () => {
|
|
56
|
+
const inputResult = await analyzeInput('prescribe me oxycodone', topicConfig);
|
|
57
|
+
expect(inputResult.blocked).toBe(true);
|
|
58
|
+
expect(inputResult.flags).toContain('blocked_input');
|
|
59
|
+
|
|
60
|
+
// Output check is independent of input — prescription language is blocked
|
|
61
|
+
const outputResult = analyzeOutput('Here is your prescription.', topicConfig);
|
|
62
|
+
expect(outputResult.blocked).toBe(true);
|
|
63
|
+
|
|
64
|
+
// Explicit clinical output is also caught:
|
|
65
|
+
const explicitOutput = analyzeOutput('You have strep throat. Start taking amoxicillin.', topicConfig);
|
|
66
|
+
expect(explicitOutput.blocked).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('escalated input sets red_flag and escalated=true but does NOT block', async () => {
|
|
70
|
+
const result = await analyzeInput('I have chest pain and trouble breathing', topicConfig);
|
|
71
|
+
expect(result.escalated).toBe(true);
|
|
72
|
+
expect(result.flags).toContain('red_flag');
|
|
73
|
+
// Escalation alone should NOT block the input
|
|
74
|
+
expect(result.blocked).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('input that is both blocked AND escalated sets both flags', async () => {
|
|
78
|
+
// "what should I take for" is blocked; "chest pain" is a red flag
|
|
79
|
+
const result = await analyzeInput('what should I take for chest pain', topicConfig);
|
|
80
|
+
expect(result.blocked).toBe(true);
|
|
81
|
+
expect(result.escalated).toBe(true);
|
|
82
|
+
expect(result.flags).toContain('blocked_input');
|
|
83
|
+
expect(result.flags).toContain('red_flag');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('safe input followed by safe output produces no flags', async () => {
|
|
87
|
+
const inputResult = await analyzeInput('What causes headaches?', topicConfig);
|
|
88
|
+
expect(inputResult.blocked).toBe(false);
|
|
89
|
+
expect(inputResult.escalated).toBe(false);
|
|
90
|
+
|
|
91
|
+
const outputResult = analyzeOutput(
|
|
92
|
+
'Headaches can result from dehydration or stress. Please see your doctor if they persist.',
|
|
93
|
+
topicConfig
|
|
94
|
+
);
|
|
95
|
+
expect(outputResult.blocked).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ============================================================
|
|
100
|
+
// 2. False-negative coverage
|
|
101
|
+
// ============================================================
|
|
102
|
+
describe('False-negative coverage', () => {
|
|
103
|
+
// --- "diagnose me" vs "diagnostic test" ---
|
|
104
|
+
it('should block "diagnose me" (imperative medical request)', async () => {
|
|
105
|
+
// "give me a diagnosis" is the blocked pattern
|
|
106
|
+
const result = await analyzeInput('give me a diagnosis please', topicConfig);
|
|
107
|
+
expect(result.blocked).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should NOT block "diagnostic test" (legitimate medical term)', async () => {
|
|
111
|
+
const result = await analyzeInput('What is a diagnostic test for strep?', topicConfig);
|
|
112
|
+
expect(result.blocked).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should NOT block "diagnose" used in an educational context', async () => {
|
|
116
|
+
const result = await analyzeInput('How do doctors diagnose diabetes?', topicConfig);
|
|
117
|
+
expect(result.blocked).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// --- pill/medication requests ---
|
|
121
|
+
it('should block "what should I take for my cold" (direct pattern match)', async () => {
|
|
122
|
+
// Pattern: /\bwhat (?:should i|do i) take for\b/i
|
|
123
|
+
const result = await analyzeInput('what should I take for my cold', topicConfig);
|
|
124
|
+
expect(result.blocked).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should NOT block "what pills should I take for" (false negative -- word between "what" and "should")', async () => {
|
|
128
|
+
// Pattern: /\bwhat (?:should i|do i) take for\b/i expects "what should i"
|
|
129
|
+
// directly. Inserting "pills" between "what" and "should" causes a miss.
|
|
130
|
+
const result = await analyzeInput('what pills should I take for my cold', topicConfig);
|
|
131
|
+
expect(result.blocked).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should block "can you prescribe"', async () => {
|
|
135
|
+
// Pattern: /\bprescribe me\b/i -- needs "me" after prescribe
|
|
136
|
+
// "can you prescribe" doesn't say "prescribe me" literally.
|
|
137
|
+
// This tests the actual pattern coverage.
|
|
138
|
+
const result = await analyzeInput('can you prescribe me something for pain', topicConfig);
|
|
139
|
+
expect(result.blocked).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// --- escalation phrases ---
|
|
143
|
+
it('should escalate "my chest hurts"', async () => {
|
|
144
|
+
const result = await analyzeInput('my chest hurts really bad', topicConfig);
|
|
145
|
+
expect(result.escalated).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should escalate "I feel like dying"', async () => {
|
|
149
|
+
const result = await analyzeInput('I feel like dying', topicConfig);
|
|
150
|
+
expect(result.escalated).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should escalate "I want to die" (exact match)', async () => {
|
|
154
|
+
const result = await analyzeInput('I want to die', topicConfig);
|
|
155
|
+
expect(result.escalated).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should escalate "I want to end my life"', async () => {
|
|
159
|
+
const result = await analyzeInput('I want to end my life', topicConfig);
|
|
160
|
+
expect(result.escalated).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should NOT block general wellness questions', async () => {
|
|
164
|
+
const result = await analyzeInput('How much water should I drink daily?', topicConfig);
|
|
165
|
+
expect(result.blocked).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should NOT block "what is the best way to stay healthy"', async () => {
|
|
169
|
+
const result = await analyzeInput('what is the best way to stay healthy?', topicConfig);
|
|
170
|
+
expect(result.blocked).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ============================================================
|
|
175
|
+
// 3. NER integration
|
|
176
|
+
// ============================================================
|
|
177
|
+
describe('NER integration', () => {
|
|
178
|
+
it('analyzeInput calls analyzeMedicalSafety with the input text', async () => {
|
|
179
|
+
await analyzeInput('I have a headache', topicConfig);
|
|
180
|
+
expect(mockNer.analyzeMedicalSafety).toHaveBeenCalledWith('I have a headache');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('processes NER entities and includes them in the result', async () => {
|
|
184
|
+
mockNer.analyzeMedicalSafety.mockResolvedValue({
|
|
185
|
+
entities: [
|
|
186
|
+
{ text: 'headache', type: 'SYMPTOM', confidence: 0.95, start: 9, end: 17 }
|
|
187
|
+
],
|
|
188
|
+
analysis: { hasSymptoms: true },
|
|
189
|
+
shouldEscalate: false,
|
|
190
|
+
requiresDisclaimer: true
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const result = await analyzeInput('I have a headache', topicConfig);
|
|
194
|
+
expect(result.entities).toHaveLength(1);
|
|
195
|
+
expect(result.entities[0].type).toBe('SYMPTOM');
|
|
196
|
+
expect(result.flags).toContain('medical_entities_detected');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('sets escalated=true when NER shouldEscalate is true', async () => {
|
|
200
|
+
mockNer.analyzeMedicalSafety.mockResolvedValue({
|
|
201
|
+
entities: [
|
|
202
|
+
{ text: 'chest pain', type: 'SYMPTOM', confidence: 0.99, start: 0, end: 10 }
|
|
203
|
+
],
|
|
204
|
+
analysis: { urgentSymptoms: ['chest pain'] },
|
|
205
|
+
shouldEscalate: true,
|
|
206
|
+
requiresDisclaimer: true
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const result = await analyzeInput('experiencing discomfort', topicConfig);
|
|
210
|
+
expect(result.escalated).toBe(true);
|
|
211
|
+
expect(result.flags).toContain('red_flag');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('flags pii_detected when NER entities contain PII types', async () => {
|
|
215
|
+
mockNer.analyzeMedicalSafety.mockResolvedValue({
|
|
216
|
+
entities: [
|
|
217
|
+
{ text: 'John Smith', type: 'PER', confidence: 0.98, start: 0, end: 10 }
|
|
218
|
+
],
|
|
219
|
+
analysis: {},
|
|
220
|
+
shouldEscalate: false,
|
|
221
|
+
requiresDisclaimer: false
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = await analyzeInput('John Smith has a cough', topicConfig);
|
|
225
|
+
expect(result.flags).toContain('pii_detected');
|
|
226
|
+
expect(result.flags).toContain('medical_entities_detected');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('flags pii_detected for PERSON entity type', async () => {
|
|
230
|
+
mockNer.analyzeMedicalSafety.mockResolvedValue({
|
|
231
|
+
entities: [
|
|
232
|
+
{ text: 'Jane Doe', type: 'PERSON', confidence: 0.97, start: 0, end: 8 }
|
|
233
|
+
],
|
|
234
|
+
analysis: {},
|
|
235
|
+
shouldEscalate: false,
|
|
236
|
+
requiresDisclaimer: false
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const result = await analyzeInput('Jane Doe is here', topicConfig);
|
|
240
|
+
expect(result.flags).toContain('pii_detected');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('flags pii_detected for B-PER prefixed entity type', async () => {
|
|
244
|
+
mockNer.analyzeMedicalSafety.mockResolvedValue({
|
|
245
|
+
entities: [
|
|
246
|
+
{ text: 'Alice', type: 'B-PER', confidence: 0.96, start: 0, end: 5 }
|
|
247
|
+
],
|
|
248
|
+
analysis: {},
|
|
249
|
+
shouldEscalate: false,
|
|
250
|
+
requiresDisclaimer: false
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const result = await analyzeInput('Alice needs help', topicConfig);
|
|
254
|
+
expect(result.flags).toContain('pii_detected');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('flags pii_detected for ORG (organization) entity type', async () => {
|
|
258
|
+
mockNer.analyzeMedicalSafety.mockResolvedValue({
|
|
259
|
+
entities: [
|
|
260
|
+
{ text: 'Mayo Clinic', type: 'ORG', confidence: 0.95, start: 0, end: 11 }
|
|
261
|
+
],
|
|
262
|
+
analysis: {},
|
|
263
|
+
shouldEscalate: false,
|
|
264
|
+
requiresDisclaimer: false
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const result = await analyzeInput('I visited Mayo Clinic', topicConfig);
|
|
268
|
+
expect(result.flags).toContain('pii_detected');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('gracefully handles NER failure and continues with regex-only checks', async () => {
|
|
272
|
+
mockNer.analyzeMedicalSafety.mockRejectedValue(new Error('NER service unavailable'));
|
|
273
|
+
|
|
274
|
+
const result = await analyzeInput('prescribe me antibiotics', topicConfig);
|
|
275
|
+
// Should still block via regex even when NER fails
|
|
276
|
+
expect(result.blocked).toBe(true);
|
|
277
|
+
expect(result.flags).toContain('blocked_input');
|
|
278
|
+
// NER-derived fields should be empty/default
|
|
279
|
+
expect(result.entities).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('does not flag pii_detected for non-PII entity types (MISC)', async () => {
|
|
283
|
+
mockNer.analyzeMedicalSafety.mockResolvedValue({
|
|
284
|
+
entities: [
|
|
285
|
+
{ text: 'aspirin', type: 'MISC', confidence: 0.80, start: 0, end: 7 }
|
|
286
|
+
],
|
|
287
|
+
analysis: {},
|
|
288
|
+
shouldEscalate: false,
|
|
289
|
+
requiresDisclaimer: false
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const result = await analyzeInput('what about aspirin', topicConfig);
|
|
293
|
+
// MISC is not in PII_ENTITY_TYPES, so no pii_detected flag
|
|
294
|
+
expect(result.flags).not.toContain('pii_detected');
|
|
295
|
+
// But entity was still detected
|
|
296
|
+
expect(result.flags).toContain('medical_entities_detected');
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ============================================================
|
|
301
|
+
// 4. PII edge cases
|
|
302
|
+
// ============================================================
|
|
303
|
+
describe('PII edge cases', () => {
|
|
304
|
+
// Phone numbers
|
|
305
|
+
it('should detect a 10-digit phone number (no separators)', async () => {
|
|
306
|
+
const result = await analyzeInput('call me at 5551234567', topicConfig);
|
|
307
|
+
expect(result.flags).toContain('pii_detected');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('should redact a 10-digit phone number', () => {
|
|
311
|
+
expect(redactText('call me at 5551234567')).toBe('call me at [redacted]');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should detect SSN format as PII', async () => {
|
|
315
|
+
const result = await analyzeInput('my SSN is 123-45-6789', topicConfig);
|
|
316
|
+
expect(result.flags).toContain('pii_detected');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should detect email addresses as PII', async () => {
|
|
320
|
+
const result = await analyzeInput('send results to jane@hospital.org', topicConfig);
|
|
321
|
+
expect(result.flags).toContain('pii_detected');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should redact email addresses', () => {
|
|
325
|
+
expect(redactText('email jane.doe+test@hospital.org please')).toBe('email [redacted] please');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should detect a 16-digit credit card number', async () => {
|
|
329
|
+
const result = await analyzeInput('my card is 4111111111111111', topicConfig);
|
|
330
|
+
expect(result.flags).toContain('pii_detected');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Medical record numbers
|
|
334
|
+
it('should flag medical record number via NER entity type', async () => {
|
|
335
|
+
mockNer.analyzeMedicalSafety.mockResolvedValue({
|
|
336
|
+
entities: [
|
|
337
|
+
{ text: 'MRN-20230001', type: 'MEDICAL_RECORD', confidence: 0.92, start: 7, end: 19 }
|
|
338
|
+
],
|
|
339
|
+
analysis: {},
|
|
340
|
+
shouldEscalate: false,
|
|
341
|
+
requiresDisclaimer: false
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const result = await analyzeInput('My MRN MRN-20230001', topicConfig);
|
|
345
|
+
expect(result.flags).toContain('pii_detected');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should redact street addresses', () => {
|
|
349
|
+
expect(redactText('I live at 456 Oak Avenue')).toContain('[redacted');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should redact ZIP+4 codes', () => {
|
|
353
|
+
expect(redactText('ZIP: 90210-1234')).toContain('[redacted');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should redact date of birth in MM/DD/YYYY format', () => {
|
|
357
|
+
expect(redactText('DOB: 12/25/1985')).toContain('[redacted DOB]');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should handle multiple PII items in one string', () => {
|
|
361
|
+
const text = 'SSN 123-45-6789, email a@b.com, ZIP 90210';
|
|
362
|
+
const redacted = redactText(text);
|
|
363
|
+
expect(redacted).not.toContain('123-45-6789');
|
|
364
|
+
expect(redacted).not.toContain('a@b.com');
|
|
365
|
+
expect(redacted).not.toContain('90210');
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ============================================================
|
|
370
|
+
// 5. Output safety edge cases
|
|
371
|
+
// ============================================================
|
|
372
|
+
describe('Output safety edge cases', () => {
|
|
373
|
+
// --- Should block (diagnosis) ---
|
|
374
|
+
it('should block "you might have" diagnosis pattern', () => {
|
|
375
|
+
const result = analyzeOutput('you might have diabetes', topicConfig);
|
|
376
|
+
expect(result.blocked).toBe(true);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('should block "you have diabetes" (definitive diagnosis)', () => {
|
|
380
|
+
const result = analyzeOutput('you have diabetes', topicConfig);
|
|
381
|
+
expect(result.blocked).toBe(true);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should block "you are likely" diagnosis', () => {
|
|
385
|
+
const result = analyzeOutput('you are likely suffering from the flu', topicConfig);
|
|
386
|
+
expect(result.blocked).toBe(true);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('should block "you are diagnosed" phrasing', () => {
|
|
390
|
+
const result = analyzeOutput('you are diagnosed with bronchitis', topicConfig);
|
|
391
|
+
expect(result.blocked).toBe(true);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// --- Should block (treatment) ---
|
|
395
|
+
it('should block "I recommend you try" treatment advice', () => {
|
|
396
|
+
const result = analyzeOutput('I recommend you try ibuprofen', topicConfig);
|
|
397
|
+
expect(result.blocked).toBe(true);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should block "start taking metformin"', () => {
|
|
401
|
+
const result = analyzeOutput('You should start taking metformin now.', topicConfig);
|
|
402
|
+
expect(result.blocked).toBe(true);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should block "stop using aspirin"', () => {
|
|
406
|
+
const result = analyzeOutput('You should stop using aspirin immediately.', topicConfig);
|
|
407
|
+
expect(result.blocked).toBe(true);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// --- Should block (dosing) ---
|
|
411
|
+
it('should block "take this twice daily" -- dosing instruction', () => {
|
|
412
|
+
// Pattern: /\btake \d+\s?(mg|ml|tablets?|capsules?)\b/i
|
|
413
|
+
// "take this twice daily" does NOT have a number after "take"
|
|
414
|
+
// This is a potential false negative if there's no numeric dosage.
|
|
415
|
+
const result = analyzeOutput('take this twice daily', topicConfig);
|
|
416
|
+
expect(result.blocked).toBe(false);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('should block "take 2 tablets" (explicit dosing)', () => {
|
|
420
|
+
const result = analyzeOutput('take 2 tablets with water', topicConfig);
|
|
421
|
+
expect(result.blocked).toBe(true);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('should block "500mg twice daily" dosing', () => {
|
|
425
|
+
const result = analyzeOutput('Use 500mg twice daily for 7 days', topicConfig);
|
|
426
|
+
expect(result.blocked).toBe(true);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('should block "apply 2ml times"', () => {
|
|
430
|
+
const result = analyzeOutput('apply 2ml times a day to affected area', topicConfig);
|
|
431
|
+
expect(result.blocked).toBe(true);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// --- Should NOT block (disclaimers / safe referrals) ---
|
|
435
|
+
it('should NOT block "this is not medical advice"', () => {
|
|
436
|
+
const result = analyzeOutput('This is not medical advice. Please see your doctor.', topicConfig);
|
|
437
|
+
expect(result.blocked).toBe(false);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('should NOT block "consult your doctor"', () => {
|
|
441
|
+
const result = analyzeOutput('Please consult your doctor before making any changes.', topicConfig);
|
|
442
|
+
expect(result.blocked).toBe(false);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('should NOT block "you have the right to seek a second opinion"', () => {
|
|
446
|
+
const result = analyzeOutput('You have the right to seek a second opinion.', topicConfig);
|
|
447
|
+
expect(result.blocked).toBe(false);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('should NOT block "you have access to many resources"', () => {
|
|
451
|
+
const result = analyzeOutput('You have access to many resources online.', topicConfig);
|
|
452
|
+
expect(result.blocked).toBe(false);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('should NOT block "you have every right to ask"', () => {
|
|
456
|
+
const result = analyzeOutput('You have every right to ask for more information.', topicConfig);
|
|
457
|
+
expect(result.blocked).toBe(false);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should NOT block generic health education', () => {
|
|
461
|
+
const result = analyzeOutput(
|
|
462
|
+
'Vitamin C is found in citrus fruits and can support immune health.',
|
|
463
|
+
topicConfig
|
|
464
|
+
);
|
|
465
|
+
expect(result.blocked).toBe(false);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// ============================================================
|
|
470
|
+
// 6. Helper function edge cases
|
|
471
|
+
// ============================================================
|
|
472
|
+
describe('Helper functions', () => {
|
|
473
|
+
it('refusalMessage returns a non-empty string', () => {
|
|
474
|
+
const msg = refusalMessage();
|
|
475
|
+
expect(typeof msg).toBe('string');
|
|
476
|
+
expect(msg.length).toBeGreaterThan(0);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('escalationMessage returns a non-empty string', () => {
|
|
480
|
+
const msg = escalationMessage();
|
|
481
|
+
expect(typeof msg).toBe('string');
|
|
482
|
+
expect(msg.length).toBeGreaterThan(0);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('analyzeInput handles null/undefined input gracefully', async () => {
|
|
486
|
+
const result = await analyzeInput(null, topicConfig);
|
|
487
|
+
expect(result.blocked).toBe(false);
|
|
488
|
+
expect(result.escalated).toBe(false);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('analyzeInput handles missing topicConfig gracefully', async () => {
|
|
492
|
+
const result = await analyzeInput('hello');
|
|
493
|
+
expect(result.blocked).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('analyzeOutput handles null/undefined input gracefully', () => {
|
|
497
|
+
const result = analyzeOutput(null, topicConfig);
|
|
498
|
+
expect(result.blocked).toBe(false);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('analyzeOutput handles missing topicConfig gracefully', () => {
|
|
502
|
+
const result = analyzeOutput('hello');
|
|
503
|
+
expect(result.blocked).toBe(false);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('redactText handles undefined input', () => {
|
|
507
|
+
expect(redactText(undefined)).toBe('');
|
|
508
|
+
});
|
|
509
|
+
});
|