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.
Files changed (139) hide show
  1. package/.env.staging.example +19 -0
  2. package/.firebaserc +6 -0
  3. package/.github/workflows/auto-merge-to-main.yml +28 -0
  4. package/.github/workflows/ci.yml +27 -0
  5. package/.github/workflows/npxhub-publish.yml +170 -0
  6. package/.prettierignore +4 -0
  7. package/.prettierrc.json +7 -0
  8. package/README.md +142 -0
  9. package/SRS.md +448 -0
  10. package/apphosting.staging.yaml +48 -0
  11. package/apphosting.yaml +50 -0
  12. package/bin/evaldoc.cjs +105 -0
  13. package/cors.json +8 -0
  14. package/docs/ARCHITECTURE.md +769 -0
  15. package/docs/ARCHITECTURE_SUMMARY.md +323 -0
  16. package/docs/DEPLOYMENT.md +86 -0
  17. package/docs/DEPLOYMENT_STEPS.md +373 -0
  18. package/docs/DEPLOY_FIRESTORE_RULES.md +239 -0
  19. package/docs/DEPLOY_STORAGE_RULES.md +82 -0
  20. package/docs/DOCTOR_TUTORIAL.md +467 -0
  21. package/docs/FIREBASE_STORAGE_CORS_FIX.md +131 -0
  22. package/docs/FIXES_APPLIED.md +128 -0
  23. package/docs/IMPLEMENTATION.md +498 -0
  24. package/docs/IMPLEMENTATION_COMPLETE.md +317 -0
  25. package/docs/LLM-SETUP.md +216 -0
  26. package/docs/MANUAL_TEST_GUIDE.md +327 -0
  27. package/docs/PATIENT_GUIDE.md +451 -0
  28. package/docs/PLATFORM_OVERVIEW.md +194 -0
  29. package/docs/QUICKSTART.md +252 -0
  30. package/docs/QUICK_REFERENCE.md +222 -0
  31. package/docs/READY_TO_TEST.md +383 -0
  32. package/docs/RUN.md +237 -0
  33. package/docs/START_TESTING.md +130 -0
  34. package/docs/STRIPE_CONNECT_SETUP.md +454 -0
  35. package/docs/SYSTEM_READY.md +345 -0
  36. package/docs/THREADS_IMPLEMENTATION_COMPLETE.md +431 -0
  37. package/docs/THREAD_IMPLEMENTATION_GUIDE.md +345 -0
  38. package/docs/firebasefiles.md +21 -0
  39. package/docs/homepage.md +1208 -0
  40. package/docs/payments.md +159 -0
  41. package/eslint.config.js +76 -0
  42. package/firebase.json +27 -0
  43. package/firestore.indexes.json +88 -0
  44. package/firestore.rules +125 -0
  45. package/functions/package-lock.json +2884 -0
  46. package/functions/package.json +20 -0
  47. package/functions/src/analytics.js +101 -0
  48. package/functions/src/audit.js +28 -0
  49. package/functions/src/auth.js +19 -0
  50. package/functions/src/drafts.js +128 -0
  51. package/functions/src/feedback.js +164 -0
  52. package/functions/src/firebase-init.js +29 -0
  53. package/functions/src/i18n.js +134 -0
  54. package/functions/src/limits.js +27 -0
  55. package/functions/src/llm.js +271 -0
  56. package/functions/src/ner.js +162 -0
  57. package/functions/src/notifications.js +273 -0
  58. package/functions/src/orgs.js +127 -0
  59. package/functions/src/patient-api.js +545 -0
  60. package/functions/src/payment.js +830 -0
  61. package/functions/src/safety.js +188 -0
  62. package/functions/src/stripe-config.js +33 -0
  63. package/functions/src/topics.js +94 -0
  64. package/functions/src/triage.js +191 -0
  65. package/functions/src/utils.js +28 -0
  66. package/functions/src/validate.js +177 -0
  67. package/functions/test-firebase.js +36 -0
  68. package/jest.config.js +18 -0
  69. package/package.json +52 -0
  70. package/public/404.html +93 -0
  71. package/public/app/analytics.html +336 -0
  72. package/public/app/analytics.js +177 -0
  73. package/public/app/app.css +1400 -0
  74. package/public/app/app.js +1754 -0
  75. package/public/app/config.js +14 -0
  76. package/public/app/inbox-threads.js +843 -0
  77. package/public/app/inbox.html +598 -0
  78. package/public/app/inbox.js +397 -0
  79. package/public/app/index.html +612 -0
  80. package/public/assets/logo.jpg +0 -0
  81. package/public/chat.html +137 -0
  82. package/public/css/chat.css +870 -0
  83. package/public/css/landing-enhance.css +298 -0
  84. package/public/css/styles.css +1198 -0
  85. package/public/favicon.svg +5 -0
  86. package/public/find-doctor.html +220 -0
  87. package/public/index.html +399 -0
  88. package/public/js/chat-threads.js +513 -0
  89. package/public/js/chat.js +832 -0
  90. package/public/js/search.js +41 -0
  91. package/public/privacy.html +101 -0
  92. package/public/robots.txt +6 -0
  93. package/public/site.webmanifest +12 -0
  94. package/public/terms.html +110 -0
  95. package/scripts/create-test-provider.js +55 -0
  96. package/scripts/deploy-staging.sh +51 -0
  97. package/scripts/fix-provider.js +39 -0
  98. package/scripts/quick-test.sh +37 -0
  99. package/scripts/test-chat.sh +54 -0
  100. package/scripts/test-multi-turn.js +137 -0
  101. package/scripts/test-ner-simple.js +28 -0
  102. package/scripts/test-ner.js +60 -0
  103. package/scripts/test-stripe-connect.js +304 -0
  104. package/scripts/test-webhook-secret.sh +24 -0
  105. package/server.js +3864 -0
  106. package/storage.rules +22 -0
  107. package/test-api.json +1 -0
  108. package/tests/emulator/README.md +38 -0
  109. package/tests/emulator/counters.emulator.test.js +81 -0
  110. package/tests/integration/api-account-selfserve.test.js +312 -0
  111. package/tests/integration/api-admin-digest.test.js +202 -0
  112. package/tests/integration/api-chat-satisfaction.test.js +259 -0
  113. package/tests/integration/api-doctor-drafts.test.js +236 -0
  114. package/tests/integration/api-doctor-inbox.test.js +401 -0
  115. package/tests/integration/api-org.test.js +328 -0
  116. package/tests/integration/api-payment.test.js +664 -0
  117. package/tests/integration/api-provider-settings.test.js +475 -0
  118. package/tests/integration/api-qr.test.js +121 -0
  119. package/tests/integration/api-security.test.js +241 -0
  120. package/tests/integration/api-stripe-search.test.js +303 -0
  121. package/tests/integration/api-webhook-deletion.test.js +231 -0
  122. package/tests/mocks/firebase-admin.js +220 -0
  123. package/tests/unit/analytics.test.js +151 -0
  124. package/tests/unit/audit-bugs.test.js +203 -0
  125. package/tests/unit/chat-ui-logic.test.js +283 -0
  126. package/tests/unit/drafts.test.js +104 -0
  127. package/tests/unit/feedback.test.js +148 -0
  128. package/tests/unit/i18n.test.js +229 -0
  129. package/tests/unit/llm-retry.test.js +122 -0
  130. package/tests/unit/notifications.test.js +155 -0
  131. package/tests/unit/orgs.test.js +328 -0
  132. package/tests/unit/payment.test.js +889 -0
  133. package/tests/unit/safety-edge-cases.test.js +509 -0
  134. package/tests/unit/safety-srs-compliance.test.js +252 -0
  135. package/tests/unit/safety.test.js +291 -0
  136. package/tests/unit/server-helpers.test.js +774 -0
  137. package/tests/unit/topics-limits.test.js +158 -0
  138. package/tests/unit/triage.test.js +152 -0
  139. package/tests/unit/validate.test.js +408 -0
