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,20 @@
1
+ {
2
+ "name": "evaldoc-functions",
3
+ "private": true,
4
+ "main": "src/index.js",
5
+ "engines": {
6
+ "node": "20"
7
+ },
8
+ "scripts": {
9
+ "lint": "echo 'no lint configured'",
10
+ "serve": "firebase emulators:start --only functions,firestore,hosting",
11
+ "deploy": "firebase deploy --only functions"
12
+ },
13
+ "dependencies": {
14
+ "firebase-admin": "^12.2.0",
15
+ "firebase-functions": "^5.1.0",
16
+ "openai": "^4.20.0",
17
+ "@google/generative-ai": "^0.1.3",
18
+ "dotenv": "^16.3.1"
19
+ }
20
+ }
@@ -0,0 +1,101 @@
1
+ const admin = require("firebase-admin");
2
+
3
+ function getDateKey(date = new Date()) {
4
+ const year = date.getUTCFullYear();
5
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
6
+ const day = String(date.getUTCDate()).padStart(2, "0");
7
+ return `${year}-${month}-${day}`;
8
+ }
9
+
10
+ function getMonthKey(date = new Date()) {
11
+ const year = date.getUTCFullYear();
12
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
13
+ return `${year}-${month}`;
14
+ }
15
+
16
+ function extractKeywords(message) {
17
+ const text = String(message || "")
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9\s]/g, " ");
20
+ const stop = new Set([
21
+ "the",
22
+ "and",
23
+ "for",
24
+ "with",
25
+ "that",
26
+ "this",
27
+ "from",
28
+ "have",
29
+ "what",
30
+ "when",
31
+ "where",
32
+ "your",
33
+ "you",
34
+ "are",
35
+ "can",
36
+ "how",
37
+ "does",
38
+ "about"
39
+ ]);
40
+ const words = text
41
+ .split(/\s+/)
42
+ .filter((word) => word.length > 3 && !stop.has(word));
43
+ return words.slice(0, 2);
44
+ }
45
+
46
+ async function incrementAnalytics({ providerId, sessionId, escalated, satisfaction, topicKey, message }) {
47
+ const db = admin.firestore();
48
+ const dateKey = getDateKey();
49
+ const monthKey = getMonthKey();
50
+ const dailyRef = db
51
+ .collection("providers")
52
+ .doc(providerId)
53
+ .collection("analyticsDaily")
54
+ .doc(dateKey);
55
+
56
+ const updates = {
57
+ chatCount: admin.firestore.FieldValue.increment(1),
58
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
59
+ };
60
+
61
+ // Track unique sessions per day using a sub-field set
62
+ if (sessionId) {
63
+ updates[`sessions.${sessionId}`] = true;
64
+ }
65
+
66
+ if (escalated) {
67
+ updates.escalationCount = admin.firestore.FieldValue.increment(1);
68
+ }
69
+ if (typeof satisfaction === "number") {
70
+ updates.satisfactionSum = admin.firestore.FieldValue.increment(satisfaction);
71
+ updates.satisfactionCount = admin.firestore.FieldValue.increment(1);
72
+ }
73
+ if (topicKey) {
74
+ updates[`topicCounts.${topicKey}`] = admin.firestore.FieldValue.increment(1);
75
+ }
76
+ const keywords = extractKeywords(message);
77
+ keywords.forEach((word) => {
78
+ updates[`questionBuckets.${word}`] = admin.firestore.FieldValue.increment(1);
79
+ });
80
+
81
+ const providerRef = db.collection("providers").doc(providerId);
82
+ const providerUpdates = {
83
+ monthlyChatCount: admin.firestore.FieldValue.increment(1),
84
+ monthlyChatCountMonth: monthKey,
85
+ totalChatCount: admin.firestore.FieldValue.increment(1),
86
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
87
+ };
88
+ if (escalated) {
89
+ providerUpdates.totalEscalationCount = admin.firestore.FieldValue.increment(1);
90
+ }
91
+
92
+ const batch = db.batch();
93
+ batch.set(dailyRef, updates, { merge: true });
94
+ batch.set(providerRef, providerUpdates, { merge: true });
95
+ await batch.commit();
96
+ }
97
+
98
+ module.exports = {
99
+ incrementAnalytics,
100
+ getMonthKey
101
+ };
@@ -0,0 +1,28 @@
1
+ const admin = require('firebase-admin');
2
+
3
+ /**
4
+ * HIPAA-compliant audit logging.
5
+ * Logs access and modification events to Firestore auditLog collection.
6
+ *
7
+ * Every entry records: who, what, when, and the outcome.
8
+ */
9
+ async function logAuditEvent({ action, actorId, actorType, resourceType, resourceId, outcome, meta }) {
10
+ try {
11
+ const db = admin.firestore();
12
+ await db.collection('auditLog').add({
13
+ action,
14
+ actorId: actorId || 'system',
15
+ actorType: actorType || 'system',
16
+ resourceType,
17
+ resourceId: resourceId || null,
18
+ outcome: outcome || 'success',
19
+ meta: meta || {},
20
+ timestamp: admin.firestore.FieldValue.serverTimestamp()
21
+ });
22
+ } catch (err) {
23
+ // Audit logging must never crash the application
24
+ console.error('[Audit] Failed to write audit log:', err.message);
25
+ }
26
+ }
27
+
28
+ module.exports = { logAuditEvent };
@@ -0,0 +1,19 @@
1
+ const admin = require("firebase-admin");
2
+
3
+ async function verifyAuthHeader(authHeader) {
4
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
5
+ return null;
6
+ }
7
+
8
+ const token = authHeader.slice("Bearer ".length);
9
+
10
+ try {
11
+ return await admin.auth().verifyIdToken(token);
12
+ } catch (err) {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ module.exports = {
18
+ verifyAuthHeader
19
+ };
@@ -0,0 +1,128 @@
1
+ const OpenAI = require('openai');
2
+ const { GoogleGenerativeAI } = require('@google/generative-ai');
3
+
4
+ /**
5
+ * AI-Drafted Doctor Responses Module
6
+ *
7
+ * When a conversation is classified as needs_review or urgent,
8
+ * generates a suggested response for the doctor to review, edit, and send.
9
+ */
10
+
11
+ /**
12
+ * Generate a draft doctor response for a conversation
13
+ */
14
+ async function generateDoctorDraft({ messages, provider, topic, triageCategory, doctorExamples }) {
15
+ const providerName = provider.displayName || 'Doctor';
16
+ const specialty = provider.specialty || 'healthcare';
17
+
18
+ // Build conversation summary from recent messages
19
+ const recentMessages = (messages || []).slice(-8);
20
+ const conversationSummary = recentMessages.map(msg => {
21
+ const role = msg.role === 'user' || msg.role === 'patient' ? 'Patient' : 'AI Assistant';
22
+ return `${role}: ${(msg.content || msg.message || '').substring(0, 300)}`;
23
+ }).join('\n');
24
+
25
+ // Find the last patient message
26
+ const lastPatientMsg = [...recentMessages].reverse().find(
27
+ m => m.role === 'user' || m.role === 'patient'
28
+ );
29
+ const patientQuestion = lastPatientMsg
30
+ ? (lastPatientMsg.content || lastPatientMsg.message || '')
31
+ : '';
32
+
33
+ // Build doctor examples section
34
+ let examplesSection = '';
35
+ if (doctorExamples && doctorExamples.length > 0) {
36
+ examplesSection = `\n\nHere are examples of how ${providerName} has responded to similar questions previously. Match their tone and style:\n`;
37
+ doctorExamples.slice(0, 3).forEach((ex, i) => {
38
+ examplesSection += `\nExample ${i + 1}:\nPatient asked: ${ex.patientMessage}\nDoctor replied: ${ex.doctorResponse}\n`;
39
+ });
40
+ }
41
+
42
+ const systemPrompt = `You are drafting a response for ${providerName}, a ${specialty} provider, to send to their patient. The doctor will review and edit your draft before sending it.
43
+
44
+ GUIDELINES:
45
+ - Write as if you ARE the doctor (first person: "I recommend...", "In my experience...")
46
+ - Be specific and clinically accurate for a ${specialty} context
47
+ - Address the patient's actual question directly
48
+ - Keep it professional but warm - match a real doctor's communication style
49
+ - Include any relevant follow-up steps or recommendations
50
+ - If the patient's concern is urgent (triage: ${triageCategory}), acknowledge the urgency
51
+ - Length: 2-4 sentences for simple questions, 4-6 for complex ones
52
+ - Do NOT include disclaimers about being an AI - this will be sent as the doctor's own words
53
+ ${examplesSection}
54
+
55
+ Topic context: ${topic || 'general health'}`;
56
+
57
+ const userPrompt = `Draft a doctor response for this conversation:\n\n${conversationSummary}\n\nThe patient's key question/concern: "${patientQuestion.substring(0, 500)}"`;
58
+
59
+ try {
60
+ const draft = await callDraftLLM(systemPrompt, userPrompt);
61
+ return {
62
+ draft: draft,
63
+ generatedAt: new Date().toISOString(),
64
+ status: 'pending'
65
+ };
66
+ } catch (err) {
67
+ console.error('[Drafts] LLM draft generation failed:', err.message);
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Call LLM for draft generation - DeepSeek primary (better quality), Gemini fallback
74
+ */
75
+ async function callDraftLLM(systemPrompt, userPrompt) {
76
+ // Try DeepSeek first (better quality for medical drafts)
77
+ if (process.env.DEEPSEEK_API_KEY) {
78
+ try {
79
+ const client = new OpenAI({
80
+ apiKey: process.env.DEEPSEEK_API_KEY,
81
+ baseURL: 'https://api.deepseek.com'
82
+ });
83
+
84
+ const response = await client.chat.completions.create({
85
+ model: 'deepseek-chat',
86
+ messages: [
87
+ { role: 'system', content: systemPrompt },
88
+ { role: 'user', content: userPrompt }
89
+ ],
90
+ max_tokens: 500,
91
+ temperature: 0.3
92
+ });
93
+
94
+ return response.choices[0].message.content.trim();
95
+ } catch (err) {
96
+ console.error('[Drafts] DeepSeek failed:', err.message);
97
+ }
98
+ }
99
+
100
+ // Fallback to Gemini
101
+ if (process.env.GEMINI_API_KEY) {
102
+ try {
103
+ const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
104
+ const model = genAI.getGenerativeModel({
105
+ model: 'gemini-2.5-flash',
106
+ systemInstruction: systemPrompt
107
+ });
108
+
109
+ const result = await model.generateContent({
110
+ contents: [{ role: 'user', parts: [{ text: userPrompt }] }],
111
+ generationConfig: {
112
+ maxOutputTokens: 500,
113
+ temperature: 0.3
114
+ }
115
+ });
116
+
117
+ return result.response.text().trim();
118
+ } catch (err) {
119
+ console.error('[Drafts] Gemini failed:', err.message);
120
+ }
121
+ }
122
+
123
+ throw new Error('No LLM available for draft generation');
124
+ }
125
+
126
+ module.exports = {
127
+ generateDoctorDraft
128
+ };
@@ -0,0 +1,164 @@
1
+ const admin = require('firebase-admin');
2
+
3
+ /**
4
+ * Doctor Response Feedback Loop Module
5
+ *
6
+ * When a doctor responds (or accepts/edits an AI draft), this module:
7
+ * 1. Captures the doctor's response as an "example" linked to the patient question
8
+ * 2. Retrieves relevant examples for future AI prompt injection
9
+ *
10
+ * This gives the AI the doctor's "voice" without model fine-tuning.
11
+ */
12
+
13
+ /**
14
+ * Capture a doctor's response as a training example
15
+ */
16
+ async function captureDoctorExample({
17
+ doctorId,
18
+ threadId,
19
+ topic,
20
+ patientMessage,
21
+ doctorResponse,
22
+ wasAiDraft,
23
+ aiDraftOriginal
24
+ }) {
25
+ const db = admin.firestore();
26
+
27
+ // Calculate edit distance ratio if draft was edited
28
+ let editDistance = null;
29
+ if (wasAiDraft && aiDraftOriginal) {
30
+ editDistance = calculateEditRatio(aiDraftOriginal, doctorResponse);
31
+ }
32
+
33
+ const example = {
34
+ threadId,
35
+ topic: topic || 'general_health_info',
36
+ patientMessage: (patientMessage || '').substring(0, 500),
37
+ doctorResponse: (doctorResponse || '').substring(0, 1000),
38
+ wasAiDraft: wasAiDraft || false,
39
+ aiDraftOriginal: wasAiDraft ? (aiDraftOriginal || '').substring(0, 1000) : null,
40
+ editDistance,
41
+ createdAt: admin.firestore.FieldValue.serverTimestamp()
42
+ };
43
+
44
+ const ref = await db
45
+ .collection('providers')
46
+ .doc(doctorId)
47
+ .collection('doctorExamples')
48
+ .add(example);
49
+
50
+ console.log(`[Feedback] Captured doctor example ${ref.id} for doctor ${doctorId}`);
51
+
52
+ // Update the thread to mark feedback was processed
53
+ await db.collection('threads').doc(threadId).update({
54
+ doctorResponseFedBack: true,
55
+ feedbackProcessedAt: admin.firestore.FieldValue.serverTimestamp()
56
+ }).catch(err => console.error('[Feedback] Failed to update thread:', err.message));
57
+
58
+ return { id: ref.id, ...example };
59
+ }
60
+
61
+ /**
62
+ * Retrieve relevant doctor examples for prompt injection
63
+ *
64
+ * Fetches the most recent examples from this doctor that match the topic,
65
+ * to be injected into the AI system prompt as few-shot examples.
66
+ */
67
+ async function retrieveRelevantExamples({ doctorId, topic, limit = 3 }) {
68
+ const db = admin.firestore();
69
+
70
+ // First try topic-specific examples
71
+ let snapshot = await db
72
+ .collection('providers')
73
+ .doc(doctorId)
74
+ .collection('doctorExamples')
75
+ .where('topic', '==', topic || 'general_health_info')
76
+ .orderBy('createdAt', 'desc')
77
+ .limit(limit)
78
+ .get();
79
+
80
+ let examples = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
81
+
82
+ // If not enough topic-specific examples, fill with recent general examples
83
+ if (examples.length < limit) {
84
+ const remaining = limit - examples.length;
85
+ const existingIds = new Set(examples.map(e => e.id));
86
+
87
+ const generalSnapshot = await db
88
+ .collection('providers')
89
+ .doc(doctorId)
90
+ .collection('doctorExamples')
91
+ .orderBy('createdAt', 'desc')
92
+ .limit(remaining + examples.length) // fetch extra to account for dupes
93
+ .get();
94
+
95
+ generalSnapshot.docs.forEach(doc => {
96
+ if (!existingIds.has(doc.id) && examples.length < limit) {
97
+ examples.push({ id: doc.id, ...doc.data() });
98
+ }
99
+ });
100
+ }
101
+
102
+ return examples;
103
+ }
104
+
105
+ /**
106
+ * Calculate a simple edit distance ratio between two strings
107
+ * Returns 0.0 (identical) to 1.0 (completely different)
108
+ */
109
+ function calculateEditRatio(original, edited) {
110
+ if (!original || !edited) return 1.0;
111
+ if (original === edited) return 0.0;
112
+
113
+ const maxLen = Math.max(original.length, edited.length);
114
+ if (maxLen === 0) return 0.0;
115
+
116
+ // Simple character-level difference approximation
117
+ let changes = 0;
118
+ const minLen = Math.min(original.length, edited.length);
119
+
120
+ for (let i = 0; i < minLen; i++) {
121
+ if (original[i] !== edited[i]) changes++;
122
+ }
123
+ changes += Math.abs(original.length - edited.length);
124
+
125
+ return Math.min(1.0, changes / maxLen);
126
+ }
127
+
128
+ /**
129
+ * Get feedback statistics for a doctor
130
+ */
131
+ async function getFeedbackStats({ doctorId }) {
132
+ const db = admin.firestore();
133
+
134
+ const snapshot = await db
135
+ .collection('providers')
136
+ .doc(doctorId)
137
+ .collection('doctorExamples')
138
+ .orderBy('createdAt', 'desc')
139
+ .limit(100)
140
+ .get();
141
+
142
+ const examples = snapshot.docs.map(doc => doc.data());
143
+
144
+ const totalExamples = examples.length;
145
+ const aiDraftBased = examples.filter(e => e.wasAiDraft).length;
146
+ const manualResponses = totalExamples - aiDraftBased;
147
+ const avgEditDistance = aiDraftBased > 0
148
+ ? examples.filter(e => e.wasAiDraft && e.editDistance !== null)
149
+ .reduce((sum, e) => sum + e.editDistance, 0) / aiDraftBased
150
+ : null;
151
+
152
+ return {
153
+ totalExamples,
154
+ aiDraftBased,
155
+ manualResponses,
156
+ avgEditDistance: avgEditDistance !== null ? Math.round(avgEditDistance * 100) / 100 : null
157
+ };
158
+ }
159
+
160
+ module.exports = {
161
+ captureDoctorExample,
162
+ retrieveRelevantExamples,
163
+ getFeedbackStats
164
+ };
@@ -0,0 +1,29 @@
1
+ const admin = require('firebase-admin');
2
+
3
+ /**
4
+ * Shared Firebase Admin initialization.
5
+ * Ensures a single admin instance across server.js and Cloud Functions.
6
+ *
7
+ * Usage:
8
+ * const { admin, db } = require('./firebase-init');
9
+ */
10
+ function initializeFirebase(options = {}) {
11
+ if (admin.apps.length) {
12
+ return { admin, db: admin.firestore() };
13
+ }
14
+
15
+ if (options.credential) {
16
+ admin.initializeApp({
17
+ credential: options.credential,
18
+ projectId: options.projectId || process.env.PROJECT_ID || 'evaldocplatform'
19
+ });
20
+ } else {
21
+ admin.initializeApp({
22
+ projectId: options.projectId || process.env.PROJECT_ID || 'evaldocplatform'
23
+ });
24
+ }
25
+
26
+ return { admin, db: admin.firestore() };
27
+ }
28
+
29
+ module.exports = { admin, initializeFirebase };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * i18n.js — Localization module for safety-critical patient-facing text.
3
+ *
4
+ * Self-contained: no dependencies on safety.js or topics.js. English is the
5
+ * source of truth; missing locales or keys gracefully fall back to English.
6
+ *
7
+ * Intended to be wired into safety.js (refusalMessage/escalationMessage) and
8
+ * topics.js (per-topic disclaimers) later without breaking existing behavior.
9
+ */
10
+
11
+ // Supported base locales. The provider `locale` field should normalize to one
12
+ // of these via normalizeLocale().
13
+ const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de', 'pt', 'ar'];
14
+
15
+ const DEFAULT_LOCALE = 'en';
16
+
17
+ /**
18
+ * Translation table. Every key MUST exist in every supported locale.
19
+ * English is authoritative; other locales are natural translations of the
20
+ * same meaning, preserving the safety/medical-disclaimer intent.
21
+ */
22
+ const MESSAGES = {
23
+ en: {
24
+ disclaimer_footer:
25
+ 'This is general information, not medical advice. Consult a licensed clinician.',
26
+ refusal:
27
+ "I can't help with that. Please consult a licensed clinician for personalized medical advice.",
28
+ escalation:
29
+ 'This may be urgent. Please seek emergency care or contact your local emergency number immediately.',
30
+ emergency_banner:
31
+ 'If this is an emergency, call your local emergency number now.'
32
+ },
33
+ es: {
34
+ disclaimer_footer:
35
+ 'Esta es información general, no asesoramiento médico. Consulte a un profesional sanitario autorizado.',
36
+ refusal:
37
+ 'No puedo ayudar con eso. Consulte a un profesional sanitario autorizado para obtener asesoramiento médico personalizado.',
38
+ escalation:
39
+ 'Esto podría ser urgente. Busque atención de emergencia o llame de inmediato al número de emergencias local.',
40
+ emergency_banner:
41
+ 'Si se trata de una emergencia, llame ahora al número de emergencias local.'
42
+ },
43
+ fr: {
44
+ disclaimer_footer:
45
+ "Ceci est une information générale, et non un avis médical. Consultez un professionnel de santé agréé.",
46
+ refusal:
47
+ "Je ne peux pas vous aider avec cela. Veuillez consulter un professionnel de santé agréé pour obtenir un avis médical personnalisé.",
48
+ escalation:
49
+ "Cela peut être urgent. Veuillez consulter des soins d'urgence ou appeler immédiatement votre numéro d'urgence local.",
50
+ emergency_banner:
51
+ "En cas d'urgence, appelez immédiatement votre numéro d'urgence local."
52
+ },
53
+ de: {
54
+ disclaimer_footer:
55
+ 'Dies sind allgemeine Informationen und keine medizinische Beratung. Wenden Sie sich an eine zugelassene medizinische Fachkraft.',
56
+ refusal:
57
+ 'Dabei kann ich nicht helfen. Bitte wenden Sie sich für eine persönliche medizinische Beratung an eine zugelassene medizinische Fachkraft.',
58
+ escalation:
59
+ 'Dies könnte dringend sein. Bitte suchen Sie sofort eine Notaufnahme auf oder rufen Sie umgehend Ihre örtliche Notrufnummer an.',
60
+ emergency_banner:
61
+ 'Wenn dies ein Notfall ist, rufen Sie jetzt Ihre örtliche Notrufnummer an.'
62
+ },
63
+ pt: {
64
+ disclaimer_footer:
65
+ 'Estas são informações gerais, não aconselhamento médico. Consulte um profissional de saúde licenciado.',
66
+ refusal:
67
+ 'Não posso ajudar com isso. Por favor, consulte um profissional de saúde licenciado para obter aconselhamento médico personalizado.',
68
+ escalation:
69
+ 'Isto pode ser urgente. Procure atendimento de emergência ou ligue imediatamente para o número de emergência local.',
70
+ emergency_banner:
71
+ 'Se isto for uma emergência, ligue agora para o número de emergência local.'
72
+ },
73
+ ar: {
74
+ disclaimer_footer:
75
+ 'هذه معلومات عامة وليست استشارة طبية. يُرجى استشارة طبيب مرخّص.',
76
+ refusal:
77
+ 'لا أستطيع المساعدة في ذلك. يُرجى استشارة طبيب مرخّص للحصول على استشارة طبية مخصّصة.',
78
+ escalation:
79
+ 'قد يكون هذا أمرًا عاجلاً. يُرجى طلب الرعاية الطارئة أو الاتصال برقم الطوارئ المحلي فورًا.',
80
+ emergency_banner:
81
+ 'إذا كانت هذه حالة طارئة، فاتصل الآن برقم الطوارئ المحلي.'
82
+ }
83
+ };
84
+
85
+ /**
86
+ * Normalize an arbitrary locale tag to a supported base locale.
87
+ * Examples: 'en-US' -> 'en', 'EN' -> 'en', 'pt_BR' -> 'pt', 'xx' -> 'en',
88
+ * null/undefined/'' -> 'en'.
89
+ *
90
+ * @param {string} locale
91
+ * @returns {string} a supported locale code
92
+ */
93
+ function normalizeLocale(locale) {
94
+ if (typeof locale !== 'string' || locale.trim() === '') {
95
+ return DEFAULT_LOCALE;
96
+ }
97
+ // Take the primary subtag before any region separator ('-' or '_').
98
+ const base = locale.trim().toLowerCase().split(/[-_]/)[0];
99
+ return SUPPORTED_LOCALES.includes(base) ? base : DEFAULT_LOCALE;
100
+ }
101
+
102
+ /**
103
+ * Translate a key into the given locale, falling back to English when the
104
+ * locale is unsupported or the key is missing in that locale.
105
+ *
106
+ * @param {string} key a key present in MESSAGES.en
107
+ * @param {string} locale a locale tag (will be normalized)
108
+ * @returns {string} the localized string, or '' if the key does not exist at all
109
+ */
110
+ function t(key, locale) {
111
+ const normalized = normalizeLocale(locale);
112
+ const localeTable = MESSAGES[normalized] || MESSAGES[DEFAULT_LOCALE];
113
+
114
+ if (localeTable && Object.prototype.hasOwnProperty.call(localeTable, key)) {
115
+ return localeTable[key];
116
+ }
117
+ // Fall back to English for a missing key in a non-English locale.
118
+ if (
119
+ MESSAGES[DEFAULT_LOCALE] &&
120
+ Object.prototype.hasOwnProperty.call(MESSAGES[DEFAULT_LOCALE], key)
121
+ ) {
122
+ return MESSAGES[DEFAULT_LOCALE][key];
123
+ }
124
+ // Unknown key entirely.
125
+ return '';
126
+ }
127
+
128
+ module.exports = {
129
+ SUPPORTED_LOCALES,
130
+ DEFAULT_LOCALE,
131
+ MESSAGES,
132
+ normalizeLocale,
133
+ t
134
+ };
@@ -0,0 +1,27 @@
1
+ const { getMonthKey } = require("./analytics");
2
+
3
+ async function checkMonthlyLimit({ providerDoc }) {
4
+ const data = providerDoc.data();
5
+ const limit = data.monthlyChatLimit;
6
+ if (!limit || limit <= 0) {
7
+ return { allowed: true };
8
+ }
9
+
10
+ const currentMonth = getMonthKey();
11
+ const storedMonth = data.monthlyChatCountMonth;
12
+ const currentCount = storedMonth === currentMonth ? data.monthlyChatCount || 0 : 0;
13
+ const resetNeeded = storedMonth && storedMonth !== currentMonth;
14
+
15
+ if (currentCount >= limit) {
16
+ return {
17
+ allowed: false,
18
+ message: "This provider has reached their monthly chat limit. Please try again next month."
19
+ };
20
+ }
21
+
22
+ return { allowed: true, resetNeeded, currentMonth };
23
+ }
24
+
25
+ module.exports = {
26
+ checkMonthlyLimit
27
+ };