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,271 @@
|
|
|
1
|
+
const OpenAI = require('openai');
|
|
2
|
+
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Retry wrapper with exponential backoff for transient API errors.
|
|
6
|
+
* Retries up to maxRetries times (so maxRetries+1 total attempts).
|
|
7
|
+
* Only retries on network errors, 429 rate limits, and 5xx server errors.
|
|
8
|
+
* Does NOT retry on 4xx client errors (except 429).
|
|
9
|
+
*/
|
|
10
|
+
async function withRetry(fn, { maxRetries = 2, baseDelayMs = 1000, label = 'LLM' } = {}) {
|
|
11
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
12
|
+
try {
|
|
13
|
+
return await fn();
|
|
14
|
+
} catch (error) {
|
|
15
|
+
const status = error.status || error.statusCode || (error.response && error.response.status);
|
|
16
|
+
const isRateLimit = status === 429;
|
|
17
|
+
const isServerError = status >= 500 && status < 600;
|
|
18
|
+
const isNetworkError = !status && (
|
|
19
|
+
error.code === 'ECONNRESET' ||
|
|
20
|
+
error.code === 'ECONNREFUSED' ||
|
|
21
|
+
error.code === 'ETIMEDOUT' ||
|
|
22
|
+
error.code === 'ENOTFOUND' ||
|
|
23
|
+
error.code === 'EAI_AGAIN' ||
|
|
24
|
+
error.message === 'Network Error' ||
|
|
25
|
+
error.type === 'system'
|
|
26
|
+
);
|
|
27
|
+
const isTransient = isRateLimit || isServerError || isNetworkError;
|
|
28
|
+
|
|
29
|
+
if (!isTransient || attempt === maxRetries) {
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const delayMs = baseDelayMs * Math.pow(2, attempt); // 1s, 2s
|
|
34
|
+
console.log(`⏳ [${label}] Transient error (${error.message}), retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})...`);
|
|
35
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Initialize clients (lazy loaded when needed)
|
|
41
|
+
let deepseekClient = null;
|
|
42
|
+
let geminiClient = null;
|
|
43
|
+
|
|
44
|
+
function getDeepSeekClient() {
|
|
45
|
+
if (!deepseekClient && process.env.DEEPSEEK_API_KEY) {
|
|
46
|
+
deepseekClient = new OpenAI({
|
|
47
|
+
apiKey: process.env.DEEPSEEK_API_KEY,
|
|
48
|
+
baseURL: 'https://api.deepseek.com'
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return deepseekClient;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getGeminiClient() {
|
|
55
|
+
if (!geminiClient && process.env.GEMINI_API_KEY) {
|
|
56
|
+
geminiClient = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
|
|
57
|
+
}
|
|
58
|
+
return geminiClient;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildSystemPrompt(topicConfig, provider, doctorExamples) {
|
|
62
|
+
const providerName = provider.displayName || 'the clinic';
|
|
63
|
+
const specialty = provider.specialty || 'healthcare';
|
|
64
|
+
|
|
65
|
+
let prompt = `You are a knowledgeable and helpful medical information assistant for ${providerName}, a ${specialty} provider. Your role is to provide accurate, educational health information to help patients understand their health better.
|
|
66
|
+
|
|
67
|
+
YOUR PRIMARY GOAL: Be as helpful as possible while staying within safe boundaries.
|
|
68
|
+
|
|
69
|
+
WHAT YOU SHOULD DO:
|
|
70
|
+
- Provide clear, detailed explanations of medical conditions, symptoms, and treatments
|
|
71
|
+
- Explain what medications do, their common side effects, and general usage information
|
|
72
|
+
- Share evidence-based health education about ${topicConfig.title}
|
|
73
|
+
- Help patients prepare for medical appointments with relevant questions to ask
|
|
74
|
+
- Explain medical terms and procedures in simple language
|
|
75
|
+
- Provide general wellness and preventive health information
|
|
76
|
+
- Discuss common causes and risk factors for health conditions
|
|
77
|
+
|
|
78
|
+
SAFETY BOUNDARIES (important but not overly restrictive):
|
|
79
|
+
- Don't make specific diagnoses for individuals (you can discuss what conditions generally involve)
|
|
80
|
+
- Don't prescribe specific medications or tell people to start/stop their medications
|
|
81
|
+
- Don't provide exact dosing instructions (but you can explain general dosing concepts)
|
|
82
|
+
- For urgent symptoms (chest pain, difficulty breathing, severe bleeding), emphasize seeking immediate care
|
|
83
|
+
- Remind patients that this is educational information, and they should discuss specifics with ${providerName}
|
|
84
|
+
|
|
85
|
+
RESPONSE STYLE:
|
|
86
|
+
- Be conversational and warm, not overly clinical or cautious
|
|
87
|
+
- Provide 3-4 sentences of helpful information
|
|
88
|
+
- Include practical insights patients can use
|
|
89
|
+
- End with a brief reminder to consult ${providerName} for personalized guidance
|
|
90
|
+
|
|
91
|
+
COLLECTING CONTACT INFORMATION:
|
|
92
|
+
- In your FIRST or SECOND response strictly, naturally ask for the patient's name and phone number or email
|
|
93
|
+
- Example: "By the way, what's your name? And could you share a phone number or email where ${providerName} can follow up with you if needed?"
|
|
94
|
+
- Make it conversational, not formal - like a friendly receptionist would ask
|
|
95
|
+
- Only ask once - if the patient doesn't provide it or declines, continue normally
|
|
96
|
+
- If the patient shares contact info (email or phone number), acknowledge it briefly and continue
|
|
97
|
+
|
|
98
|
+
Provider Context:
|
|
99
|
+
- Provider: ${providerName}
|
|
100
|
+
- Specialty: ${specialty}
|
|
101
|
+
- Topic: ${topicConfig.title}
|
|
102
|
+
|
|
103
|
+
Remember: You're here to educate and empower patients with knowledge, not to replace medical consultation.`;
|
|
104
|
+
|
|
105
|
+
// Inject doctor examples for personalized responses (Pillar 2 - Component 5)
|
|
106
|
+
if (doctorExamples && doctorExamples.length > 0) {
|
|
107
|
+
prompt += `\n\nIMPORTANT - DOCTOR'S STYLE GUIDE:
|
|
108
|
+
Here are examples of how ${providerName} actually responds to patients. Match their tone, level of detail, and communication style:`;
|
|
109
|
+
doctorExamples.slice(0, 3).forEach((ex, i) => {
|
|
110
|
+
prompt += `\n\nExample ${i + 1}:
|
|
111
|
+
Patient: ${ex.patientMessage}
|
|
112
|
+
${providerName}: ${ex.doctorResponse}`;
|
|
113
|
+
});
|
|
114
|
+
prompt += `\n\nUse these examples to calibrate your response style to match ${providerName}'s voice.`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return prompt;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async function callDeepSeek({ topicConfig, message, provider, conversationHistory, doctorExamples }) {
|
|
122
|
+
const client = getDeepSeekClient();
|
|
123
|
+
if (!client) throw new Error('DeepSeek API key not configured');
|
|
124
|
+
|
|
125
|
+
const systemPrompt = buildSystemPrompt(topicConfig, provider, doctorExamples);
|
|
126
|
+
|
|
127
|
+
// Build messages array: system prompt + conversation history + current message
|
|
128
|
+
const messages = [{ role: 'system', content: systemPrompt }];
|
|
129
|
+
|
|
130
|
+
if (conversationHistory && conversationHistory.length > 0) {
|
|
131
|
+
for (const msg of conversationHistory) {
|
|
132
|
+
messages.push({
|
|
133
|
+
role: msg.role === 'user' ? 'user' : 'assistant',
|
|
134
|
+
content: msg.content
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
messages.push({ role: 'user', content: message });
|
|
140
|
+
|
|
141
|
+
const response = await client.chat.completions.create({
|
|
142
|
+
model: 'deepseek-chat',
|
|
143
|
+
messages,
|
|
144
|
+
max_tokens: 300,
|
|
145
|
+
temperature: 0.4,
|
|
146
|
+
top_p: 0.9
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const content = response &&
|
|
150
|
+
Array.isArray(response.choices) &&
|
|
151
|
+
response.choices[0] &&
|
|
152
|
+
response.choices[0].message &&
|
|
153
|
+
response.choices[0].message.content;
|
|
154
|
+
if (typeof content !== 'string' || !content.trim()) {
|
|
155
|
+
throw new Error('DeepSeek returned an empty or malformed response');
|
|
156
|
+
}
|
|
157
|
+
return content.trim();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function callGemini({ topicConfig, message, provider, conversationHistory, doctorExamples }) {
|
|
161
|
+
const client = getGeminiClient();
|
|
162
|
+
if (!client) throw new Error('Gemini API key not configured');
|
|
163
|
+
|
|
164
|
+
const systemPrompt = buildSystemPrompt(topicConfig, provider, doctorExamples);
|
|
165
|
+
|
|
166
|
+
const model = client.getGenerativeModel({
|
|
167
|
+
model: 'gemini-2.5-flash',
|
|
168
|
+
systemInstruction: systemPrompt
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Build multi-turn contents: conversation history + current message
|
|
172
|
+
const contents = [];
|
|
173
|
+
|
|
174
|
+
if (conversationHistory && conversationHistory.length > 0) {
|
|
175
|
+
for (const msg of conversationHistory) {
|
|
176
|
+
contents.push({
|
|
177
|
+
role: msg.role === 'user' ? 'user' : 'model',
|
|
178
|
+
parts: [{ text: msg.content }]
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
contents.push({ role: 'user', parts: [{ text: message }] });
|
|
184
|
+
|
|
185
|
+
const result = await model.generateContent({
|
|
186
|
+
contents,
|
|
187
|
+
generationConfig: {
|
|
188
|
+
maxOutputTokens: 300,
|
|
189
|
+
temperature: 0.4,
|
|
190
|
+
topP: 0.9
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const text = result && result.response && typeof result.response.text === 'function'
|
|
195
|
+
? result.response.text()
|
|
196
|
+
: null;
|
|
197
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
198
|
+
throw new Error('Gemini returned an empty or malformed response');
|
|
199
|
+
}
|
|
200
|
+
return text.trim();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function generateFallbackResponse({ topicConfig, message, provider }) {
|
|
204
|
+
const providerName = provider.displayName || 'the clinic';
|
|
205
|
+
return `I can provide general information about ${topicConfig.title}. However, for specific medical questions or concerns, please contact ${providerName} directly. They can provide personalized guidance based on your individual situation.`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function generateResponse({ topicConfig, message, provider, conversationHistory, doctorExamples }) {
|
|
209
|
+
const historyLen = (conversationHistory && conversationHistory.length) || 0;
|
|
210
|
+
const examplesLen = (doctorExamples && doctorExamples.length) || 0;
|
|
211
|
+
|
|
212
|
+
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
213
|
+
console.log('🤖 LLM REQUEST');
|
|
214
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
215
|
+
console.log('📝 User Message:', message.substring(0, 100) + (message.length > 100 ? '...' : ''));
|
|
216
|
+
console.log('🏥 Provider:', provider.displayName || 'Unknown');
|
|
217
|
+
console.log('📋 Topic:', topicConfig.title || 'General');
|
|
218
|
+
console.log('💬 History:', historyLen, 'previous messages');
|
|
219
|
+
if (examplesLen > 0) console.log('📚 Doctor Examples:', examplesLen, 'examples injected');
|
|
220
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
221
|
+
|
|
222
|
+
// Try DeepSeek first (better results), then Gemini, then fallback
|
|
223
|
+
|
|
224
|
+
// 1. Try DeepSeek first (produces better medical responses)
|
|
225
|
+
try {
|
|
226
|
+
console.log('🟣 [DEEPSEEK] Attempting DeepSeek API (primary)...');
|
|
227
|
+
const startTime = Date.now();
|
|
228
|
+
const response = await withRetry(
|
|
229
|
+
() => callDeepSeek({ topicConfig, message, provider, conversationHistory, doctorExamples }),
|
|
230
|
+
{ label: 'DEEPSEEK' }
|
|
231
|
+
);
|
|
232
|
+
const duration = Date.now() - startTime;
|
|
233
|
+
|
|
234
|
+
console.log(`✅ [DEEPSEEK] Success! (${duration}ms)`);
|
|
235
|
+
console.log('📤 Response:', response.substring(0, 200) + (response.length > 200 ? '...' : ''));
|
|
236
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
237
|
+
return response;
|
|
238
|
+
} catch (deepseekError) {
|
|
239
|
+
console.error('❌ [DEEPSEEK] Failed after retries:', deepseekError.message);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 2. Fallback to Gemini
|
|
243
|
+
try {
|
|
244
|
+
console.log('🔵 [GEMINI] Attempting Gemini 2.5 Flash API (fallback)...');
|
|
245
|
+
const startTime = Date.now();
|
|
246
|
+
const response = await withRetry(
|
|
247
|
+
() => callGemini({ topicConfig, message, provider, conversationHistory, doctorExamples }),
|
|
248
|
+
{ label: 'GEMINI' }
|
|
249
|
+
);
|
|
250
|
+
const duration = Date.now() - startTime;
|
|
251
|
+
|
|
252
|
+
console.log(`✅ [GEMINI] Success! (${duration}ms)`);
|
|
253
|
+
console.log('📤 Response:', response.substring(0, 200) + (response.length > 200 ? '...' : ''));
|
|
254
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
255
|
+
return response;
|
|
256
|
+
} catch (geminiError) {
|
|
257
|
+
console.error('❌ [GEMINI] Failed after retries:', geminiError.message);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 3. Final fallback to safe generic response
|
|
261
|
+
console.log('⚠️ [FALLBACK] Using safe generic response');
|
|
262
|
+
const fallback = generateFallbackResponse({ topicConfig, message, provider });
|
|
263
|
+
console.log('📤 Response:', fallback);
|
|
264
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
265
|
+
return fallback;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
module.exports = {
|
|
269
|
+
generateResponse,
|
|
270
|
+
withRetry
|
|
271
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Medical Named Entity Recognition Module
|
|
2
|
+
//
|
|
3
|
+
// CURRENT STATUS:
|
|
4
|
+
// ✓ NER API integration is working and tested
|
|
5
|
+
// ✓ Using dslim/bert-base-NER (general NER model) as primary model
|
|
6
|
+
// ⚠️ Specialized medical NER models (OpenMed, etc.) don't have inference providers available yet
|
|
7
|
+
// ✓ System gracefully degrades: NER failures don't break the safety pipeline
|
|
8
|
+
// ✓ Regex-based safety checks continue to work independently
|
|
9
|
+
//
|
|
10
|
+
// TO USE MEDICAL-SPECIFIC NER:
|
|
11
|
+
// - Wait for OpenMed or other medical NER models to become available on HF Inference API
|
|
12
|
+
// - Or deploy your own inference endpoint and update MODEL_CANDIDATES
|
|
13
|
+
// - Current model detects general entities (PERSON, LOCATION, ORG) which helps with PII detection
|
|
14
|
+
|
|
15
|
+
const { HfInference } = require('@huggingface/inference');
|
|
16
|
+
|
|
17
|
+
const HF_API_KEY = process.env.HUGGINGFACE_API_KEY;
|
|
18
|
+
|
|
19
|
+
// Try multiple NER models in order of preference
|
|
20
|
+
// Note: Not all models have inference providers available on HF's hosted API
|
|
21
|
+
const MODEL_CANDIDATES = [
|
|
22
|
+
'dslim/bert-base-NER', // General NER (PERSON, LOCATION, ORG) - WORKING ✓
|
|
23
|
+
'OpenMed/OpenMed-ZeroShot-NER-Pathology-Medium-209M', // Medical NER - NO PROVIDER ✗
|
|
24
|
+
'd4data/biomedical-ner-all' // Biomedical NER - UNKNOWN STATUS
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// Initialize Hugging Face client (lazy loaded)
|
|
28
|
+
let hfClient = null;
|
|
29
|
+
|
|
30
|
+
function getHfClient() {
|
|
31
|
+
if (!hfClient && HF_API_KEY) {
|
|
32
|
+
hfClient = new HfInference(HF_API_KEY);
|
|
33
|
+
}
|
|
34
|
+
return hfClient;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extract medical entities from text using OpenMed NER model
|
|
39
|
+
* @param {string} text - Text to analyze
|
|
40
|
+
* @returns {Promise<Array>} Array of detected entities with type and confidence
|
|
41
|
+
*/
|
|
42
|
+
async function extractMedicalEntities(text) {
|
|
43
|
+
const client = getHfClient();
|
|
44
|
+
|
|
45
|
+
if (!client) {
|
|
46
|
+
console.log('[NER] Hugging Face API key not configured, skipping NER');
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!text || text.trim().length === 0) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Try models in order until one works
|
|
55
|
+
for (const modelName of MODEL_CANDIDATES) {
|
|
56
|
+
try {
|
|
57
|
+
console.log(`[NER] Trying model: ${modelName}...`);
|
|
58
|
+
|
|
59
|
+
const result = await client.tokenClassification({
|
|
60
|
+
model: modelName,
|
|
61
|
+
inputs: text
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
console.log(`[NER] ✓ Success with ${modelName}: Detected ${result.length} entities`);
|
|
65
|
+
|
|
66
|
+
return result.map(entity => ({
|
|
67
|
+
text: entity.word,
|
|
68
|
+
type: entity.entity_group || entity.entity,
|
|
69
|
+
confidence: entity.score,
|
|
70
|
+
start: entity.start,
|
|
71
|
+
end: entity.end
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.warn(`[NER] ${modelName} failed: ${error.message}`);
|
|
76
|
+
// Continue to next model
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// All models failed
|
|
81
|
+
console.log('[NER] All NER models failed, using regex-only detection');
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Analyze detected entities for safety concerns
|
|
87
|
+
* @param {Array} entities - Entities from extractMedicalEntities
|
|
88
|
+
* @returns {Object} Analysis with flags and detected categories
|
|
89
|
+
*/
|
|
90
|
+
function analyzeEntities(entities) {
|
|
91
|
+
const analysis = {
|
|
92
|
+
hasSymptoms: false,
|
|
93
|
+
hasDiseases: false,
|
|
94
|
+
hasMedications: false,
|
|
95
|
+
hasPathology: false,
|
|
96
|
+
urgentSymptoms: [],
|
|
97
|
+
detectedTerms: []
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Urgent symptoms that should trigger escalation
|
|
101
|
+
const urgentKeywords = [
|
|
102
|
+
'chest pain', 'heart attack', 'stroke', 'seizure',
|
|
103
|
+
'bleeding', 'unconscious', 'breathing', 'suicide'
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
for (const entity of entities) {
|
|
107
|
+
const text = entity.text.toLowerCase();
|
|
108
|
+
const type = entity.type.toLowerCase();
|
|
109
|
+
|
|
110
|
+
analysis.detectedTerms.push({
|
|
111
|
+
term: entity.text,
|
|
112
|
+
type: entity.type,
|
|
113
|
+
confidence: entity.confidence
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Categorize entity types
|
|
117
|
+
if (type.includes('symptom') || type.includes('sign')) {
|
|
118
|
+
analysis.hasSymptoms = true;
|
|
119
|
+
}
|
|
120
|
+
if (type.includes('disease') || type.includes('disorder')) {
|
|
121
|
+
analysis.hasDiseases = true;
|
|
122
|
+
}
|
|
123
|
+
if (type.includes('medication') || type.includes('drug')) {
|
|
124
|
+
analysis.hasMedications = true;
|
|
125
|
+
}
|
|
126
|
+
if (type.includes('pathology') || type.includes('finding')) {
|
|
127
|
+
analysis.hasPathology = true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check for urgent symptoms
|
|
131
|
+
for (const urgent of urgentKeywords) {
|
|
132
|
+
if (text.includes(urgent)) {
|
|
133
|
+
analysis.urgentSymptoms.push(entity.text);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return analysis;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Enhanced safety check using NER
|
|
143
|
+
* @param {string} text - Patient message
|
|
144
|
+
* @returns {Promise<Object>} Safety analysis with NER insights
|
|
145
|
+
*/
|
|
146
|
+
async function analyzeMedicalSafety(text) {
|
|
147
|
+
const entities = await extractMedicalEntities(text);
|
|
148
|
+
const analysis = analyzeEntities(entities);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
entities,
|
|
152
|
+
analysis,
|
|
153
|
+
shouldEscalate: analysis.urgentSymptoms.length > 0,
|
|
154
|
+
requiresDisclaimer: analysis.hasSymptoms || analysis.hasDiseases || analysis.hasMedications
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
extractMedicalEntities,
|
|
160
|
+
analyzeEntities,
|
|
161
|
+
analyzeMedicalSafety
|
|
162
|
+
};
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
const admin = require('firebase-admin');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Notification System Module
|
|
5
|
+
*
|
|
6
|
+
* Handles notifications for:
|
|
7
|
+
* 1. Escalations - Immediate notification when conversation is escalated
|
|
8
|
+
* 2. Unread messages - After configurable delay (default 5 min)
|
|
9
|
+
* 3. Daily digest - Summary of conversations
|
|
10
|
+
*
|
|
11
|
+
* Channels: In-app (Firestore), Email (nodemailer/future), Push (FCM/future)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const NOTIFICATION_TYPES = {
|
|
15
|
+
ESCALATION: 'escalation',
|
|
16
|
+
UNREAD: 'unread_messages',
|
|
17
|
+
DAILY_DIGEST: 'daily_digest',
|
|
18
|
+
NEW_THREAD: 'new_thread',
|
|
19
|
+
DRAFT_READY: 'draft_ready'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create an in-app notification for a doctor
|
|
24
|
+
*/
|
|
25
|
+
async function createNotification({ doctorId, type, title, body, threadId, metadata }) {
|
|
26
|
+
const db = admin.firestore();
|
|
27
|
+
|
|
28
|
+
const notification = {
|
|
29
|
+
doctorId,
|
|
30
|
+
type,
|
|
31
|
+
title,
|
|
32
|
+
body,
|
|
33
|
+
threadId: threadId || null,
|
|
34
|
+
metadata: metadata || {},
|
|
35
|
+
read: false,
|
|
36
|
+
createdAt: admin.firestore.FieldValue.serverTimestamp()
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const ref = await db.collection('notifications').add(notification);
|
|
40
|
+
console.log(`[Notifications] Created ${type} notification for doctor ${doctorId}`);
|
|
41
|
+
return { id: ref.id, ...notification };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Notify doctor about an escalation
|
|
46
|
+
*/
|
|
47
|
+
async function notifyEscalation({ doctorId, threadId, patientName, reason }) {
|
|
48
|
+
const name = patientName || 'A patient';
|
|
49
|
+
|
|
50
|
+
await createNotification({
|
|
51
|
+
doctorId,
|
|
52
|
+
type: NOTIFICATION_TYPES.ESCALATION,
|
|
53
|
+
title: 'Urgent: Conversation Escalated',
|
|
54
|
+
body: `${name}'s conversation was flagged as urgent. ${reason || 'Immediate review needed.'}`,
|
|
55
|
+
threadId,
|
|
56
|
+
metadata: { reason, patientName }
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Update thread to record notification was sent
|
|
60
|
+
const db = admin.firestore();
|
|
61
|
+
await db.collection('threads').doc(threadId).update({
|
|
62
|
+
lastDoctorNotifiedAt: admin.firestore.FieldValue.serverTimestamp()
|
|
63
|
+
}).catch(err => console.error('[Notifications] Failed to update thread:', err.message));
|
|
64
|
+
|
|
65
|
+
// Future: Send email notification
|
|
66
|
+
await sendEmailNotification({
|
|
67
|
+
doctorId,
|
|
68
|
+
subject: `Urgent: ${name}'s conversation needs attention`,
|
|
69
|
+
body: `${name}'s conversation has been flagged as urgent.\n\nReason: ${reason || 'Safety concern detected'}\n\nPlease review the conversation in your EvalDoc inbox.`
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Notify doctor about unread messages
|
|
75
|
+
*/
|
|
76
|
+
async function notifyUnread({ doctorId, unreadCount, threadIds }) {
|
|
77
|
+
await createNotification({
|
|
78
|
+
doctorId,
|
|
79
|
+
type: NOTIFICATION_TYPES.UNREAD,
|
|
80
|
+
title: `${unreadCount} Unread Message${unreadCount > 1 ? 's' : ''}`,
|
|
81
|
+
body: `You have ${unreadCount} unread patient message${unreadCount > 1 ? 's' : ''} waiting for review.`,
|
|
82
|
+
metadata: { unreadCount, threadIds }
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Notify doctor that an AI draft is ready
|
|
88
|
+
*/
|
|
89
|
+
async function notifyDraftReady({ doctorId, threadId, patientName }) {
|
|
90
|
+
const name = patientName || 'a patient';
|
|
91
|
+
|
|
92
|
+
await createNotification({
|
|
93
|
+
doctorId,
|
|
94
|
+
type: NOTIFICATION_TYPES.DRAFT_READY,
|
|
95
|
+
title: 'AI Draft Ready for Review',
|
|
96
|
+
body: `A suggested response for ${name} is ready for your review.`,
|
|
97
|
+
threadId,
|
|
98
|
+
metadata: { patientName }
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Send email notification (stub - uses nodemailer when configured)
|
|
104
|
+
*/
|
|
105
|
+
async function sendEmailNotification({ doctorId, subject, body }) {
|
|
106
|
+
const db = admin.firestore();
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
// Get doctor's notification preferences
|
|
110
|
+
const providerDoc = await db.collection('providers').doc(doctorId).get();
|
|
111
|
+
if (!providerDoc.exists) return;
|
|
112
|
+
|
|
113
|
+
const provider = providerDoc.data();
|
|
114
|
+
const prefs = provider.notificationPreferences || {};
|
|
115
|
+
|
|
116
|
+
// Check if email notifications are enabled
|
|
117
|
+
if (prefs.emailEnabled === false) {
|
|
118
|
+
console.log(`[Notifications] Email disabled for doctor ${doctorId}`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const email = prefs.email || provider.email;
|
|
123
|
+
if (!email) {
|
|
124
|
+
console.log(`[Notifications] No email configured for doctor ${doctorId}`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// For now, log the email. In production, use nodemailer/SendGrid
|
|
129
|
+
console.log(`[Notifications] Would send email to ${email}:`);
|
|
130
|
+
console.log(` Subject: ${subject}`);
|
|
131
|
+
console.log(` Body: ${body.substring(0, 100)}...`);
|
|
132
|
+
|
|
133
|
+
// Future: Actual email sending with nodemailer
|
|
134
|
+
// const transporter = nodemailer.createTransport({...});
|
|
135
|
+
// await transporter.sendMail({ to: email, subject, text: body });
|
|
136
|
+
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error('[Notifications] Email notification failed:', err.message);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get unread notifications for a doctor
|
|
144
|
+
*/
|
|
145
|
+
async function getNotifications({ doctorId, limit = 20 }) {
|
|
146
|
+
const db = admin.firestore();
|
|
147
|
+
|
|
148
|
+
const snapshot = await db.collection('notifications')
|
|
149
|
+
.where('doctorId', '==', doctorId)
|
|
150
|
+
.orderBy('createdAt', 'desc')
|
|
151
|
+
.limit(limit)
|
|
152
|
+
.get();
|
|
153
|
+
|
|
154
|
+
return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get unread notification count for a doctor
|
|
159
|
+
*/
|
|
160
|
+
async function getUnreadCount({ doctorId }) {
|
|
161
|
+
const db = admin.firestore();
|
|
162
|
+
|
|
163
|
+
const snapshot = await db.collection('notifications')
|
|
164
|
+
.where('doctorId', '==', doctorId)
|
|
165
|
+
.where('read', '==', false)
|
|
166
|
+
.get();
|
|
167
|
+
|
|
168
|
+
return snapshot.size;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Mark a notification as read
|
|
173
|
+
*/
|
|
174
|
+
async function markNotificationRead({ notificationId, doctorId }) {
|
|
175
|
+
const db = admin.firestore();
|
|
176
|
+
const ref = db.collection('notifications').doc(notificationId);
|
|
177
|
+
const doc = await ref.get();
|
|
178
|
+
|
|
179
|
+
if (!doc.exists || doc.data().doctorId !== doctorId) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await ref.update({ read: true });
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Mark all notifications as read for a doctor
|
|
189
|
+
*/
|
|
190
|
+
async function markAllNotificationsRead({ doctorId }) {
|
|
191
|
+
const db = admin.firestore();
|
|
192
|
+
|
|
193
|
+
const snapshot = await db.collection('notifications')
|
|
194
|
+
.where('doctorId', '==', doctorId)
|
|
195
|
+
.where('read', '==', false)
|
|
196
|
+
.get();
|
|
197
|
+
|
|
198
|
+
const batch = db.batch();
|
|
199
|
+
snapshot.docs.forEach(doc => {
|
|
200
|
+
batch.update(doc.ref, { read: true });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await batch.commit();
|
|
204
|
+
return snapshot.size;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Generate a daily digest for a doctor (called by cron/scheduler)
|
|
209
|
+
*/
|
|
210
|
+
async function generateDailyDigest({ doctorId }) {
|
|
211
|
+
const db = admin.firestore();
|
|
212
|
+
|
|
213
|
+
// Get threads with activity in the last 24 hours
|
|
214
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
215
|
+
|
|
216
|
+
const threadsSnapshot = await db.collection('threads')
|
|
217
|
+
.where('doctorId', '==', doctorId)
|
|
218
|
+
.where('lastMessageAt', '>=', yesterday)
|
|
219
|
+
.orderBy('lastMessageAt', 'desc')
|
|
220
|
+
.limit(50)
|
|
221
|
+
.get();
|
|
222
|
+
|
|
223
|
+
if (threadsSnapshot.empty) return null;
|
|
224
|
+
|
|
225
|
+
const threads = threadsSnapshot.docs.map(doc => doc.data());
|
|
226
|
+
|
|
227
|
+
const urgentCount = threads.filter(t => t.triageCategory === 'urgent').length;
|
|
228
|
+
const needsReviewCount = threads.filter(t => t.triageCategory === 'needs_review').length;
|
|
229
|
+
const totalUnread = threads.reduce((sum, t) => sum + (t.unreadByDoctor || 0), 0);
|
|
230
|
+
|
|
231
|
+
const digestBody = [
|
|
232
|
+
`Daily Summary:`,
|
|
233
|
+
`- ${threads.length} active conversation${threads.length > 1 ? 's' : ''} in the last 24 hours`,
|
|
234
|
+
urgentCount > 0 ? `- ${urgentCount} urgent conversation${urgentCount > 1 ? 's' : ''} need attention` : null,
|
|
235
|
+
needsReviewCount > 0 ? `- ${needsReviewCount} conversation${needsReviewCount > 1 ? 's' : ''} need review` : null,
|
|
236
|
+
totalUnread > 0 ? `- ${totalUnread} total unread message${totalUnread > 1 ? 's' : ''}` : null
|
|
237
|
+
].filter(Boolean).join('\n');
|
|
238
|
+
|
|
239
|
+
await createNotification({
|
|
240
|
+
doctorId,
|
|
241
|
+
type: NOTIFICATION_TYPES.DAILY_DIGEST,
|
|
242
|
+
title: 'Daily Conversation Digest',
|
|
243
|
+
body: digestBody,
|
|
244
|
+
metadata: {
|
|
245
|
+
activeThreads: threads.length,
|
|
246
|
+
urgentCount,
|
|
247
|
+
needsReviewCount,
|
|
248
|
+
totalUnread
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Also send email digest
|
|
253
|
+
await sendEmailNotification({
|
|
254
|
+
doctorId,
|
|
255
|
+
subject: 'EvalDoc Daily Digest',
|
|
256
|
+
body: digestBody
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return { activeThreads: threads.length, urgentCount, needsReviewCount, totalUnread };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = {
|
|
263
|
+
NOTIFICATION_TYPES,
|
|
264
|
+
createNotification,
|
|
265
|
+
notifyEscalation,
|
|
266
|
+
notifyUnread,
|
|
267
|
+
notifyDraftReady,
|
|
268
|
+
getNotifications,
|
|
269
|
+
getUnreadCount,
|
|
270
|
+
markNotificationRead,
|
|
271
|
+
markAllNotificationsRead,
|
|
272
|
+
generateDailyDigest
|
|
273
|
+
};
|