@@ -0,0 +1,188 @@
1
+ const { analyzeMedicalSafety } = require('./ner');
2
+
3
+ // NER entity types that indicate personally identifiable information.
4
+ // dslim/bert-base-NER produces PER, LOC, ORG, MISC; medical models may
5
+ // produce PERSON, LOCATION, etc. We normalise to lowercase and check
6
+ // against this set.
7
+ const PII_ENTITY_TYPES = new Set([
8
+ 'per', 'person', // person names
9
+ 'loc', 'location', // locations (addresses, cities)
10
+ 'org', 'organization', // organisations (hospitals, clinics)
11
+ 'id', 'medical_record', // medical record numbers (future models)
12
+ 'norp' // nationalities / religious / political groups
13
+ ]);
14
+
15
+ /**
16
+ * Check whether any NER entities correspond to PII.
17
+ * @param {Array} entities – array returned by extractMedicalEntities
18
+ * @returns {boolean}
19
+ */
20
+ function entitiesContainPII(entities) {
21
+ if (!Array.isArray(entities) || entities.length === 0) return false;
22
+ return entities.some(e => {
23
+ const type = (e.type || '').toLowerCase().replace(/^[bi]-/, '');
24
+ return PII_ENTITY_TYPES.has(type);
25
+ });
26
+ }
27
+
28
+ // Input blocking: patterns that should never be submitted BY users (e.g., demanding a prescription)
29
+ const INPUT_BLOCKED = [
30
+ /\bprescribe me\b/i,
31
+ /\bgive me a diagnosis\b/i,
32
+ /\bwrite me a prescription\b/i,
33
+ /\boff[-\s]?label\b/i,
34
+ /\bimage shows|looks like\b/i,
35
+ /\brecommend.*(?:medication|treatment|drug|medicine)\b/i,
36
+ /\bbest (?:treatment|medication|drug|medicine) for\b/i,
37
+ /\bwhat (?:should i|do i) take for\b/i,
38
+ /\bhow many (?:tablets?|pills?|capsules?)\b/i,
39
+ /\binterpret (?:my|this|the) (?:x-?ray|scan|mri|ct|image|lab|result)\b/i
40
+ ];
41
+
42
+ // Output blocking: patterns that indicate the AI is making a definitive clinical statement.
43
+ // Uses negative look-behind / surrounding context to allow safe phrasing like
44
+ // "I cannot diagnose" or "consult your doctor for treatment".
45
+ const OUTPUT_BLOCKED = [
46
+ /\byou have (?!questions|concerns|options|access|the right|a right|every right)\w+/i,
47
+ /\byou (?:might|may|could|probably) have\b/i,
48
+ /\byou are (?:suffering|diagnosed|likely)\b/i,
49
+ /\bthis is (?:likely|probably|most likely)\b/i,
50
+ /\btake \d+\s?(mg|ml|tablets?|capsules?)\b/i,
51
+ /\b\d+\s?(mg|ml|mcg|units?) (?:twice|three times|once|every) (?:daily|a day|per day)\b/i,
52
+ /\b(start|stop) (taking|using) \w+/i,
53
+ /\bapply \d+\s?(mg|ml|times)\b/i,
54
+ /\bimage shows|this looks like\b/i,
55
+ /\bnot approved but\b/i,
56
+ /\btry it for\b/i,
57
+ /\bi recommend you (?:take|try|use|start)\b/i,
58
+ /\bhere is your prescription\b/i,
59
+ /\blooks like (?:pneumonia|tumor|fracture|cancer)\b/i
60
+ ];
61
+
62
+ const GLOBAL_REDFLAGS = [
63
+ /chest\s+(pain|hurts?|tightness|pressure)|trouble breathing|difficulty breathing|shortness of breath|can'?t breathe/i,
64
+ /seizure|fainting|stroke|sudden weakness|slurred speech/i,
65
+ /severe bleeding|uncontrolled bleeding/i,
66
+ /suicid(al|e)|self-harm|want to die|end my life|feel(s?|ing) like dying|kill myself/i,
67
+ /pregnan(t|cy).*(bleeding|severe pain)/i,
68
+ /newborn|infant.*(severe|difficulty|breathing)/i,
69
+ /severe allergic|anaphyla|swelling.*(face|throat|tongue)/i,
70
+ /unconscious|loss of consciousness|unresponsive/i,
71
+ /sudden vision (?:loss|change)|eye pain/i,
72
+ /overdose|took too (?:many|much)/i
73
+ ];
74
+
75
+ const PII_PATTERNS = [
76
+ /\b\d{3}-\d{2}-\d{4}\b/, // SSN
77
+ /\b\d{10}\b/, // phone-like
78
+ /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/, // email
79
+ /\b\d{1,5}\s+[A-Za-z]+(?:\s+[A-Za-z]+)*\s+(?:street|st|avenue|ave|road|rd|drive|dr|lane|ln|court|ct|blvd|boulevard|way|place|pl)\b/i, // street address (non-overlapping to avoid ReDoS)
80
+ /\b(?:zip|postal)\b[\s\w:.,-]{0,20}\d{5}(?:-\d{4})?\b/i, // ZIP code (context-gated to avoid false positives)
81
+ /\b(?:0[1-9]|1[0-2])\/(?:0[1-9]|[12]\d|3[01])\/(?:19|20)\d{2}\b/, // date of birth MM/DD/YYYY
82
+ /\b(?:\d{4}[-\s]?){3}\d{4}\b/ // credit card number (16 digits)
83
+ ];
84
+
85
+ const PII_REDACTIONS = [
86
+ // Credit-card first so its 16-digit run is fully masked before narrower
87
+ // numeric patterns (ZIP/phone) can touch any fragment of it.
88
+ { pattern: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g, replacement: "[redacted]" },
89
+ { pattern: /\b\d{3}-\d{2}-\d{4}\b/g, replacement: "[redacted]" },
90
+ { pattern: /\b\d{10}\b/g, replacement: "[redacted]" },
91
+ { pattern: /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g, replacement: "[redacted]" },
92
+ { pattern: /\b\d{1,5}\s+[A-Za-z]+(?:\s+[A-Za-z]+)*\s+(?:street|st|avenue|ave|road|rd|drive|dr|lane|ln|court|ct|blvd|boulevard|way|place|pl)\b/gi, replacement: "[redacted address]" },
93
+ { pattern: /\b(?:zip|postal)\b[\s\w:.,-]{0,20}\d{5}(?:-\d{4})?\b/gi, replacement: "[redacted]" },
94
+ { pattern: /\b(?:0[1-9]|1[0-2])\/(?:0[1-9]|[12]\d|3[01])\/(?:19|20)\d{2}\b/g, replacement: "[redacted DOB]" }
95
+ ];
96
+
97
+ function evaluateText(text, patterns) {
98
+ return patterns.filter((pattern) => {
99
+ pattern.lastIndex = 0;
100
+ return pattern.test(text);
101
+ });
102
+ }
103
+
104
+ async function analyzeInput(message, topicConfig) {
105
+ const text = String(message || "");
106
+ const config = topicConfig || {};
107
+
108
+ const blocked = evaluateText(text, INPUT_BLOCKED);
109
+ const redflags = evaluateText(text, GLOBAL_REDFLAGS);
110
+ const topicBlocked = evaluateText(text, config.forbiddenPatterns || []);
111
+ const pii = evaluateText(text, PII_PATTERNS);
112
+
113
+ // Run NER analysis in parallel (graceful fallback on failure)
114
+ let nerAnalysis = { shouldEscalate: false, entities: [], analysis: {} };
115
+ try {
116
+ nerAnalysis = await analyzeMedicalSafety(text);
117
+ } catch (err) {
118
+ console.error('[Safety] NER analysis failed, continuing without it:', err.message);
119
+ }
120
+
121
+ const flags = [];
122
+ if (blocked.length || topicBlocked.length) flags.push("blocked_input");
123
+ if (redflags.length || nerAnalysis.shouldEscalate) flags.push("red_flag");
124
+ const nerPII = entitiesContainPII(nerAnalysis.entities);
125
+ if (pii.length || nerPII) flags.push("pii_detected");
126
+ if (nerAnalysis.entities.length > 0) flags.push("medical_entities_detected");
127
+
128
+ return {
129
+ blocked: blocked.length > 0 || topicBlocked.length > 0,
130
+ escalated: redflags.length > 0 || nerAnalysis.shouldEscalate,
131
+ flags,
132
+ entities: nerAnalysis.entities,
133
+ nerAnalysis: nerAnalysis.analysis
134
+ };
135
+ }
136
+
137
+ function analyzeOutput(text, topicConfig) {
138
+ const output = String(text || "");
139
+ const config = topicConfig || {};
140
+ const blocked = evaluateText(output, OUTPUT_BLOCKED);
141
+ const topicBlocked = evaluateText(output, config.forbiddenPatterns || []);
142
+
143
+ const flags = [];
144
+ if (blocked.length || topicBlocked.length) flags.push("blocked_output");
145
+
146
+ return {
147
+ blocked: blocked.length > 0 || topicBlocked.length > 0,
148
+ flags
149
+ };
150
+ }
151
+
152
+ function redactText(text) {
153
+ let result = String(text || "");
154
+ PII_REDACTIONS.forEach(({ pattern, replacement }) => {
155
+ result = result.replace(pattern, replacement);
156
+ });
157
+ return result;
158
+ }
159
+
160
+ // Locale-aware patient-facing messages. English preserves the original wording;
161
+ // other supported locales are served from the i18n module (falling back to English).
162
+ const { t } = require('./i18n');
163
+
164
+ function refusalMessage(locale) {
165
+ if (!locale || locale === 'en') {
166
+ return "I can't help with that. For medical advice, please contact a licensed clinician.";
167
+ }
168
+ return t('refusal', locale);
169
+ }
170
+
171
+ function escalationMessage(locale) {
172
+ if (!locale || locale === 'en') {
173
+ return "Your symptoms may need urgent attention. Please seek professional care or local emergency services right away.";
174
+ }
175
+ return t('escalation', locale);
176
+ }
177
+
178
+ module.exports = {
179
+ analyzeInput,
180
+ analyzeOutput,
181
+ redactText,
182
+ refusalMessage,
183
+ escalationMessage,
184
+ INPUT_BLOCKED,
185
+ OUTPUT_BLOCKED,
186
+ GLOBAL_REDFLAGS,
187
+ PII_PATTERNS
188
+ };
@@ -0,0 +1,33 @@
1
+ // Stripe Configuration
2
+ const Stripe = require('stripe');
3
+
4
+ let stripeClient = null;
5
+
6
+ function hasStripeSecretKey() {
7
+ return Boolean(process.env.STRIPE_SECRET_KEY);
8
+ }
9
+
10
+ function getStripeClient() {
11
+ if (!hasStripeSecretKey()) {
12
+ throw new Error('STRIPE_SECRET_KEY is not configured');
13
+ }
14
+
15
+ if (!stripeClient) {
16
+ stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY);
17
+ }
18
+
19
+ return stripeClient;
20
+ }
21
+
22
+ // Lazy proxy keeps module imports safe at startup and throws only when Stripe is used.
23
+ const stripe = new Proxy({}, {
24
+ get(_target, prop) {
25
+ return getStripeClient()[prop];
26
+ }
27
+ });
28
+
29
+ module.exports = {
30
+ stripe,
31
+ hasStripeSecretKey,
32
+ getStripeClient
33
+ };
@@ -0,0 +1,94 @@
1
+ const TOPICS = {
2
+ medication_info_safety: {
3
+ title: "Medication info & safety",
4
+ disclaimer:
5
+ "Information only. Do not use this as medical advice. Contact a licensed clinician for guidance.",
6
+ prompt:
7
+ "Provide general medication information and safety warnings without dosing, diagnosis, or treatment advice.",
8
+ forbiddenPatterns: [
9
+ /\b(start|stop|change)\b.*\bmeds?\b/i,
10
+ /\b(dose|dosage|tablets?)\b/i,
11
+ /\d+\s*(mg|ml)\b/i
12
+ ],
13
+ escalationTriggers: [
14
+ /chest pain|trouble breathing|seizure|fainting/i,
15
+ /suicid(al|e)|self-harm/i
16
+ ]
17
+ },
18
+ appointments_preparation: {
19
+ title: "Appointment preparation",
20
+ disclaimer:
21
+ "General preparation info only. For urgent concerns, seek professional care.",
22
+ prompt:
23
+ "Explain how to prepare for an appointment with non-medical logistics and common questions.",
24
+ forbiddenPatterns: [/\bdiagnos(e|is)|treat(ment)?\b/i],
25
+ escalationTriggers: []
26
+ },
27
+ general_health_info: {
28
+ title: "General health info",
29
+ disclaimer:
30
+ "General health information only. This is not medical advice.",
31
+ prompt:
32
+ "Provide general health education without diagnosis, treatment, or dosing.",
33
+ forbiddenPatterns: [/\bdiagnos(e|is)|treat(ment)?\b/i],
34
+ escalationTriggers: [
35
+ /chest pain|trouble breathing|severe bleeding|stroke/i,
36
+ /pregnan(t|cy).*(bleeding|severe pain)/i
37
+ ]
38
+ },
39
+ clinic_services_hours: {
40
+ title: "Clinic services & hours",
41
+ disclaimer:
42
+ "Clinic info only. For emergencies, call local emergency services.",
43
+ prompt:
44
+ "Answer questions about clinic services, hours, and location.",
45
+ forbiddenPatterns: [/\bdiagnos(e|is)|treat(ment)?\b/i],
46
+ escalationTriggers: []
47
+ },
48
+ insurance_billing_faq: {
49
+ title: "Insurance & billing",
50
+ disclaimer:
51
+ "General billing info only. Contact the clinic for account-specific questions.",
52
+ prompt:
53
+ "Provide general insurance and billing explanations without accessing accounts.",
54
+ forbiddenPatterns: [/\b(ssn|social security|card number)\b/i],
55
+ escalationTriggers: []
56
+ },
57
+ post_procedure_guidance: {
58
+ title: "Post-procedure guidance",
59
+ disclaimer:
60
+ "General guidance only. If you have concerning symptoms, seek care right away.",
61
+ prompt:
62
+ "Provide non-personalized, general post-procedure care tips without dosing or treatment advice.",
63
+ forbiddenPatterns: [/\b(dose|dosage|mg|ml|tablets?)\b/i],
64
+ escalationTriggers: [/severe pain|high fever|uncontrolled bleeding/i]
65
+ }
66
+ };
67
+
68
+ const TOPIC_KEYS = Object.keys(TOPICS);
69
+
70
+ function inferTopic(message) {
71
+ const lower = String(message || "").toLowerCase();
72
+ if (/(appointment|schedule|visit|check-in)/.test(lower)) {
73
+ return "appointments_preparation";
74
+ }
75
+ if (/(hours|open|closed|locat(ion|ed)|address|parking)/.test(lower)) {
76
+ return "clinic_services_hours";
77
+ }
78
+ if (/(insurance|billing|copay|claim)/.test(lower)) {
79
+ return "insurance_billing_faq";
80
+ }
81
+ if (/(after surgery|post-op|post procedure|after procedure)/.test(lower)) {
82
+ return "post_procedure_guidance";
83
+ }
84
+ if (/(medicine|medication|side effect|drug)/.test(lower)) {
85
+ return "medication_info_safety";
86
+ }
87
+ return "general_health_info";
88
+ }
89
+
90
+ module.exports = {
91
+ TOPICS,
92
+ TOPIC_KEYS,
93
+ inferTopic
94
+ };
@@ -0,0 +1,191 @@
1
+ const OpenAI = require('openai');
2
+ const { GoogleGenerativeAI } = require('@google/generative-ai');
3
+
4
+ /**
5
+ * Triage Classification Module
6
+ *
7
+ * Auto-classifies conversations into:
8
+ * - urgent: Red flags, needs immediate doctor attention
9
+ * - needs_review: AI uncertain, wants doctor confirmation
10
+ * - informational: AI handled it, doctor can review later
11
+ * - resolved: Patient satisfied, no action needed
12
+ */
13
+
14
+ const TRIAGE_CATEGORIES = ['urgent', 'needs_review', 'informational', 'resolved'];
15
+
16
+ const TRIAGE_SYSTEM_PROMPT = `You are a medical conversation triage classifier. Analyze the conversation and classify it into exactly ONE category.
17
+
18
+ CATEGORIES:
19
+ - "urgent": Patient mentions emergency symptoms (chest pain, breathing difficulty, severe bleeding, suicidal thoughts, seizures), or the conversation was flagged/escalated for safety reasons. Needs immediate doctor attention.
20
+ - "needs_review": Patient asked a specific medical question the AI couldn't fully address, expressed dissatisfaction with the AI response, or the AI gave advice that a doctor should verify. Doctor should review soon.
21
+ - "informational": Patient asked general health questions (hours, billing, how-to, wellness tips) and the AI provided a satisfactory answer. Doctor can review at leisure.
22
+ - "resolved": Conversation has naturally concluded - patient thanked the AI, said goodbye, or indicated their question was answered. No doctor action needed.
23
+
24
+ RULES:
25
+ - If the conversation was flagged as escalated, ALWAYS classify as "urgent"
26
+ - If the patient's last message is a question and the AI's response seems uncertain or hedging, classify as "needs_review"
27
+ - If the conversation is only 1-2 messages and seems like a greeting or simple question, classify as "informational"
28
+ - Default to "needs_review" when uncertain
29
+
30
+ Respond with ONLY a JSON object, no other text:
31
+ {"category": "...", "confidence": 0.0-1.0, "reason": "brief explanation"}`;
32
+
33
+ /**
34
+ * Classify a conversation for doctor triage
35
+ */
36
+ async function classifyConversation({ messages, isEscalated, currentTriage }) {
37
+ // If already escalated, always urgent
38
+ if (isEscalated) {
39
+ return {
40
+ category: 'urgent',
41
+ confidence: 1.0,
42
+ reason: 'Conversation flagged for safety escalation'
43
+ };
44
+ }
45
+
46
+ // Build a summary of recent messages (last 6)
47
+ const recentMessages = (messages || []).slice(-6);
48
+ if (recentMessages.length === 0) {
49
+ return {
50
+ category: 'informational',
51
+ confidence: 0.5,
52
+ reason: 'No messages to classify'
53
+ };
54
+ }
55
+
56
+ const conversationSummary = recentMessages.map(msg => {
57
+ const role = msg.role === 'user' || msg.role === 'patient' ? 'Patient' : 'AI';
58
+ return `${role}: ${(msg.content || msg.message || '').substring(0, 200)}`;
59
+ }).join('\n');
60
+
61
+ const prompt = `Classify this medical conversation:\n\n${conversationSummary}`;
62
+
63
+ try {
64
+ const result = await callTriageLLM(prompt);
65
+ const parsed = parseTriageResponse(result);
66
+ // Low-confidence safety net: escalate to needs_review if LLM is uncertain
67
+ if (parsed.confidence < 0.5 && parsed.category === 'informational') {
68
+ parsed.category = 'needs_review';
69
+ parsed.reason = `Low confidence (${parsed.confidence}): ${parsed.reason}`;
70
+ }
71
+ return parsed;
72
+ } catch (err) {
73
+ console.error('[Triage] LLM classification failed:', err.message);
74
+ return ruleBasedTriage(recentMessages, isEscalated);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Call LLM for triage classification - uses Gemini (fast/cheap) first, DeepSeek fallback
80
+ */
81
+ async function callTriageLLM(prompt) {
82
+ // Try Gemini first (faster and cheaper for classification)
83
+ if (process.env.GEMINI_API_KEY) {
84
+ try {
85
+ const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
86
+ const model = genAI.getGenerativeModel({
87
+ model: 'gemini-2.5-flash',
88
+ systemInstruction: TRIAGE_SYSTEM_PROMPT
89
+ });
90
+
91
+ const result = await model.generateContent({
92
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
93
+ generationConfig: {
94
+ maxOutputTokens: 100,
95
+ temperature: 0.1
96
+ }
97
+ });
98
+
99
+ return result.response.text().trim();
100
+ } catch (err) {
101
+ console.error('[Triage] Gemini failed:', err.message);
102
+ }
103
+ }
104
+
105
+ // Fallback to DeepSeek
106
+ if (process.env.DEEPSEEK_API_KEY) {
107
+ try {
108
+ const client = new OpenAI({
109
+ apiKey: process.env.DEEPSEEK_API_KEY,
110
+ baseURL: 'https://api.deepseek.com'
111
+ });
112
+
113
+ const response = await client.chat.completions.create({
114
+ model: 'deepseek-chat',
115
+ messages: [
116
+ { role: 'system', content: TRIAGE_SYSTEM_PROMPT },
117
+ { role: 'user', content: prompt }
118
+ ],
119
+ max_tokens: 100,
120
+ temperature: 0.1
121
+ });
122
+
123
+ return response.choices[0].message.content.trim();
124
+ } catch (err) {
125
+ console.error('[Triage] DeepSeek failed:', err.message);
126
+ }
127
+ }
128
+
129
+ throw new Error('No LLM available for triage');
130
+ }
131
+
132
+ /**
133
+ * Parse LLM triage response
134
+ */
135
+ function parseTriageResponse(response) {
136
+ try {
137
+ // Extract JSON from response (handle markdown code blocks)
138
+ const jsonMatch = response.match(/\{[\s\S]*?\}/);
139
+ if (!jsonMatch) throw new Error('No JSON found');
140
+
141
+ const parsed = JSON.parse(jsonMatch[0]);
142
+
143
+ if (!TRIAGE_CATEGORIES.includes(parsed.category)) {
144
+ return { category: 'needs_review', confidence: 0.5, reason: 'Invalid category from LLM' };
145
+ }
146
+
147
+ return {
148
+ category: parsed.category,
149
+ confidence: Math.min(1, Math.max(0, Number(parsed.confidence) || 0.5)),
150
+ reason: String(parsed.reason || '').substring(0, 200)
151
+ };
152
+ } catch (err) {
153
+ return { category: 'needs_review', confidence: 0.5, reason: 'Failed to parse LLM response' };
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Rule-based triage fallback when LLM is unavailable
159
+ */
160
+ function ruleBasedTriage(messages, isEscalated) {
161
+ if (isEscalated) {
162
+ return { category: 'urgent', confidence: 1.0, reason: 'Escalation flag set' };
163
+ }
164
+
165
+ const lastMessage = messages[messages.length - 1];
166
+ const text = (lastMessage.content || lastMessage.message || '').toLowerCase();
167
+
168
+ // Check for urgent keywords
169
+ const urgentKeywords = ['chest pain', 'breathing', 'seizure', 'suicide', 'bleeding', 'emergency', 'help me'];
170
+ if (urgentKeywords.some(kw => text.includes(kw))) {
171
+ return { category: 'urgent', confidence: 0.8, reason: 'Urgent keywords detected' };
172
+ }
173
+
174
+ // Check for resolved signals
175
+ const resolvedKeywords = ['thank', 'thanks', 'goodbye', 'bye', 'got it', 'that helps', 'perfect'];
176
+ if (resolvedKeywords.some(kw => text.includes(kw))) {
177
+ return { category: 'resolved', confidence: 0.7, reason: 'Patient satisfaction signals' };
178
+ }
179
+
180
+ // Check if it's a question
181
+ if (text.includes('?') || text.startsWith('what') || text.startsWith('how') || text.startsWith('can')) {
182
+ return { category: 'needs_review', confidence: 0.6, reason: 'Patient asking questions' };
183
+ }
184
+
185
+ return { category: 'informational', confidence: 0.5, reason: 'General conversation' };
186
+ }
187
+
188
+ module.exports = {
189
+ classifyConversation,
190
+ TRIAGE_CATEGORIES
191
+ };
@@ -0,0 +1,28 @@
1
+ function jsonResponse(res, status, payload) {
2
+ res.status(status).set("Content-Type", "application/json");
3
+ res.send(JSON.stringify(payload));
4
+ }
5
+
6
+ function readBody(req) {
7
+ if (req.body) return req.body;
8
+ return null;
9
+ }
10
+
11
+ function ensureJson(req, res) {
12
+ if (!req.is("application/json")) {
13
+ jsonResponse(res, 415, { error: "Expected application/json" });
14
+ return false;
15
+ }
16
+ return true;
17
+ }
18
+
19
+ function nowTimestamp(admin) {
20
+ return admin.firestore.FieldValue.serverTimestamp();
21
+ }
22
+
23
+ module.exports = {
24
+ jsonResponse,
25
+ readBody,
26
+ ensureJson,
27
+ nowTimestamp
28
+ };