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 +331 -91
- src/routes/triage.route.ts +2 -2
- src/services/intent-classifier.service.ts +29 -2
- src/types/index.ts +1 -0
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 |
-
//
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
-
YÊU CẦU
|
| 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.
|
| 149 |
-
3.
|
| 150 |
-
4.
|
| 151 |
-
5.
|
| 152 |
-
6.
|
| 153 |
-
7.
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 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 |
-
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
| 199 |
|
| 200 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
|
| 442 |
-
YÊU CẦU
|
| 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.
|
| 445 |
-
3.
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
{
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 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
|
| 502 |
-
const
|
| 503 |
-
|
| 504 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
|
| 512 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
}
|
|
|
|
| 514 |
|
| 515 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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. CÓ 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 là câu hỏi giáo dục, KHÔNG PHẢI chẩn đoán cá 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 kê đơ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 |
+
Ví dụ format markdown (KHÔNG bắt buộc phải theo đúng format này, chỉ là 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. CÓ 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, có 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 |
+
Ví 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 |
-
//
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|