|
|
import { GeminiLLM } from './gemini-llm.js'; |
|
|
import { CVService } from '../services/cv.service.js'; |
|
|
import { TriageRulesService } from '../services/triage-rules.service.js'; |
|
|
import { RAGService } from '../services/rag.service.js'; |
|
|
import { KnowledgeBaseService } from '../services/knowledge-base.service.js'; |
|
|
import { SupabaseService } from '../services/supabase.service.js'; |
|
|
import { MapsService } from '../services/maps.service.js'; |
|
|
import { IntentClassifierService, type Intent } from '../services/intent-classifier.service.js'; |
|
|
import { logger } from '../utils/logger.js'; |
|
|
import type { TriageResult, TriageLevel, ConditionSource, ConditionConfidence, Location, NearestClinic } from '../types/index.js'; |
|
|
|
|
|
export class MedagenAgent { |
|
|
private llm: GeminiLLM; |
|
|
private cvService: CVService; |
|
|
private triageService: TriageRulesService; |
|
|
private ragService: RAGService; |
|
|
private knowledgeBase: KnowledgeBaseService; |
|
|
private mapsService: MapsService; |
|
|
private intentClassifier: IntentClassifierService; |
|
|
private initialized: boolean = false; |
|
|
|
|
|
constructor(supabaseService: SupabaseService, mapsService?: MapsService) { |
|
|
this.llm = new GeminiLLM(); |
|
|
this.cvService = new CVService(); |
|
|
this.triageService = new TriageRulesService(); |
|
|
this.ragService = new RAGService(supabaseService); |
|
|
this.knowledgeBase = new KnowledgeBaseService(supabaseService); |
|
|
this.mapsService = mapsService || new MapsService(); |
|
|
this.intentClassifier = new IntentClassifierService(); |
|
|
} |
|
|
|
|
|
async initialize(): Promise<void> { |
|
|
if (this.initialized) return; |
|
|
|
|
|
try { |
|
|
logger.info('Initializing Medagen Agent...'); |
|
|
|
|
|
|
|
|
await this.ragService.initialize(); |
|
|
|
|
|
this.initialized = true; |
|
|
logger.info('Medagen Agent initialized successfully'); |
|
|
} catch (error) { |
|
|
logger.error({ error }, 'Failed to initialize agent'); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
async processTriage( |
|
|
userText: string, |
|
|
imageUrl?: string, |
|
|
_userId?: string, |
|
|
conversationContext?: string, |
|
|
location?: Location |
|
|
): Promise<TriageResult & { nearest_clinic?: NearestClinic }> { |
|
|
if (!this.initialized) { |
|
|
await this.initialize(); |
|
|
} |
|
|
|
|
|
try { |
|
|
logger.info('Starting query processing...'); |
|
|
logger.info(`User text: "${userText}"`); |
|
|
logger.info(`Has image: ${!!imageUrl}`); |
|
|
|
|
|
|
|
|
const intent = this.intentClassifier.classifyIntent(userText, !!imageUrl); |
|
|
logger.info(`[ROUTING] Intent classified: ${intent.type} (confidence: ${intent.confidence})`); |
|
|
|
|
|
|
|
|
switch (intent.type) { |
|
|
case 'casual_greeting': |
|
|
logger.info('[ROUTING] → Lightweight: Casual greeting'); |
|
|
return await this.handleCasualConversation(userText, conversationContext); |
|
|
|
|
|
case 'out_of_scope': |
|
|
logger.info('[ROUTING] → Lightweight: Out of scope'); |
|
|
return await this.handleOutOfScope(userText, intent); |
|
|
|
|
|
case 'disease_info': |
|
|
logger.info('[ROUTING] → Medium: Disease info (RAG only)'); |
|
|
return await this.processDiseaseInfoQuery(userText, conversationContext); |
|
|
|
|
|
case 'triage': |
|
|
if (imageUrl) { |
|
|
logger.info('[ROUTING] → Full: Triage with image (CV + Triage + RAG)'); |
|
|
return await this.processTriageWithImage(userText, imageUrl, conversationContext, location); |
|
|
} else { |
|
|
logger.info('[ROUTING] → Full: Triage text-only (Triage + RAG)'); |
|
|
return await this.processTriageTextOnly(userText, conversationContext, location); |
|
|
} |
|
|
|
|
|
default: |
|
|
|
|
|
logger.info('[ROUTING] → Lightweight: Default fallback'); |
|
|
return await this.handleCasualConversation(userText, conversationContext); |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error({ error }, 'Error processing query'); |
|
|
|
|
|
|
|
|
return this.getSafeDefaultResponse(userText); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async processDiseaseInfoQuery( |
|
|
userText: string, |
|
|
conversationContext?: string |
|
|
): Promise<TriageResult> { |
|
|
try { |
|
|
logger.info('='.repeat(80)); |
|
|
logger.info('[AGENT WORKFLOW] processDiseaseInfoQuery STARTED'); |
|
|
logger.info(`[AGENT] User text: "${userText}"`); |
|
|
|
|
|
|
|
|
let guidelines: any[] = []; |
|
|
|
|
|
|
|
|
logger.info('[AGENT] Step 1: Attempting structured knowledge search...'); |
|
|
try { |
|
|
|
|
|
const diseaseKeywords = userText.match(/(?:bệnh|về)\s+([^?.,!]+)/i); |
|
|
if (diseaseKeywords && diseaseKeywords[1]) { |
|
|
const potentialDisease = diseaseKeywords[1].trim(); |
|
|
logger.info(`[AGENT] Potential disease name: ${potentialDisease}`); |
|
|
|
|
|
const disease = await this.knowledgeBase.findDisease(potentialDisease); |
|
|
if (disease) { |
|
|
logger.info(`[AGENT] Found disease: ${disease.name} (ID: ${disease.id})`); |
|
|
const structuredResults = await this.knowledgeBase.queryStructuredKnowledge({ |
|
|
disease: disease.name, |
|
|
query: userText |
|
|
}); |
|
|
if (structuredResults.length > 0) { |
|
|
guidelines = structuredResults; |
|
|
logger.info(`[AGENT] Retrieved ${guidelines.length} structured knowledge chunks from CSDL`); |
|
|
} |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
logger.warn({ error }, '[AGENT] Knowledge base search failed, will use RAG'); |
|
|
} |
|
|
|
|
|
|
|
|
if (guidelines.length === 0) { |
|
|
logger.info('[AGENT] Step 2: Using RAG for semantic search...'); |
|
|
const guidelineQuery = { |
|
|
symptoms: userText, |
|
|
suspected_conditions: [], |
|
|
triage_level: 'routine' |
|
|
}; |
|
|
|
|
|
logger.info(`[AGENT] Calling MCP RAG - searchGuidelines...`); |
|
|
guidelines = await this.ragService.searchGuidelines(guidelineQuery); |
|
|
logger.info(`[AGENT] Retrieved ${guidelines.length} guideline snippets from RAG`); |
|
|
} |
|
|
|
|
|
logger.info(`[AGENT] Total guidelines collected: ${guidelines.length}`); |
|
|
|
|
|
|
|
|
const formattedGuidelines = guidelines.map((g, i) => { |
|
|
const content = typeof g === 'string' ? g : (g.content || g.snippet || JSON.stringify(g)); |
|
|
return `\n--- Guideline ${i + 1} ---\n${content}`; |
|
|
}).join('\n\n'); |
|
|
|
|
|
|
|
|
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. |
|
|
|
|
|
Câu hỏi của người dùng: ${userText} |
|
|
|
|
|
${conversationContext ? `Ngữ cảnh cuộc trò chuyện trước: ${conversationContext}` : ''} |
|
|
|
|
|
═══════════════════════════════════════════════════════════════════════════════ |
|
|
HƯỚNG DẪN Y TẾ TỪ BỘ Y TẾ (BẮT BUỘC PHẢI SỬ DỤNG): |
|
|
═══════════════════════════════════════════════════════════════════════════════ |
|
|
${formattedGuidelines} |
|
|
═══════════════════════════════════════════════════════════════════════════════ |
|
|
|
|
|
⚠️ 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: |
|
|
- 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 |
|
|
- KHÔNG được tự ý tạo thông tin ngoài guidelines được cung cấp |
|
|
- 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) |
|
|
- 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ĩ)" |
|
|
- 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 |
|
|
|
|
|
YÊU CẦU VỀ PHONG CÁCH VIẾT: |
|
|
1. VIẾT HOÀN TOÀN BẰNG TIẾNG VIỆT - không được dùng tiếng Anh trong response |
|
|
2. Viết NGẮN GỌN, CÔ ĐỌNG - tối đa 250-350 từ, tập trung vào thông tin quan trọng nhất |
|
|
3. Viết TỰ NHIÊN, DỄ HIỂU như đang trò chuyện với người dùng |
|
|
4. CÓ THỂ biện luận, giải thích nhưng NGẮN GỌN, không lan man |
|
|
5. Sử dụng markdown để format (tiêu đề, danh sách) cho dễ đọc |
|
|
6. 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 |
|
|
7. KHÔNG được tự thêm câu mở đầu kiểu "Based on...", "I've assessed..." hoặc "This is..." |
|
|
8. Đây là câu hỏi giáo dục, KHÔNG PHẢI chẩn đoán cá nhân |
|
|
9. Luôn nhấn mạnh: "Thông tin chỉ mang tính tham khảo, không thay thế bác sĩ" |
|
|
10. KHÔNG kê đơn, KHÔNG khuyến nghị liều thuốc cụ thể |
|
|
|
|
|
QUAN TRỌNG VỀ ĐỘ DÀI: |
|
|
- Tối đa 250-350 từ (khoảng 1-2 đoạn văn ngắn) |
|
|
- Tập trung vào: định nghĩa ngắn gọn, nguyên tắc điều trị chính, phòng ngừa |
|
|
- KHÔNG lặp lại thông tin, KHÔNG giải thích quá chi tiết |
|
|
- Ưu tiên thông tin thực tế, dễ hiểu |
|
|
|
|
|
Hãy tạo một phản hồi markdown NGẮN GỌN, cô đọng, bao gồm: |
|
|
- Định nghĩa ngắn gọn về bệnh/triệu chứng (2-3 câu) |
|
|
- Nguyên tắc điều trị chính từ guidelines (3-4 điểm ngắn gọn) |
|
|
- Hướng dẫn phòng ngừa và chăm sóc (2-3 điểm) |
|
|
- Disclaimer ngắn gọn |
|
|
|
|
|
Ví dụ format markdown NGẮN GỌN: |
|
|
## 📚 Về bệnh [tên bệnh] |
|
|
|
|
|
[Định nghĩa ngắn gọn 2-3 câu từ guidelines] |
|
|
|
|
|
## 💊 Nguyên tắc điều trị |
|
|
|
|
|
- [Điểm 1 - ngắn gọn] |
|
|
- [Điểm 2 - ngắn gọn] |
|
|
- [Điểm 3 - ngắn gọn] |
|
|
|
|
|
## 💡 Phòng ngừa và chăm sóc |
|
|
|
|
|
- [Điểm 1 - ngắn gọn] |
|
|
- [Điểm 2 - ngắn gọn] |
|
|
|
|
|
**Lưu ý:** Thông tin chỉ mang tính tham khảo giáo dục, không thay thế bác sĩ.`; |
|
|
|
|
|
|
|
|
logger.info('='.repeat(80)); |
|
|
logger.info('[AGENT] PROMPT SENT TO LLM (Disease Info Query):'); |
|
|
logger.info(prompt); |
|
|
logger.info('='.repeat(80)); |
|
|
logger.info('[AGENT] INPUT DATA SUMMARY (Disease Info Query):'); |
|
|
logger.info(`- User text: "${userText}"`); |
|
|
logger.info(`- Guidelines count: ${guidelines.length}`); |
|
|
if (guidelines.length > 0) { |
|
|
guidelines.forEach((g, i) => { |
|
|
const content = typeof g === 'string' ? g : (g.content || g.snippet || JSON.stringify(g)); |
|
|
logger.info(` ${i + 1}. Preview: ${content.substring(0, 200)}...`); |
|
|
}); |
|
|
} |
|
|
logger.info(`- Conversation context: ${conversationContext ? 'Yes' : 'No'}`); |
|
|
logger.info('='.repeat(80)); |
|
|
|
|
|
const generations = await this.llm._generate([prompt]); |
|
|
const response = generations.generations[0][0].text; |
|
|
|
|
|
|
|
|
const markdownContent = response.trim(); |
|
|
|
|
|
|
|
|
const triageLevel = 'routine' as TriageLevel; |
|
|
|
|
|
|
|
|
const actionMatch = markdownContent.match(/##\s*[📚💊💡]*\s*(?:Về|Nguyên tắc|Hướng dẫn)[\s\S]*?\n([\s\S]*?)(?=\n##|$)/i); |
|
|
const homeCareMatch = markdownContent.match(/##\s*[💡]*\s*Hướng dẫn[\s\S]*?\n([\s\S]*?)(?=\n##|$)/i); |
|
|
|
|
|
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ế.'; |
|
|
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ế.'; |
|
|
|
|
|
const parsed: TriageResult = { |
|
|
triage_level: triageLevel, |
|
|
symptom_summary: userText, |
|
|
red_flags: [], |
|
|
suspected_conditions: [], |
|
|
cv_findings: { |
|
|
model_used: 'none', |
|
|
raw_output: {} |
|
|
}, |
|
|
recommendation: { |
|
|
action: action, |
|
|
timeframe: 'Không áp dụng (đây là thông tin giáo dục)', |
|
|
home_care_advice: homeCareAdvice, |
|
|
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.' |
|
|
}, |
|
|
|
|
|
message: markdownContent |
|
|
} as any; |
|
|
|
|
|
|
|
|
logger.info('='.repeat(80)); |
|
|
logger.info('[AGENT] FINAL RESPONSE (Disease Info Query - Markdown):'); |
|
|
logger.info(markdownContent); |
|
|
logger.info('[AGENT] FINAL RESPONSE (Disease Info Query - Structured):'); |
|
|
logger.info(JSON.stringify(parsed, null, 2)); |
|
|
logger.info('='.repeat(80)); |
|
|
|
|
|
return parsed; |
|
|
} catch (error) { |
|
|
logger.error({ error }, 'Error processing disease info query'); |
|
|
return this.getSafeDefaultResponse(userText); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async processTriageWithImage( |
|
|
userText: string, |
|
|
imageUrl: string, |
|
|
conversationContext?: string, |
|
|
location?: Location |
|
|
): Promise<TriageResult & { nearest_clinic?: NearestClinic }> { |
|
|
try { |
|
|
logger.info('Processing triage with image using custom workflow...'); |
|
|
|
|
|
|
|
|
logger.info('Step 1: Analyzing image with CV model...'); |
|
|
const cvType = this.determineCVType(userText); |
|
|
const cvResult = await this.callCVModel(imageUrl, cvType); |
|
|
|
|
|
logger.info(`CV analysis complete. Top condition: ${cvResult.top_conditions[0]?.name || 'none'}`); |
|
|
logger.info(`CV confidence: ${cvResult.top_conditions[0]?.prob ? (cvResult.top_conditions[0].prob * 100).toFixed(1) + '%' : 'N/A'}`); |
|
|
|
|
|
|
|
|
const CV_CONFIDENCE_THRESHOLD = 0.5; |
|
|
const validCVResults = cvResult.top_conditions.filter((c: any) => c.prob >= CV_CONFIDENCE_THRESHOLD); |
|
|
|
|
|
if (validCVResults.length === 0) { |
|
|
logger.warn(`[AGENT] CV results có confidence quá thấp (< ${CV_CONFIDENCE_THRESHOLD * 100}%). Sẽ bỏ qua CV results và chỉ dùng text-based analysis.`); |
|
|
logger.info(`[AGENT] Top CV result: ${cvResult.top_conditions[0]?.name} (${(cvResult.top_conditions[0]?.prob * 100 || 0).toFixed(1)}%)`); |
|
|
} else { |
|
|
logger.info(`[AGENT] Sử dụng ${validCVResults.length} CV results với confidence >= ${CV_CONFIDENCE_THRESHOLD * 100}%`); |
|
|
} |
|
|
|
|
|
|
|
|
logger.info('Step 2: Applying triage rules...'); |
|
|
const triageInput = { |
|
|
symptoms: { |
|
|
main_complaint: userText || 'Triệu chứng dựa trên hình ảnh', |
|
|
context: conversationContext |
|
|
}, |
|
|
cv_results: validCVResults.length > 0 ? { |
|
|
model_used: cvType === 'derm' ? 'derm_cv' : cvType === 'eye' ? 'eye_cv' : 'wound_cv', |
|
|
raw_output: { |
|
|
top_predictions: validCVResults.map(c => ({ |
|
|
condition: c.name, |
|
|
probability: c.prob |
|
|
})) |
|
|
} |
|
|
} : undefined |
|
|
}; |
|
|
|
|
|
const triageResult = this.triageService.evaluateSymptoms(triageInput); |
|
|
logger.info(`Triage level: ${triageResult.triage}`); |
|
|
|
|
|
|
|
|
logger.info('[AGENT] Step 3: Retrieving medical guidelines from RAG...'); |
|
|
|
|
|
|
|
|
const suspectedConditions = validCVResults.length > 0 |
|
|
? validCVResults.slice(0, 1).map(c => c.name) |
|
|
: []; |
|
|
|
|
|
if (validCVResults.length === 0) { |
|
|
logger.info('[AGENT] Không dùng CV conditions trong RAG search vì confidence quá thấp. Chỉ dùng user symptoms.'); |
|
|
} |
|
|
|
|
|
const guidelineInput = { |
|
|
symptoms: userText, |
|
|
suspected_conditions: suspectedConditions, |
|
|
triage_level: triageResult.triage |
|
|
}; |
|
|
|
|
|
logger.info(`[AGENT] Calling MCP RAG - searchGuidelines...`); |
|
|
const guidelines = await this.ragService.searchGuidelines(guidelineInput); |
|
|
logger.info(`[AGENT] Retrieved ${guidelines.length} guideline snippets from RAG`); |
|
|
|
|
|
(guidelineInput as any).guidelines_count = guidelines.length; |
|
|
|
|
|
|
|
|
logger.info('Step 4: Synthesizing final response with LLM...'); |
|
|
|
|
|
const filteredCVResult = { |
|
|
top_conditions: validCVResults.length > 0 ? validCVResults : [] |
|
|
}; |
|
|
|
|
|
const finalResult = await this.synthesizeFinalResponse( |
|
|
userText, |
|
|
filteredCVResult, |
|
|
triageResult, |
|
|
guidelines, |
|
|
conversationContext |
|
|
); |
|
|
|
|
|
|
|
|
(finalResult as any).guidelines_count = guidelines.length; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const condition = finalResult.suspected_conditions?.length > 0 |
|
|
? finalResult.suspected_conditions[0].name |
|
|
: (validCVResults.length > 0 ? validCVResults[0].name : undefined); |
|
|
|
|
|
if ((triageResult.triage === 'emergency' || triageResult.triage === 'urgent') && location) { |
|
|
logger.info(`[AGENT] Step 5: Finding best matching hospital (emergency/urgent case)${condition ? ` for condition: ${condition}` : ''}...`); |
|
|
logger.info('[REPORT] Hospital tool (MCP) will be executed for emergency/urgent case'); |
|
|
try { |
|
|
const bestHospital = await this.mapsService.findBestMatchingHospital( |
|
|
location, |
|
|
condition, |
|
|
'bệnh viện' |
|
|
); |
|
|
if (bestHospital) { |
|
|
logger.info(`[AGENT] Found best matching hospital: ${bestHospital.name} (${bestHospital.distance_km}km away${bestHospital.specialty_score ? `, specialty match: ${bestHospital.specialty_score.toFixed(2)}` : ''})`); |
|
|
logger.info(`[REPORT] ✓ Hospital tool (MCP) executed successfully: ${bestHospital.name}`); |
|
|
|
|
|
const hospitalInfo = `\n\n## 🏥 Bệnh viện gần nhất\n\n**${bestHospital.name}**\n- Khoảng cách: ${bestHospital.distance_km}km\n- Địa chỉ: ${bestHospital.address || 'Địa chỉ không có sẵn'}${bestHospital.rating ? `\n- Đánh giá: ${bestHospital.rating}/5` : ''}`; |
|
|
return { |
|
|
...finalResult, |
|
|
nearest_clinic: bestHospital, |
|
|
message: (finalResult.message || '') + hospitalInfo |
|
|
}; |
|
|
} else { |
|
|
logger.warn('[AGENT] No hospital found nearby'); |
|
|
logger.info('[REPORT] Hospital tool (MCP) executed but no hospital found'); |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error({ error }, '[AGENT] Failed to find best matching hospital'); |
|
|
logger.error('[REPORT] Hospital tool (MCP) execution failed'); |
|
|
|
|
|
} |
|
|
} else if (this.shouldSuggestHospital(userText)) { |
|
|
|
|
|
if (location) { |
|
|
logger.info(`[AGENT] Step 5: Finding best matching hospital (user requested)${condition ? ` for condition: ${condition}` : ''}...`); |
|
|
logger.info('[REPORT] Hospital tool (MCP) will be executed (user explicitly requested)'); |
|
|
try { |
|
|
const bestHospital = await this.mapsService.findBestMatchingHospital( |
|
|
location, |
|
|
condition, |
|
|
'bệnh viện' |
|
|
); |
|
|
if (bestHospital) { |
|
|
logger.info(`[AGENT] Found best matching hospital: ${bestHospital.name} (${bestHospital.distance_km}km away${bestHospital.specialty_score ? `, specialty match: ${bestHospital.specialty_score.toFixed(2)}` : ''})`); |
|
|
logger.info(`[REPORT] ✓ Hospital tool (MCP) executed successfully: ${bestHospital.name}`); |
|
|
return { |
|
|
...finalResult, |
|
|
nearest_clinic: bestHospital |
|
|
}; |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error({ error }, '[AGENT] Failed to find best matching hospital'); |
|
|
logger.error('[REPORT] Hospital tool (MCP) execution failed'); |
|
|
} |
|
|
} else { |
|
|
logger.info('[REPORT] Hospital tool (MCP) requested by user but no location provided - will request location in response'); |
|
|
|
|
|
(finalResult as any).needs_location_for_hospital = true; |
|
|
} |
|
|
} else { |
|
|
if (location) { |
|
|
logger.info(`[REPORT] Hospital tool (MCP) skipped: triage_level=${triageResult.triage} (only called for emergency/urgent or explicit request)`); |
|
|
} else { |
|
|
logger.info('[REPORT] Hospital tool (MCP) skipped: no location provided'); |
|
|
} |
|
|
} |
|
|
|
|
|
return finalResult; |
|
|
} catch (error) { |
|
|
logger.error({ error }, 'Error in custom agent workflow'); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async processTriageTextOnly( |
|
|
userText: string, |
|
|
conversationContext?: string, |
|
|
location?: Location |
|
|
): Promise<TriageResult & { nearest_clinic?: NearestClinic }> { |
|
|
try { |
|
|
logger.info('Processing text-only query...'); |
|
|
|
|
|
|
|
|
|
|
|
const lowerText = userText.toLowerCase(); |
|
|
const isEducationalQuery = |
|
|
lowerText.includes('là gì') || |
|
|
lowerText.includes('như thế nào') || |
|
|
lowerText.includes('về') || |
|
|
lowerText.includes('giải thích') || |
|
|
lowerText.includes('cho tôi biết'); |
|
|
|
|
|
if (isEducationalQuery) { |
|
|
|
|
|
logger.info('[AGENT] Detected educational query, using knowledge base/RAG workflow'); |
|
|
return await this.processDiseaseInfoQuery(userText, conversationContext); |
|
|
} |
|
|
|
|
|
|
|
|
logger.info('[AGENT] Detected symptom query, using triage workflow'); |
|
|
|
|
|
|
|
|
const triageInput = { |
|
|
symptoms: { |
|
|
main_complaint: userText, |
|
|
context: conversationContext |
|
|
} |
|
|
}; |
|
|
|
|
|
const triageResult = this.triageService.evaluateSymptoms(triageInput); |
|
|
|
|
|
|
|
|
const guidelineInput = { |
|
|
symptoms: userText, |
|
|
suspected_conditions: [], |
|
|
triage_level: triageResult.triage |
|
|
}; |
|
|
|
|
|
const guidelines = await this.ragService.searchGuidelines(guidelineInput); |
|
|
|
|
|
|
|
|
const finalResult = await this.synthesizeFinalResponse( |
|
|
userText, |
|
|
{ top_conditions: [] }, |
|
|
triageResult, |
|
|
guidelines, |
|
|
conversationContext |
|
|
); |
|
|
|
|
|
|
|
|
(finalResult as any).guidelines_count = guidelines.length; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const condition = finalResult.suspected_conditions?.length > 0 |
|
|
? finalResult.suspected_conditions[0].name |
|
|
: undefined; |
|
|
|
|
|
if ((triageResult.triage === 'emergency' || triageResult.triage === 'urgent') && location) { |
|
|
logger.info(`[AGENT] Step 4: Finding best matching hospital (emergency/urgent case)${condition ? ` for condition: ${condition}` : ''}...`); |
|
|
try { |
|
|
const bestHospital = await this.mapsService.findBestMatchingHospital( |
|
|
location, |
|
|
condition, |
|
|
'bệnh viện' |
|
|
); |
|
|
if (bestHospital) { |
|
|
logger.info(`[AGENT] Found best matching hospital: ${bestHospital.name} (${bestHospital.distance_km}km away${bestHospital.specialty_score ? `, specialty match: ${bestHospital.specialty_score.toFixed(2)}` : ''})`); |
|
|
logger.info(`[REPORT] ✓ Hospital tool (MCP) executed successfully: ${bestHospital.name}`); |
|
|
|
|
|
const hospitalInfo = `\n\n## 🏥 Bệnh viện gần nhất\n\n**${bestHospital.name}**\n- Khoảng cách: ${bestHospital.distance_km}km\n- Địa chỉ: ${bestHospital.address || 'Địa chỉ không có sẵn'}${bestHospital.rating ? `\n- Đánh giá: ${bestHospital.rating}/5` : ''}`; |
|
|
return { |
|
|
...finalResult, |
|
|
nearest_clinic: bestHospital, |
|
|
message: (finalResult.message || '') + hospitalInfo |
|
|
}; |
|
|
} else { |
|
|
logger.warn('[AGENT] No hospital found nearby'); |
|
|
logger.info('[REPORT] Hospital tool (MCP) executed but no hospital found'); |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error({ error }, '[AGENT] Failed to find best matching hospital'); |
|
|
|
|
|
} |
|
|
} else if (this.shouldSuggestHospital(userText)) { |
|
|
|
|
|
if (location) { |
|
|
logger.info(`[AGENT] Step 4: Finding best matching hospital (user requested)${condition ? ` for condition: ${condition}` : ''}...`); |
|
|
logger.info('[REPORT] Hospital tool (MCP) will be executed (user explicitly requested)'); |
|
|
try { |
|
|
const bestHospital = await this.mapsService.findBestMatchingHospital( |
|
|
location, |
|
|
condition, |
|
|
'bệnh viện' |
|
|
); |
|
|
if (bestHospital) { |
|
|
logger.info(`[AGENT] Found best matching hospital: ${bestHospital.name} (${bestHospital.distance_km}km away${bestHospital.specialty_score ? `, specialty match: ${bestHospital.specialty_score.toFixed(2)}` : ''})`); |
|
|
logger.info(`[REPORT] ✓ Hospital tool (MCP) executed successfully: ${bestHospital.name}`); |
|
|
|
|
|
const hospitalInfo = `\n\n## 🏥 Bệnh viện gần nhất\n\n**${bestHospital.name}**\n- Khoảng cách: ${bestHospital.distance_km}km\n- Địa chỉ: ${bestHospital.address || 'Địa chỉ không có sẵn'}${bestHospital.rating ? `\n- Đánh giá: ${bestHospital.rating}/5` : ''}`; |
|
|
return { |
|
|
...finalResult, |
|
|
nearest_clinic: bestHospital, |
|
|
message: (finalResult.message || '') + hospitalInfo |
|
|
}; |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error({ error }, '[AGENT] Failed to find best matching hospital'); |
|
|
logger.error('[REPORT] Hospital tool (MCP) execution failed'); |
|
|
} |
|
|
} else { |
|
|
logger.info('[REPORT] Hospital tool (MCP) requested by user but no location provided - will request location in response'); |
|
|
|
|
|
(finalResult as any).needs_location_for_hospital = true; |
|
|
} |
|
|
} |
|
|
|
|
|
return finalResult; |
|
|
} catch (error) { |
|
|
logger.error({ error }, 'Error in text-only triage'); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private determineCVType(userText: string): 'derm' | 'eye' | 'wound' { |
|
|
const lowerText = userText.toLowerCase(); |
|
|
|
|
|
|
|
|
if (lowerText.includes('mắt') || lowerText.includes('eye') || |
|
|
lowerText.includes('nhìn') || lowerText.includes('đỏ mắt')) { |
|
|
return 'eye'; |
|
|
} |
|
|
|
|
|
|
|
|
if (lowerText.includes('vết thương') || lowerText.includes('wound') || |
|
|
lowerText.includes('bỏng') || lowerText.includes('burn') || |
|
|
lowerText.includes('chảy máu') || lowerText.includes('cắt')) { |
|
|
return 'wound'; |
|
|
} |
|
|
|
|
|
|
|
|
return 'derm'; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async callCVModel(imageUrl: string, type: 'derm' | 'eye' | 'wound') { |
|
|
switch (type) { |
|
|
case 'derm': |
|
|
return await this.cvService.callDermCV(imageUrl); |
|
|
case 'eye': |
|
|
return await this.cvService.callEyeCV(imageUrl); |
|
|
case 'wound': |
|
|
return await this.cvService.callWoundCV(imageUrl); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async synthesizeFinalResponse( |
|
|
userText: string, |
|
|
cvResult: any, |
|
|
triageResult: any, |
|
|
guidelines: any[], |
|
|
conversationContext?: string |
|
|
): Promise<TriageResult> { |
|
|
|
|
|
const cvModelUsed = cvResult.top_conditions.length > 0 |
|
|
? (cvResult.top_conditions[0] as any).model_used || 'derm_cv' |
|
|
: 'none'; |
|
|
|
|
|
|
|
|
const formattedGuidelines = guidelines.map((g, i) => { |
|
|
const content = typeof g === 'string' ? g : (g.content || g.snippet || JSON.stringify(g)); |
|
|
return `\n--- Guideline ${i + 1} ---\n${content}`; |
|
|
}).join('\n\n'); |
|
|
|
|
|
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. |
|
|
|
|
|
Mô tả triệu chứng: ${userText} |
|
|
|
|
|
${conversationContext ? `Ngữ cảnh cuộc trò chuyện trước: ${conversationContext}` : ''} |
|
|
|
|
|
${cvResult.top_conditions.length > 0 ? ` |
|
|
Kết quả phân tích hình ảnh (chỉ các kết quả có độ tin cậy cao): |
|
|
${cvResult.top_conditions.map((c: any, i: number) => `${i + 1}. ${c.name}: ${(c.prob * 100).toFixed(1)}%`).join('\n')} |
|
|
` : ` |
|
|
Lưu ý: Phân tích hình ảnh không cho kết quả đủ tin cậy, sẽ dựa chủ yếu vào mô tả triệu chứng của người dùng. |
|
|
`} |
|
|
|
|
|
Mức độ khẩn cấp: ${triageResult.triage} |
|
|
Dấu hiệu cảnh báo: ${triageResult.red_flags?.join(', ') || 'Không có'} |
|
|
Lý do đánh giá: ${triageResult.reasoning} |
|
|
|
|
|
═══════════════════════════════════════════════════════════════════════════════ |
|
|
HƯỚNG DẪN Y TẾ TỪ BỘ Y TẾ (BẮT BUỘC PHẢI SỬ DỤNG): |
|
|
═══════════════════════════════════════════════════════════════════════════════ |
|
|
${formattedGuidelines} |
|
|
═══════════════════════════════════════════════════════════════════════════════ |
|
|
|
|
|
⚠️ 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: |
|
|
- PHẢI dựa trên thông tin CỤ THỂ từ guidelines để giải thích, biện luận, so sánh |
|
|
- KHÔNG được tự ý tạo thông tin ngoài guidelines được cung cấp |
|
|
- 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) |
|
|
- 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ĩ)" |
|
|
- 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 |
|
|
|
|
|
YÊU CẦU VỀ PHONG CÁCH VIẾT: |
|
|
1. VIẾT HOÀN TOÀN BẰNG TIẾNG VIỆT - không được dùng tiếng Anh trong response |
|
|
2. Viết NGẮN GỌN, CÔ ĐỌNG - tối đa 300-400 từ, tập trung vào thông tin quan trọng nhất |
|
|
3. Viết TỰ NHIÊN, DỄ HIỂU như đang trò chuyện với bệnh nhân |
|
|
4. CÓ THỂ biện luận, giải thích "tại sao" nhưng NGẮN GỌN, không lan man |
|
|
5. Sử dụng markdown để format (tiêu đề, danh sách) cho dễ đọc |
|
|
6. 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 |
|
|
7. 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" |
|
|
${cvResult.top_conditions.length === 0 ? '8. 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.' : ''} |
|
|
|
|
|
QUAN TRỌNG VỀ ĐỘ DÀI: |
|
|
- Tối đa 300-400 từ (khoảng 1-2 đoạn văn ngắn) |
|
|
- Tập trung vào: tình trạng có thể là gì, hướng dẫn chăm sóc ngắn gọn, khi nào cần đi khám |
|
|
- KHÔNG lặp lại thông tin, KHÔNG giải thích quá chi tiết |
|
|
- Ưu tiên thông tin thực tế, hành động cụ thể |
|
|
|
|
|
Hãy tạo một phản hồi markdown NGẮN GỌN, cô đọng, bao gồm: |
|
|
- Tóm tắt ngắn về tình trạng có thể là gì (1-2 câu) |
|
|
- Hướng dẫn chăm sóc tại nhà ngắn gọn từ guidelines (3-4 điểm chính) |
|
|
- Khi nào cần đi khám ngay (1-2 câu) |
|
|
- Disclaimer ngắn gọn |
|
|
|
|
|
Ví dụ format markdown NGẮN GỌN: |
|
|
## 📋 Tình trạng |
|
|
|
|
|
Dựa trên triệu chứng và hình ảnh, có khả năng bạn đang gặp [tên bệnh]. [1-2 câu giải thích ngắn gọn]. |
|
|
|
|
|
## 💡 Chăm sóc tại nhà |
|
|
|
|
|
- [Điểm 1 từ guidelines - ngắn gọn] |
|
|
- [Điểm 2 từ guidelines - ngắn gọn] |
|
|
- [Điểm 3 từ guidelines - ngắn gọn] |
|
|
|
|
|
## ⚠️ Khi nào cần đi khám |
|
|
|
|
|
[1-2 câu về dấu hiệu cảnh báo] |
|
|
|
|
|
**Lưu ý:** 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.`; |
|
|
|
|
|
|
|
|
logger.info('='.repeat(80)); |
|
|
logger.info('[AGENT] PROMPT SENT TO LLM:'); |
|
|
logger.info(prompt); |
|
|
logger.info('='.repeat(80)); |
|
|
logger.info('[AGENT] INPUT DATA SUMMARY:'); |
|
|
logger.info(`- User text: "${userText}"`); |
|
|
logger.info(`- CV results count: ${cvResult.top_conditions.length}`); |
|
|
if (cvResult.top_conditions.length > 0) { |
|
|
cvResult.top_conditions.forEach((c: any, i: number) => { |
|
|
logger.info(` ${i + 1}. ${c.name}: ${(c.prob * 100).toFixed(1)}%`); |
|
|
}); |
|
|
} |
|
|
logger.info(`- Triage level: ${triageResult.triage}`); |
|
|
logger.info(`- Triage reasoning: ${triageResult.reasoning || 'N/A'}`); |
|
|
logger.info(`- Red flags: ${triageResult.red_flags?.join(', ') || 'None'}`); |
|
|
logger.info(`- Guidelines count: ${guidelines.length}`); |
|
|
if (guidelines.length > 0) { |
|
|
guidelines.forEach((g, i) => { |
|
|
const content = typeof g === 'string' ? g : (g.content || g.snippet || JSON.stringify(g)); |
|
|
logger.info(` ${i + 1}. Preview: ${content.substring(0, 200)}...`); |
|
|
}); |
|
|
} |
|
|
logger.info(`- Conversation context: ${conversationContext ? 'Yes' : 'No'}`); |
|
|
logger.info('='.repeat(80)); |
|
|
|
|
|
const generations = await this.llm._generate([prompt]); |
|
|
const response = generations.generations[0][0].text; |
|
|
|
|
|
|
|
|
const markdownContent = response.trim(); |
|
|
|
|
|
|
|
|
const triageLevel = triageResult.triage as TriageLevel; |
|
|
const suspectedCondition = cvResult.top_conditions.length > 0 ? cvResult.top_conditions[0].name : undefined; |
|
|
|
|
|
|
|
|
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); |
|
|
const homeCareMatch = markdownContent.match(/##\s*[💡]*\s*Hướng dẫn chăm sóc[\s\S]*?\n([\s\S]*?)(?=\n##|$)/i); |
|
|
const warningMatch = markdownContent.match(/##\s*[⚠️]*\s*Khi nào cần đi khám[\s\S]*?\n([\s\S]*?)(?=\n##|$)/i); |
|
|
|
|
|
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.'; |
|
|
const homeCareAdvice = homeCareMatch ? homeCareMatch[1].trim().substring(0, 500) : 'Giữ vệ sinh sạch sẽ và theo dõi triệu chứng.'; |
|
|
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.'; |
|
|
|
|
|
const parsed: TriageResult = { |
|
|
triage_level: triageLevel, |
|
|
symptom_summary: userText, |
|
|
red_flags: triageResult.red_flags || [], |
|
|
suspected_conditions: suspectedCondition ? [{ |
|
|
name: suspectedCondition, |
|
|
source: 'cv_model' as ConditionSource, |
|
|
confidence: cvResult.top_conditions.length > 0 && cvResult.top_conditions[0].prob > 0.5 ? 'medium' : 'low' as ConditionConfidence |
|
|
}] : [], |
|
|
cv_findings: { |
|
|
model_used: cvModelUsed as any, |
|
|
raw_output: cvResult.top_conditions.length > 0 ? { |
|
|
top_predictions: cvResult.top_conditions.slice(0, 1).map((c: any) => ({ condition: c.name, probability: c.prob })) |
|
|
} : {} |
|
|
}, |
|
|
recommendation: { |
|
|
action: action, |
|
|
timeframe: triageLevel === 'emergency' ? 'Ngay lập tức' : triageLevel === 'urgent' ? 'Trong 24 giờ' : 'Khi có thể sắp xếp', |
|
|
home_care_advice: homeCareAdvice, |
|
|
warning_signs: warningSigns |
|
|
}, |
|
|
|
|
|
message: markdownContent |
|
|
} as any; |
|
|
|
|
|
|
|
|
logger.info('='.repeat(80)); |
|
|
logger.info('[AGENT] FINAL RESPONSE (Markdown):'); |
|
|
logger.info(markdownContent); |
|
|
logger.info('[AGENT] FINAL RESPONSE (Structured):'); |
|
|
logger.info(JSON.stringify(parsed, null, 2)); |
|
|
logger.info('='.repeat(80)); |
|
|
|
|
|
return parsed; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async handleCasualConversation( |
|
|
userText: string, |
|
|
conversationContext?: string |
|
|
): Promise<TriageResult> { |
|
|
try { |
|
|
logger.info('[LIGHTWEIGHT] Handling casual conversation...'); |
|
|
|
|
|
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}" |
|
|
|
|
|
${conversationContext ? `Ngữ cảnh cuộc trò chuyện trước: ${conversationContext}` : ''} |
|
|
|
|
|
Hãy trả lời tự nhiên, ngắn gọn, thân thiện bằng tiếng Việt: |
|
|
- 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 |
|
|
- Nếu là câu cảm ơn, hãy trả lời lịch sự |
|
|
- Nếu là câu hỏi đơn giản, hãy trả lời ngắn gọn |
|
|
- Luôn sẵn sàng hỗ trợ về vấn đề sức khỏe |
|
|
|
|
|
Viết bằng markdown, tự nhiên, không cần format cứng nhắc.`; |
|
|
|
|
|
const generations = await this.llm._generate([prompt]); |
|
|
const markdown = generations.generations[0][0].text.trim(); |
|
|
|
|
|
return this.buildLightweightResponse(markdown, 'routine', userText); |
|
|
} catch (error) { |
|
|
logger.error({ error }, 'Error handling casual conversation'); |
|
|
return this.buildLightweightResponse( |
|
|
'Xin chào! Tôi có thể giúp gì cho bạn về vấn đề sức khỏe?', |
|
|
'routine', |
|
|
userText |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async handleOutOfScope( |
|
|
userText: string, |
|
|
intent: Intent |
|
|
): Promise<TriageResult> { |
|
|
try { |
|
|
logger.info('[LIGHTWEIGHT] Handling out of scope query...'); |
|
|
|
|
|
const prompt = `Bạn là trợ lý y tế của Việt Nam. Người dùng hỏi: "${userText}" |
|
|
|
|
|
Câu hỏi này nằm ngoài phạm vi của hệ thống (${JSON.stringify(intent.entities)}). |
|
|
|
|
|
Hãy từ chối lịch sự và hướng dẫn họ đến kênh phù hợp: |
|
|
- 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 |
|
|
- 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ế |
|
|
- Luôn lịch sự, thân thiện |
|
|
|
|
|
Viết bằng tiếng Việt, markdown format, ngắn gọn.`; |
|
|
|
|
|
const generations = await this.llm._generate([prompt]); |
|
|
const markdown = generations.generations[0][0].text.trim(); |
|
|
|
|
|
return this.buildLightweightResponse(markdown, 'routine', userText); |
|
|
} catch (error) { |
|
|
logger.error({ error }, 'Error handling out of scope'); |
|
|
return this.buildLightweightResponse( |
|
|
'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ợ.', |
|
|
'routine', |
|
|
userText |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private buildLightweightResponse( |
|
|
markdown: string, |
|
|
triageLevel: TriageLevel, |
|
|
userText?: string |
|
|
): TriageResult { |
|
|
|
|
|
const actionLine = markdown.split('\n').find(line => |
|
|
line.trim().length > 10 && !line.trim().startsWith('#') |
|
|
) || markdown.split('\n')[0] || 'Cảm ơn bạn đã liên hệ.'; |
|
|
|
|
|
return { |
|
|
triage_level: triageLevel, |
|
|
symptom_summary: userText || '', |
|
|
red_flags: [], |
|
|
suspected_conditions: [], |
|
|
cv_findings: { |
|
|
model_used: 'none', |
|
|
raw_output: {} |
|
|
}, |
|
|
recommendation: { |
|
|
action: actionLine.trim(), |
|
|
timeframe: 'Không áp dụng', |
|
|
home_care_advice: '', |
|
|
warning_signs: '' |
|
|
}, |
|
|
message: markdown |
|
|
} as any; |
|
|
} |
|
|
|
|
|
private getSafeDefaultResponse(userText: string): TriageResult { |
|
|
return { |
|
|
triage_level: 'urgent', |
|
|
symptom_summary: `Triệu chứng: ${userText}`, |
|
|
red_flags: ['Không thể phân tích tự động, cần đánh giá trực tiếp'], |
|
|
suspected_conditions: [], |
|
|
cv_findings: { |
|
|
model_used: 'none', |
|
|
raw_output: {} |
|
|
}, |
|
|
recommendation: { |
|
|
action: 'Vui lòng đến cơ sở y tế để được bác sĩ khám và đánh giá trực tiếp', |
|
|
timeframe: 'Trong vòng 24 giờ', |
|
|
home_care_advice: 'Theo dõi triệu chứng và đến ngay nếu tình trạng xấu đi', |
|
|
warning_signs: 'Nếu triệu chứng nặng hơn, đến cấp cứu ngay lập tức' |
|
|
} |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private shouldSuggestHospital(userText: string): boolean { |
|
|
const lowerText = userText.toLowerCase(); |
|
|
const hospitalKeywords = [ |
|
|
'bệnh viện', |
|
|
'bệnh viện gần', |
|
|
'bệnh viện nào', |
|
|
'đi bệnh viện', |
|
|
'đến bệnh viện', |
|
|
'khám ở đâu', |
|
|
'nên đi khám ở đâu', |
|
|
'nên khám ở đâu', |
|
|
'đi khám ở đâu', |
|
|
'đi khám', |
|
|
'cần đi khám', |
|
|
'gợi ý bệnh viện', |
|
|
'tìm bệnh viện', |
|
|
'tìm nơi khám', |
|
|
'nơi khám', |
|
|
'địa chỉ khám', |
|
|
'chỗ khám' |
|
|
]; |
|
|
|
|
|
return hospitalKeywords.some(keyword => lowerText.includes(keyword)); |
|
|
} |
|
|
} |
|
|
|
|
|
|