Cuong2004 commited on
Commit
0c00ee2
·
1 Parent(s): faf5d21

Implement intent classification for casual greetings in the IntentClassifierService and update MedagenAgent to route user queries based on classified intent. Enhance response handling in triage routes to utilize markdown messages when available. Extend TriageResult type to include a message field for markdown responses from the LLM.

Browse files
src/agent/agent-executor.ts CHANGED
@@ -4,8 +4,9 @@ import { TriageRulesService } from '../services/triage-rules.service.js';
4
  import { RAGService } from '../services/rag.service.js';
5
  import { KnowledgeBaseService } from '../services/knowledge-base.service.js';
6
  import { SupabaseService } from '../services/supabase.service.js';
 
7
  import { logger } from '../utils/logger.js';
8
- import type { TriageResult } from '../types/index.js';
9
 
10
  export class MedagenAgent {
11
  private llm: GeminiLLM;
@@ -13,6 +14,7 @@ export class MedagenAgent {
13
  private triageService: TriageRulesService;
14
  private ragService: RAGService;
15
  private knowledgeBase: KnowledgeBaseService;
 
16
  private initialized: boolean = false;
17
 
18
  constructor(supabaseService: SupabaseService) {
@@ -21,6 +23,7 @@ export class MedagenAgent {
21
  this.triageService = new TriageRulesService();
22
  this.ragService = new RAGService(supabaseService);
23
  this.knowledgeBase = new KnowledgeBaseService(supabaseService);
 
24
  }
25
 
26
  async initialize(): Promise<void> {
@@ -55,17 +58,37 @@ export class MedagenAgent {
55
  logger.info(`User text: "${userText}"`);
56
  logger.info(`Has image: ${!!imageUrl}`);
57
 
58
- // Agent tự quyết định workflow dựa trên input
59
- // - image: luôn gọi CV + RAG + Triage Rules
60
- // - Không image: Phân tích user text để quyết định gọi tools nào
61
-
62
- if (imageUrl) {
63
- // hình ảnh: luôn xử lý như triage với CV
64
- return await this.processTriageWithImage(userText, imageUrl, conversationContext);
65
- } else {
66
- // Không hình ảnh: Agent tự quyết định dựa trên user text
67
- // Sử dụng LLM để phân tích và quyết định workflow
68
- return await this.processTriageTextOnly(userText, conversationContext);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  }
70
  } catch (error) {
71
  logger.error({ error }, 'Error processing query');
@@ -133,39 +156,66 @@ export class MedagenAgent {
133
 
134
  logger.info(`[AGENT] Total guidelines collected: ${guidelines.length}`);
135
 
 
 
 
 
 
 
136
  // Use LLM to synthesize educational response
137
- const prompt = `Bạn là trợ lý y tế giáo dục của Việt Nam, dựa trên hướng dẫn của Bộ Y Tế.
138
 
139
  Câu hỏi của người dùng: ${userText}
140
 
141
  ${conversationContext ? `Ngữ cảnh cuộc trò chuyện trước: ${conversationContext}` : ''}
142
 
143
- Thông tin từ hướng dẫn Bộ Y Tế:
144
- ${guidelines.map((g, i) => `${i + 1}. ${g.content || g.snippet || g}`).join('\n')}
 
 
 
 
 
 
 
 
 
 
145
 
146
- YÊU CẦU BẮT BUỘC:
147
  1. VIẾT HOÀN TOÀN BẰNG TIẾNG VIỆT - không được dùng tiếng Anh trong response
148
- 2. KHÔNG được tự thêm câu mở đầu kiểu "Based on...", "I've assessed..." hoặc "This is..."
149
- 3. Đây câu hỏi giáo dục, KHÔNG PHẢI chẩn đoán nhân
150
- 4. Trả lời dựa trên hướng dẫn BYT, giải thích ràng dễ hiểu
151
- 5. Field "action" phải viết trực tiếp nội dung giải thích, không câu meta
152
- 6. Luôn nhấn mạnh: "Thông tin chỉ mang tính tham khảo, không thay thế bác "
153
- 7. KHÔNG đơn, KHÔNG khuyến nghị liều thuốc cụ thể
154
-
155
- Tạo response JSON (CHỈ JSON thuần, không markdown):
156
- {
157
- "triage_level": "routine",
158
- "symptom_summary": "Tóm tắt câu hỏi của người dùng bằng tiếng Việt (VD: 'Hỏi về bệnh trứng cá và cách điều trị')",
159
- "red_flags": [],
160
- "suspected_conditions": [],
161
- "cv_findings": {"model_used": "none", "raw_output": {}},
162
- "recommendation": {
163
- "action": "Viết trực tiếp nội dung giải thích về bệnh/triệu chứng dựa trên hướng dẫn BYT (VD: 'Bệnh trứng cá là tình trạng viêm da mãn tính...')",
164
- "timeframe": "Không áp dụng (đây thông tin giáo dục, không phải trường hợp cụ thể)",
165
- "home_care_advice": "Thông tin hữu ích từ guideline về phòng ngừa và chăm sóc",
166
- "warning_signs": "Nhắc nhở: Thông tin chỉ mang tính tham khảo giáo dục. Nếu bạn đang có triệu chứng, hãy đến gặp bác sĩ để được khám và chẩn đoán chính xác."
167
- }
168
- }`;
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  // Log prompt and input data before sending to LLM
171
  logger.info('='.repeat(80));
@@ -187,20 +237,47 @@ Tạo response JSON (CHỈ JSON thuần, không có markdown):
187
  const generations = await this.llm._generate([prompt]);
188
  const response = generations.generations[0][0].text;
189
 
190
- const jsonMatch = response.match(/\{[\s\S]*\}/);
191
- if (jsonMatch) {
192
- const parsed = JSON.parse(jsonMatch[0]) as TriageResult;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
- // Log final response
195
- logger.info('='.repeat(80));
196
- logger.info('[AGENT] FINAL RESPONSE (Disease Info Query):');
197
- logger.info(JSON.stringify(parsed, null, 2));
198
- logger.info('='.repeat(80));
 
 
199
 
200
- return parsed;
201
- }
202
-
203
- throw new Error('Failed to parse LLM response');
204
  } catch (error) {
205
  logger.error({ error }, 'Error processing disease info query');
206
  return this.getSafeDefaultResponse(userText);
@@ -419,7 +496,13 @@ Tạo response JSON (CHỈ JSON thuần, không có markdown):
419
  ? (cvResult.top_conditions[0] as any).model_used || 'derm_cv'
420
  : 'none';
421
 
422
- const prompt = `Bạn trợ lý y tế AI của Việt Nam. Dựa trên thông tin sau, hãy tạo một phản hồi có cấu trúc HOÀN TOÀN BẰNG TIẾNG VIỆT:
 
 
 
 
 
 
423
 
424
  Mô tả triệu chứng: ${userText}
425
 
@@ -436,38 +519,57 @@ Mức độ khẩn cấp: ${triageResult.triage}
436
  Dấu hiệu cảnh báo: ${triageResult.red_flags?.join(', ') || 'Không có'}
437
  Lý do đánh giá: ${triageResult.reasoning}
438
 
439
- Hướng dẫn y tế từ Bộ Y Tế:
440
- ${guidelines.map((g, i) => `${i + 1}. ${g.content || g.snippet || g}`).join('\n')}
 
 
 
 
 
 
 
 
 
 
441
 
442
- YÊU CẦU BẮT BUỘC:
443
  1. VIẾT HOÀN TOÀN BẰNG TIẾNG VIỆT - không được dùng tiếng Anh trong response
444
- 2. Field "action" phải viết trực tiếp hành động cần làm, bắt đầu bằng động từ (VD: "Bạn nên đến gặp bác sĩ...", "Hãy theo dõi triệu chứng...")
445
- 3. Luôn nhấn mạnh: "Thông tin chỉ mang tính tham khảo, cần bác khám để chẩn đoán chính xác"
446
- ${cvResult.top_conditions.length === 0 ? '4. Phân tích hình ảnh không đủ tin cậy, chỉ dựa vào tả triệu chứng và guidelines.' : ''}
447
-
448
- Hãy tạo response JSON với format sau (CHỈ JSON thuần, không markdown):
449
- {
450
- "triage_level": "${triageResult.triage}",
451
- "symptom_summary": "Tóm tắt triệu chứng của người dùng bằng tiếng Việt (VD: Bị mụn nhọt và đau ở mặt)",
452
- "red_flags": ${JSON.stringify(triageResult.red_flags || [])},
453
- "suspected_conditions": [
454
- ${cvResult.top_conditions.length > 0 ? cvResult.top_conditions.slice(0, 1).map((c: any) =>
455
- `{"name": "${c.name}", "source": "cv_model", "confidence": "${c.prob > 0.8 ? 'high' : c.prob > 0.5 ? 'medium' : 'low'}"}`
456
- ).join(',\n ') : '[]'}
457
- ],
458
- "cv_findings": {
459
- "model_used": "${cvModelUsed}",
460
- "raw_output": ${JSON.stringify(cvResult.top_conditions.length > 0 ? {
461
- top_predictions: cvResult.top_conditions.slice(0, 1).map((c: any) => ({ condition: c.name, probability: c.prob }))
462
- } : {})}
463
- },
464
- "recommendation": {
465
- "action": "Viết một câu hoàn chỉnh, trực tiếp hướng dẫn hành động tiếp theo cho người dùng dựa trên mức độ khẩn cấp và hướng dẫn của BYT.",
466
- "timeframe": "Nêu rõ khung thời gian thực hiện hành động (VD: 'Ngay lập tức', 'Trong 24-48 giờ', 'Khi có thể').",
467
- "home_care_advice": "Liệt các lời khuyên chăm sóc tại nhà phù hợp và an toàn, dựa trên hướng dẫn của BYT nếu có.",
468
- "warning_signs": "Dấu hiệu cảnh báo cần đi khám ngay + disclaimer (VD: 'Nếu sưng đỏ lan rộng, sốt cao, đau tăng nhanh, hãy đến khám ngay. Thông tin chỉ mang tính tham khảo.')"
469
- }
470
- }`;
 
 
 
 
 
 
 
 
 
471
 
472
  // Log prompt and input data before sending to LLM
473
  logger.info('='.repeat(80));
@@ -498,21 +600,159 @@ Hãy tạo response JSON với format sau (CHỈ JSON thuần, không có markdo
498
  const generations = await this.llm._generate([prompt]);
499
  const response = generations.generations[0][0].text;
500
 
501
- // Extract JSON from response
502
- const jsonMatch = response.match(/\{[\s\S]*\}/);
503
- if (jsonMatch) {
504
- const parsed = JSON.parse(jsonMatch[0]) as TriageResult;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
 
506
- // Log final response
507
- logger.info('='.repeat(80));
508
- logger.info('[AGENT] FINAL RESPONSE:');
509
- logger.info(JSON.stringify(parsed, null, 2));
510
- logger.info('='.repeat(80));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
 
512
- return parsed;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  }
 
514
 
515
- throw new Error('Failed to parse LLM response');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  }
517
 
518
  private getSafeDefaultResponse(userText: string): TriageResult {
 
4
  import { RAGService } from '../services/rag.service.js';
5
  import { KnowledgeBaseService } from '../services/knowledge-base.service.js';
6
  import { SupabaseService } from '../services/supabase.service.js';
7
+ import { IntentClassifierService, type Intent } from '../services/intent-classifier.service.js';
8
  import { logger } from '../utils/logger.js';
9
+ import type { TriageResult, TriageLevel, ConditionSource, ConditionConfidence } from '../types/index.js';
10
 
11
  export class MedagenAgent {
12
  private llm: GeminiLLM;
 
14
  private triageService: TriageRulesService;
15
  private ragService: RAGService;
16
  private knowledgeBase: KnowledgeBaseService;
17
+ private intentClassifier: IntentClassifierService;
18
  private initialized: boolean = false;
19
 
20
  constructor(supabaseService: SupabaseService) {
 
23
  this.triageService = new TriageRulesService();
24
  this.ragService = new RAGService(supabaseService);
25
  this.knowledgeBase = new KnowledgeBaseService(supabaseService);
26
+ this.intentClassifier = new IntentClassifierService();
27
  }
28
 
29
  async initialize(): Promise<void> {
 
58
  logger.info(`User text: "${userText}"`);
59
  logger.info(`Has image: ${!!imageUrl}`);
60
 
61
+ // Step 1: Classify intent FIRST (routing decision)
62
+ const intent = this.intentClassifier.classifyIntent(userText, !!imageUrl);
63
+ logger.info(`[ROUTING] Intent classified: ${intent.type} (confidence: ${intent.confidence})`);
64
+
65
+ // Step 2: Route based on intent
66
+ switch (intent.type) {
67
+ case 'casual_greeting':
68
+ logger.info('[ROUTING] Lightweight: Casual greeting');
69
+ return await this.handleCasualConversation(userText, conversationContext);
70
+
71
+ case 'out_of_scope':
72
+ logger.info('[ROUTING] → Lightweight: Out of scope');
73
+ return await this.handleOutOfScope(userText, intent);
74
+
75
+ case 'disease_info':
76
+ logger.info('[ROUTING] → Medium: Disease info (RAG only)');
77
+ return await this.processDiseaseInfoQuery(userText, conversationContext);
78
+
79
+ case 'triage':
80
+ if (imageUrl) {
81
+ logger.info('[ROUTING] → Full: Triage with image (CV + Triage + RAG)');
82
+ return await this.processTriageWithImage(userText, imageUrl, conversationContext);
83
+ } else {
84
+ logger.info('[ROUTING] → Full: Triage text-only (Triage + RAG)');
85
+ return await this.processTriageTextOnly(userText, conversationContext);
86
+ }
87
+
88
+ default:
89
+ // Fallback: if unclear, use lightweight response
90
+ logger.info('[ROUTING] → Lightweight: Default fallback');
91
+ return await this.handleCasualConversation(userText, conversationContext);
92
  }
93
  } catch (error) {
94
  logger.error({ error }, 'Error processing query');
 
156
 
157
  logger.info(`[AGENT] Total guidelines collected: ${guidelines.length}`);
158
 
159
+ // Format guidelines for better readability
160
+ const formattedGuidelines = guidelines.map((g, i) => {
161
+ const content = typeof g === 'string' ? g : (g.content || g.snippet || JSON.stringify(g));
162
+ return `\n--- Guideline ${i + 1} ---\n${content}`;
163
+ }).join('\n\n');
164
+
165
  // Use LLM to synthesize educational response
166
+ const prompt = `Bạn là trợ lý y tế giáo dục của Việt Nam, dựa trên hướng dẫn của Bộ Y Tế. Hãy tạo một phản hồi TỰ NHIÊN, DỄ HIỂU bằng markdown HOÀN TOÀN BẰNG TIẾNG VIỆT.
167
 
168
  Câu hỏi của người dùng: ${userText}
169
 
170
  ${conversationContext ? `Ngữ cảnh cuộc trò chuyện trước: ${conversationContext}` : ''}
171
 
172
+ ═══════════════════════════════════════════════════════════════════════════════
173
+ HƯỚNG D���N Y TẾ TỪ BỘ Y TẾ (BẮT BUỘC PHẢI SỬ DỤNG):
174
+ ═══════════════════════════════════════════════════════════════════════════════
175
+ ${formattedGuidelines}
176
+ ═══════════════════════════════════════════════════════════════════════════════
177
+
178
+ ⚠️ QUAN TRỌNG: BẮT BUỘC sử dụng thông tin từ "Hướng dẫn y tế từ Bộ Y Tế" ở trên:
179
+ - PHẢI dựa trên thông tin CỤ THỂ từ guidelines để giải thích, biện luận về bệnh/triệu chứng
180
+ - KHÔNG được tự ý tạo thông tin ngoài guidelines được cung cấp
181
+ - Có thể giải thích nguyên tắc điều trị từ guidelines (KHÔNG kê đơn cụ thể, không khuyến nghị liều thuốc)
182
+ - Nếu guidelines đề cập thuốc cụ thể, có thể giải thích: "Có thể sử dụng các thuốc như... (theo chỉ định của bác sĩ)"
183
+ - Nếu guidelines đề cập phương pháp, có thể giải thích phương pháp đó một cách tự nhiên
184
 
185
+ YÊU CẦU VỀ PHONG CÁCH VIẾT:
186
  1. VIẾT HOÀN TOÀN BẰNG TIẾNG VIỆT - không được dùng tiếng Anh trong response
187
+ 2. Viết TỰ NHIÊN, DỄ HIỂU như đang trò chuyện với người dùng
188
+ 3. THỂ biện luận, so sánh, giải thích "tại sao", "như thế nào" một cách tự do
189
+ 4. Sử dụng markdown để format (tiêu đề, danh sách, nhấn mạnh) cho dễ đọc
190
+ 5. PHẢI sử dụng thông tin từ "Hướng dẫn y tế từ Bộ Y Tế" trên - KHÔNG được tự ý tạo thông tin
191
+ 6. KHÔNG được tự thêm câu mở đầu kiểu "Based on...", "I've assessed..." hoặc "This is..."
192
+ 7. Đây câu hỏi giáo dục, KHÔNG PHẢI chẩn đoán nhân
193
+ 8. Luôn nhấn mạnh: "Thông tin chỉ mang tính tham khảo, không thay thế bác sĩ"
194
+ 9. KHÔNG đơn, KHÔNG khuyến nghị liều thuốc cụ thể
195
+
196
+ Hãy tạo một phản hồi markdown TỰ NHIÊN, có thể bao gồm:
197
+ - Giải thích về bệnh/triệu chứng dựa trên guidelines (định nghĩa, nguyên nhân, triệu chứng)
198
+ - Giải thích nguyên tắc điều trị từ guidelines (KHÔNG kê đơn cụ thể)
199
+ - Hướng dẫn về phòng ngừa và chăm sóc dựa trên guidelines
200
+ - So sánh với các bệnh tương tự nếu có
201
+ - Disclaimer về tính tham khảo
202
+
203
+ dụ format markdown (KHÔNG bắt buộc phải theo đúng format này, chỉ gợi ý):
204
+ ## 📚 Về bệnh [tên bệnh]
205
+
206
+ [Giải thích định nghĩa, nguyên nhân, triệu chứng dựa trên guidelines]
207
+
208
+ ## 💊 Nguyên tắc điều trị
209
+
210
+ [Giải thích nguyên tắc điều trị từ guidelines, KHÔNG kê đơn cụ thể]
211
+
212
+ ## 💡 Hướng dẫn phòng ngừa và chăm sóc
213
+
214
+ [Thông tin CỤ THỂ từ guidelines về phòng ngừa và chăm sóc]
215
+
216
+ ---
217
+
218
+ **Lưu ý quan trọng:** Thông tin này chỉ mang tính tham khảo giáo dục, không thay thế bác sĩ. Nếu bạn đang có triệu chứng, hãy đến gặp bác sĩ để được khám và chẩn đoán chính xác.`;
219
 
220
  // Log prompt and input data before sending to LLM
221
  logger.info('='.repeat(80));
 
237
  const generations = await this.llm._generate([prompt]);
238
  const response = generations.generations[0][0].text;
239
 
240
+ // Extract markdown content (full response is markdown)
241
+ const markdownContent = response.trim();
242
+
243
+ // Build TriageResult from markdown response
244
+ const triageLevel = 'routine' as TriageLevel;
245
+
246
+ // Extract key information from markdown for backward compatibility
247
+ const actionMatch = markdownContent.match(/##\s*[📚💊💡]*\s*(?:Về|Nguyên tắc|Hướng dẫn)[\s\S]*?\n([\s\S]*?)(?=\n##|$)/i);
248
+ const homeCareMatch = markdownContent.match(/##\s*[💡]*\s*Hướng dẫn[\s\S]*?\n([\s\S]*?)(?=\n##|$)/i);
249
+
250
+ const action = actionMatch ? actionMatch[1].trim().split('\n')[0] : 'Thông tin giáo dục về bệnh/triệu chứng dựa trên hướng dẫn của Bộ Y Tế.';
251
+ const homeCareAdvice = homeCareMatch ? homeCareMatch[1].trim().substring(0, 500) : 'Thông tin về phòng ngừa và chăm sóc từ hướng dẫn của Bộ Y Tế.';
252
+
253
+ const parsed: TriageResult = {
254
+ triage_level: triageLevel,
255
+ symptom_summary: userText,
256
+ red_flags: [],
257
+ suspected_conditions: [],
258
+ cv_findings: {
259
+ model_used: 'none',
260
+ raw_output: {}
261
+ },
262
+ recommendation: {
263
+ action: action,
264
+ timeframe: 'Không áp dụng (đây là thông tin giáo dục)',
265
+ home_care_advice: homeCareAdvice,
266
+ warning_signs: 'Thông tin chỉ mang tính tham khảo giáo dục. Nếu bạn đang có triệu chứng, hãy đến gặp bác sĩ để được khám và chẩn đoán chính xác.'
267
+ },
268
+ // Add markdown response as additional field
269
+ message: markdownContent
270
+ } as any;
271
 
272
+ // Log final response
273
+ logger.info('='.repeat(80));
274
+ logger.info('[AGENT] FINAL RESPONSE (Disease Info Query - Markdown):');
275
+ logger.info(markdownContent);
276
+ logger.info('[AGENT] FINAL RESPONSE (Disease Info Query - Structured):');
277
+ logger.info(JSON.stringify(parsed, null, 2));
278
+ logger.info('='.repeat(80));
279
 
280
+ return parsed;
 
 
 
281
  } catch (error) {
282
  logger.error({ error }, 'Error processing disease info query');
283
  return this.getSafeDefaultResponse(userText);
 
496
  ? (cvResult.top_conditions[0] as any).model_used || 'derm_cv'
497
  : 'none';
498
 
499
+ // Format guidelines for better readability
500
+ const formattedGuidelines = guidelines.map((g, i) => {
501
+ const content = typeof g === 'string' ? g : (g.content || g.snippet || JSON.stringify(g));
502
+ return `\n--- Guideline ${i + 1} ---\n${content}`;
503
+ }).join('\n\n');
504
+
505
+ const prompt = `Bạn là trợ lý y tế AI của Việt Nam. Dựa trên thông tin sau, hãy tạo một phản hồi TỰ NHIÊN, DỄ HIỂU bằng markdown HOÀN TOÀN BẰNG TIẾNG VIỆT.
506
 
507
  Mô tả triệu chứng: ${userText}
508
 
 
519
  Dấu hiệu cảnh báo: ${triageResult.red_flags?.join(', ') || 'Không có'}
520
  Lý do đánh giá: ${triageResult.reasoning}
521
 
522
+ ═══════════════════════════════════════════════════════════════════════════════
523
+ HƯỚNG DẪN Y TẾ TỪ BỘ Y TẾ (BẮT BUỘC PHẢI SỬ DỤNG):
524
+ ═══════════════════════════════════════════════════════════════════════════════
525
+ ${formattedGuidelines}
526
+ ═══════════════════════════════════════════════════════════════════════════════
527
+
528
+ ⚠️ QUAN TRỌNG: BẮT BUỘC sử dụng thông tin từ "Hướng dẫn y tế từ Bộ Y Tế" ở trên:
529
+ - PHẢI dựa trên thông tin CỤ THỂ từ guidelines để giải thích, biện luận, so sánh
530
+ - KHÔNG được tự ý tạo thông tin ngoài guidelines được cung cấp
531
+ - Có thể giải thích nguyên tắc điều trị từ guidelines (KHÔNG kê đơn cụ thể, không khuyến nghị liều thuốc)
532
+ - Nếu guidelines đề cập thuốc cụ thể, có thể giải thích: "Có thể sử dụng các thuốc bôi tại chỗ như retinoid, benzoyl peroxid (theo chỉ định của bác sĩ)"
533
+ - Nếu guidelines đề cập phương pháp, có thể giải thích phương pháp đó một cách tự nhiên
534
 
535
+ YÊU CẦU VỀ PHONG CÁCH VIẾT:
536
  1. VIẾT HOÀN TOÀN BẰNG TIẾNG VIỆT - không được dùng tiếng Anh trong response
537
+ 2. Viết TỰ NHIÊN, DỄ HIỂU như đang trò chuyện với bệnh nhân
538
+ 3. THỂ biện luận, so sánh, giải thích "tại sao", "như thế nào" một cách tự do
539
+ 4. Sử dụng markdown để format (tiêu đề, danh sách, nhấn mạnh) cho dễ đọc
540
+ 5. PHẢI sử dụng thông tin từ "Hướng dẫn y tế từ Bộ Y Tế" ở trên - KHÔNG được tự ý tạo thông tin
541
+ 6. Luôn nhấn mạnh: "Thông tin chỉ mang tính tham khảo, cần bác sĩ khám để chẩn đoán chính xác"
542
+ ${cvResult.top_conditions.length === 0 ? '7. Phân tích hình ảnh không đủ tin cậy, chỉ dựa vào mô tả triệu chứng và guidelines.' : ''}
543
+
544
+ Hãy tạo một phản hồi markdown TỰ NHIÊN, thể bao gồm:
545
+ - Giải thích về tình trạng dựa trên triệu chứng và hình ảnh (nếu có)
546
+ - So sánh các khả năng nếu có nhiều suspected conditions
547
+ - Giải thích "tại sao" đưa ra kết luận này (explainability)
548
+ - Hướng dẫn cụ thể về chăm sóc tại nhà dựa trên guidelines
549
+ - Lời khuyên về hành động tiếp theo
550
+ - Dấu hiệu cảnh báo cần đi khám ngay
551
+ - Disclaimer về tính tham khảo
552
+
553
+ dụ format markdown (KHÔNG bắt buộc phải theo đúng format này, chỉ là gợi ý):
554
+ ## 📋 Tóm tắt tình trạng
555
+
556
+ Dựa trên hình ảnh và mô tả triệu chứng của bạn...
557
+
558
+ ## 🔍 Phân tích
559
+
560
+ [Giải thích, biện luận, so sánh tự do dựa trên guidelines]
561
+
562
+ ## 💡 Hướng dẫn chăm sóc tại nhà
563
+
564
+ [Các lời khuyên CỤ THỂ từ guidelines về phương pháp điều trị tại chỗ, lưu ý về thuốc, cách chăm sóc]
565
+
566
+ ## ⚠️ Khi nào cần đi khám ngay
567
+
568
+ [Dấu hiệu cảnh báo + disclaimer]
569
+
570
+ ---
571
+
572
+ **Lưu ý quan trọng:** Thông tin này chỉ mang tính tham khảo, cần bác sĩ khám để chẩn đoán chính xác.`;
573
 
574
  // Log prompt and input data before sending to LLM
575
  logger.info('='.repeat(80));
 
600
  const generations = await this.llm._generate([prompt]);
601
  const response = generations.generations[0][0].text;
602
 
603
+ // Extract markdown content (full response is markdown)
604
+ const markdownContent = response.trim();
605
+
606
+ // Build TriageResult from markdown response
607
+ const triageLevel = triageResult.triage as TriageLevel;
608
+ const suspectedCondition = cvResult.top_conditions.length > 0 ? cvResult.top_conditions[0].name : undefined;
609
+
610
+ // Extract key information from markdown for backward compatibility
611
+ const actionMatch = markdownContent.match(/##\s*[📋💡⚠️🔍]*\s*(?:Hành động|Khi nào|Kết luận|Khuyến nghị)[\s\S]*?\n([\s\S]*?)(?=\n##|$)/i);
612
+ const homeCareMatch = markdownContent.match(/##\s*[💡]*\s*Hướng dẫn chăm sóc[\s\S]*?\n([\s\S]*?)(?=\n##|$)/i);
613
+ const warningMatch = markdownContent.match(/##\s*[⚠️]*\s*Khi nào cần đi khám[\s\S]*?\n([\s\S]*?)(?=\n##|$)/i);
614
+
615
+ const action = actionMatch ? actionMatch[1].trim().split('\n')[0] : 'Bạn nên đến gặp bác sĩ để được thăm khám và chẩn đoán chính xác.';
616
+ const homeCareAdvice = homeCareMatch ? homeCareMatch[1].trim().substring(0, 500) : 'Giữ vệ sinh sạch sẽ và theo dõi triệu chứng.';
617
+ const warningSigns = warningMatch ? warningMatch[1].trim().substring(0, 300) : 'Nếu triệu chứng nặng hơn, hãy đến khám ngay. Thông tin chỉ mang tính tham khảo, cần bác sĩ khám để chẩn đoán chính xác.';
618
+
619
+ const parsed: TriageResult = {
620
+ triage_level: triageLevel,
621
+ symptom_summary: userText,
622
+ red_flags: triageResult.red_flags || [],
623
+ suspected_conditions: suspectedCondition ? [{
624
+ name: suspectedCondition,
625
+ source: 'cv_model' as ConditionSource,
626
+ confidence: cvResult.top_conditions.length > 0 && cvResult.top_conditions[0].prob > 0.5 ? 'medium' : 'low' as ConditionConfidence
627
+ }] : [],
628
+ cv_findings: {
629
+ model_used: cvModelUsed as any,
630
+ raw_output: cvResult.top_conditions.length > 0 ? {
631
+ top_predictions: cvResult.top_conditions.slice(0, 1).map((c: any) => ({ condition: c.name, probability: c.prob }))
632
+ } : {}
633
+ },
634
+ recommendation: {
635
+ action: action,
636
+ timeframe: triageLevel === 'emergency' ? 'Ngay lập tức' : triageLevel === 'urgent' ? 'Trong 24 giờ' : 'Khi có thể sắp xếp',
637
+ home_care_advice: homeCareAdvice,
638
+ warning_signs: warningSigns
639
+ },
640
+ // Add markdown response as additional field (extend TriageResult)
641
+ message: markdownContent
642
+ } as any;
643
+
644
+ // Log final response
645
+ logger.info('='.repeat(80));
646
+ logger.info('[AGENT] FINAL RESPONSE (Markdown):');
647
+ logger.info(markdownContent);
648
+ logger.info('[AGENT] FINAL RESPONSE (Structured):');
649
+ logger.info(JSON.stringify(parsed, null, 2));
650
+ logger.info('='.repeat(80));
651
+
652
+ return parsed;
653
+ }
654
+
655
+ /**
656
+ * Handle casual conversation/greeting - lightweight response
657
+ */
658
+ private async handleCasualConversation(
659
+ userText: string,
660
+ conversationContext?: string
661
+ ): Promise<TriageResult> {
662
+ try {
663
+ logger.info('[LIGHTWEIGHT] Handling casual conversation...');
664
 
665
+ const prompt = `Bạn là trợ lý y tế thân thiện của Việt Nam. Người dùng nói: "${userText}"
666
+
667
+ ${conversationContext ? `Ngữ cảnh cuộc trò chuyện trước: ${conversationContext}` : ''}
668
+
669
+ Hãy trả lời tự nhiên, ngắn gọn, thân thiện bằng tiếng Việt:
670
+ - Nếu là câu chào, hãy chào lại và hỏi xem bạn có thể giúp gì về sức khỏe
671
+ - Nếu là câu cảm ơn, hãy trả lời lịch sự
672
+ - Nếu là câu hỏi đơn giản, hãy trả lời ngắn gọn
673
+ - Luôn sẵn sàng hỗ trợ về vấn đề sức khỏe
674
+
675
+ Viết bằng markdown, tự nhiên, không cần format cứng nhắc.`;
676
+
677
+ const generations = await this.llm._generate([prompt]);
678
+ const markdown = generations.generations[0][0].text.trim();
679
+
680
+ return this.buildLightweightResponse(markdown, 'routine', userText);
681
+ } catch (error) {
682
+ logger.error({ error }, 'Error handling casual conversation');
683
+ return this.buildLightweightResponse(
684
+ 'Xin chào! Tôi có thể giúp gì cho bạn về vấn đề sức khỏe?',
685
+ 'routine',
686
+ userText
687
+ );
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Handle out of scope queries - lightweight response
693
+ */
694
+ private async handleOutOfScope(
695
+ userText: string,
696
+ intent: Intent
697
+ ): Promise<TriageResult> {
698
+ try {
699
+ logger.info('[LIGHTWEIGHT] Handling out of scope query...');
700
 
701
+ const prompt = `Bạn là trợ lý y tế của Việt Nam. Người dùng hỏi: "${userText}"
702
+
703
+ Câu hỏi này nằm ngoài phạm vi của hệ thống (${JSON.stringify(intent.entities)}).
704
+
705
+ Hãy từ chối lịch sự và hướng dẫn h�� đến kênh phù hợp:
706
+ - Nếu hỏi về bảo hiểm/chi phí: hướng dẫn liên hệ cơ quan bảo hiểm hoặc bệnh viện
707
+ - Nếu hỏi về thuốc nam/đông y: giải thích hệ thống chỉ hỗ trợ hướng dẫn của Bộ Y Tế
708
+ - Luôn lịch sự, thân thiện
709
+
710
+ Viết bằng tiếng Việt, markdown format, ngắn gọn.`;
711
+
712
+ const generations = await this.llm._generate([prompt]);
713
+ const markdown = generations.generations[0][0].text.trim();
714
+
715
+ return this.buildLightweightResponse(markdown, 'routine', userText);
716
+ } catch (error) {
717
+ logger.error({ error }, 'Error handling out of scope');
718
+ return this.buildLightweightResponse(
719
+ 'Xin lỗi, câu hỏi này nằm ngoài phạm vi của hệ thống. Vui lòng liên hệ trực tiếp với cơ sở y tế để được hỗ trợ.',
720
+ 'routine',
721
+ userText
722
+ );
723
  }
724
+ }
725
 
726
+ /**
727
+ * Build lightweight response structure
728
+ */
729
+ private buildLightweightResponse(
730
+ markdown: string,
731
+ triageLevel: TriageLevel,
732
+ userText?: string
733
+ ): TriageResult {
734
+ // Extract first meaningful line for action field
735
+ const actionLine = markdown.split('\n').find(line =>
736
+ line.trim().length > 10 && !line.trim().startsWith('#')
737
+ ) || markdown.split('\n')[0] || 'Cảm ơn bạn đã liên hệ.';
738
+
739
+ return {
740
+ triage_level: triageLevel,
741
+ symptom_summary: userText || '',
742
+ red_flags: [],
743
+ suspected_conditions: [],
744
+ cv_findings: {
745
+ model_used: 'none',
746
+ raw_output: {}
747
+ },
748
+ recommendation: {
749
+ action: actionLine.trim(),
750
+ timeframe: 'Không áp dụng',
751
+ home_care_advice: '',
752
+ warning_signs: ''
753
+ },
754
+ message: markdown
755
+ } as any;
756
  }
757
 
758
  private getSafeDefaultResponse(userText: string): TriageResult {
src/routes/triage.route.ts CHANGED
@@ -233,8 +233,8 @@ export async function triageRoutes(
233
 
234
  // Add assistant response to conversation history
235
  try {
236
- // Chỉ lấy recommendation.action, loại bỏ thông tin không cần thiết
237
- const assistantMessage = triageResult.recommendation.action;
238
  await conversationService.addAssistantMessage(
239
  activeSessionId,
240
  user_id,
 
233
 
234
  // Add assistant response to conversation history
235
  try {
236
+ // Use markdown message if available, otherwise fallback to recommendation.action
237
+ const assistantMessage = (triageResult as any).message || triageResult.recommendation.action;
238
  await conversationService.addAssistantMessage(
239
  activeSessionId,
240
  user_id,
src/services/intent-classifier.service.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { logger } from '../utils/logger.js';
2
 
3
- export type IntentType = 'triage' | 'disease_info' | 'symptom_inquiry' | 'general_health' | 'out_of_scope';
4
 
5
  export interface Intent {
6
  type: IntentType;
@@ -43,6 +43,12 @@ export class IntentClassifierService {
43
  'thuốc nam', 'đông y', 'thảo dược', 'bài thuốc'
44
  ];
45
 
 
 
 
 
 
 
46
  /**
47
  * Classify user intent based on query text
48
  */
@@ -51,7 +57,17 @@ export class IntentClassifierService {
51
 
52
  logger.info(`Classifying intent for query: "${query.substring(0, 50)}..."`);
53
 
54
- // Check for out of scope first
 
 
 
 
 
 
 
 
 
 
55
  if (this.isOutOfScope(lowerQuery)) {
56
  return {
57
  type: 'out_of_scope',
@@ -131,6 +147,17 @@ export class IntentClassifierService {
131
  return this.OUT_OF_SCOPE_KEYWORDS.some(keyword => query.includes(keyword));
132
  }
133
 
 
 
 
 
 
 
 
 
 
 
 
134
  private calculateKeywordScore(query: string, keywords: string[]): number {
135
  const matches = keywords.filter(keyword => query.includes(keyword));
136
  return matches.length / keywords.length;
 
1
  import { logger } from '../utils/logger.js';
2
 
3
+ export type IntentType = 'triage' | 'disease_info' | 'symptom_inquiry' | 'general_health' | 'out_of_scope' | 'casual_greeting';
4
 
5
  export interface Intent {
6
  type: IntentType;
 
43
  'thuốc nam', 'đông y', 'thảo dược', 'bài thuốc'
44
  ];
45
 
46
+ private readonly CASUAL_GREETING_KEYWORDS = [
47
+ 'xin chào', 'chào', 'hi', 'hello', 'cảm ơn', 'thanks', 'thank you',
48
+ 'tạm biệt', 'bye', 'goodbye', 'ok', 'okay', 'được rồi', 'hiểu rồi',
49
+ 'vâng', 'dạ', 'ừ', 'ừm', 'uhm', 'à', 'ah'
50
+ ];
51
+
52
  /**
53
  * Classify user intent based on query text
54
  */
 
57
 
58
  logger.info(`Classifying intent for query: "${query.substring(0, 50)}..."`);
59
 
60
+ // Check for casual greeting first (highest priority for lightweight processing)
61
+ if (this.isCasualGreeting(lowerQuery)) {
62
+ return {
63
+ type: 'casual_greeting',
64
+ confidence: 0.95,
65
+ entities: {},
66
+ needsClarification: false
67
+ };
68
+ }
69
+
70
+ // Check for out of scope
71
  if (this.isOutOfScope(lowerQuery)) {
72
  return {
73
  type: 'out_of_scope',
 
147
  return this.OUT_OF_SCOPE_KEYWORDS.some(keyword => query.includes(keyword));
148
  }
149
 
150
+ private isCasualGreeting(query: string): boolean {
151
+ // Check if query is very short (likely greeting) or contains greeting keywords
152
+ const trimmedQuery = query.trim();
153
+ if (trimmedQuery.length <= 15) {
154
+ // Very short queries are likely greetings
155
+ return this.CASUAL_GREETING_KEYWORDS.some(keyword => trimmedQuery.includes(keyword)) ||
156
+ /^(hi|hello|chào|xin chào|cảm ơn|thanks|ok|okay|vâng|dạ|ừ|bye)$/i.test(trimmedQuery);
157
+ }
158
+ return this.CASUAL_GREETING_KEYWORDS.some(keyword => query.includes(keyword));
159
+ }
160
+
161
  private calculateKeywordScore(query: string, keywords: string[]): number {
162
  const matches = keywords.filter(keyword => query.includes(keyword));
163
  return matches.length / keywords.length;
src/types/index.ts CHANGED
@@ -53,6 +53,7 @@ export interface TriageResult {
53
  suspected_conditions: SuspectedCondition[];
54
  cv_findings: CVFindings;
55
  recommendation: Recommendation;
 
56
  }
57
 
58
  export interface NearestClinic {
 
53
  suspected_conditions: SuspectedCondition[];
54
  cv_findings: CVFindings;
55
  recommendation: Recommendation;
56
+ message?: string; // Markdown response from LLM (natural language, not constrained by JSON structure)
57
  }
58
 
59
  export interface NearestClinic {