File size: 44,615 Bytes
d4abe4b c869f17 0c00ee2 d4abe4b c869f17 d4abe4b c869f17 0c00ee2 d4abe4b c869f17 d4abe4b c869f17 0c00ee2 d4abe4b 3e12ae4 d4abe4b c869f17 d4abe4b 1a8794d d4abe4b 0c00ee2 c869f17 0c00ee2 c869f17 0c00ee2 d4abe4b 3e12ae4 d4abe4b 1a8794d d4abe4b 1a8794d d4abe4b 1a8794d d4abe4b 1a8794d d4abe4b 1a8794d d4abe4b 1a8794d d4abe4b 1a8794d d4abe4b 0c00ee2 d4abe4b 0c00ee2 d4abe4b 5c04a26 d4abe4b 5c04a26 d4abe4b 0c00ee2 d4abe4b 0c00ee2 5c04a26 1935826 0c00ee2 1935826 0c00ee2 1935826 0c00ee2 1935826 0c00ee2 1935826 0c00ee2 1935826 d4abe4b faf5d21 d4abe4b 0c00ee2 69263c1 0c00ee2 69263c1 0c00ee2 d4abe4b 3e12ae4 d4abe4b c869f17 d4abe4b 9584356 d4abe4b 9584356 d4abe4b 9584356 d4abe4b 9584356 d4abe4b 9584356 d4abe4b 9584356 d4abe4b 8a43827 d4abe4b 9584356 d4abe4b 9584356 d4abe4b 8a43827 d4abe4b c869f17 8a43827 c869f17 8a43827 dc5847f c869f17 dc5847f c869f17 8a43827 c869f17 8a43827 c869f17 d2d559e c869f17 d2d559e c869f17 d2d559e 8a43827 c869f17 d4abe4b 3e12ae4 d4abe4b 1a8794d d4abe4b c869f17 d4abe4b 1a8794d d4abe4b 1a8794d d4abe4b 1a8794d d4abe4b c869f17 d4abe4b 8a43827 c869f17 dc5847f c869f17 dc5847f c869f17 dc5847f c869f17 d2d559e c869f17 d2d559e dc5847f d2d559e dc5847f d2d559e c869f17 d2d559e c869f17 d4abe4b 3e12ae4 d4abe4b 9584356 0c00ee2 d4abe4b 5c04a26 d4abe4b 5c04a26 d4abe4b 5c04a26 d4abe4b 9584356 d4abe4b 5c04a26 d4abe4b 0c00ee2 d4abe4b 0c00ee2 5c04a26 1935826 0c00ee2 1935826 0c00ee2 1935826 0c00ee2 1935826 0c00ee2 1935826 0c00ee2 1935826 0c00ee2 1935826 0c00ee2 1935826 0c00ee2 1935826 0c00ee2 1935826 d4abe4b faf5d21 d4abe4b 0c00ee2 69263c1 0c00ee2 69263c1 0c00ee2 d4abe4b 0c00ee2 d4abe4b 0c00ee2 d4abe4b c869f17 d2d559e c869f17 d2d559e c869f17 d4abe4b |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 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 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 |
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...');
// Initialize RAG service
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}`);
// Step 1: Classify intent FIRST (routing decision)
const intent = this.intentClassifier.classifyIntent(userText, !!imageUrl);
logger.info(`[ROUTING] Intent classified: ${intent.type} (confidence: ${intent.confidence})`);
// Step 2: Route based on intent
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:
// Fallback: if unclear, use lightweight response
logger.info('[ROUTING] → Lightweight: Default fallback');
return await this.handleCasualConversation(userText, conversationContext);
}
} catch (error) {
logger.error({ error }, 'Error processing query');
// Return safe default
return this.getSafeDefaultResponse(userText);
}
}
/**
* Process educational query about disease
* Agent tự quyết định khi nào cần gọi knowledge base vs RAG
*/
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}"`);
// Agent tự quyết định: thử knowledge base trước, nếu không có thì dùng RAG
let guidelines: any[] = [];
// Step 1: Thử tìm disease name từ user text và query knowledge base
logger.info('[AGENT] Step 1: Attempting structured knowledge search...');
try {
// Extract potential disease name from query (simple heuristic)
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');
}
// Step 2: Fallback to RAG if no structured results
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}`);
// Format guidelines for better readability
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');
// Use LLM to synthesize educational response
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ĩ.`;
// Log prompt and input data before sending to LLM
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;
// Extract markdown content (full response is markdown)
const markdownContent = response.trim();
// Build TriageResult from markdown response
const triageLevel = 'routine' as TriageLevel;
// Extract key information from markdown for backward compatibility
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.'
},
// Add markdown response as additional field
message: markdownContent
} as any;
// Log final response
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);
}
}
/**
* Custom agent workflow when image is provided
* This ensures CV tools are actually called, not hallucinated by LLM
*/
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...');
// Step 1: Call CV model directly based on user text
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'}`);
// Filter CV results by confidence threshold (only use if confidence >= 0.5)
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}%`);
}
// Step 2: Call triage rules with CV results (only if valid)
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}`);
// Step 3: Get guidelines from RAG
logger.info('[AGENT] Step 3: Retrieving medical guidelines from RAG...');
// Chỉ dùng CV conditions nếu có valid results với confidence đủ cao
// Chỉ lấy 1 kết quả CV có confidence cao nhất
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`);
// Store guidelines count for report generation
(guidelineInput as any).guidelines_count = guidelines.length;
// Step 4: Use LLM to synthesize final response
logger.info('Step 4: Synthesizing final response with LLM...');
// Chỉ truyền valid CV results
const filteredCVResult = {
top_conditions: validCVResults.length > 0 ? validCVResults : []
};
const finalResult = await this.synthesizeFinalResponse(
userText,
filteredCVResult,
triageResult,
guidelines,
conversationContext
);
// Attach guidelines count to result for report generation
(finalResult as any).guidelines_count = guidelines.length;
// Step 5: Find best matching hospital if emergency/urgent and location provided
// This tool is called LAST in the agent workflow
// Extract condition from suspected_conditions or CV results
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}`);
// Append hospital info to message markdown
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');
// Continue without hospital info
}
} else if (this.shouldSuggestHospital(userText)) {
// Also suggest hospital if user explicitly requests it
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');
// Add a note to the response that location is needed
(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;
}
}
/**
* Process text-only triage
* Agent tự quyết định: nếu là câu hỏi giáo dục về bệnh thì dùng knowledge base/RAG
* Nếu là triệu chứng cá nhân thì dùng triage rules + RAG
*/
private async processTriageTextOnly(
userText: string,
conversationContext?: string,
location?: Location
): Promise<TriageResult & { nearest_clinic?: NearestClinic }> {
try {
logger.info('Processing text-only query...');
// Phân tích user text để quyết định workflow
// Nếu có từ khóa "là gì", "như thế nào", "về" → câu hỏi giáo dục
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) {
// Câu hỏi giáo dục: thử knowledge base trước, sau đó RAG
logger.info('[AGENT] Detected educational query, using knowledge base/RAG workflow');
return await this.processDiseaseInfoQuery(userText, conversationContext);
}
// Triệu chứng cá nhân: dùng triage workflow
logger.info('[AGENT] Detected symptom query, using triage workflow');
// Step 1: Apply triage rules
const triageInput = {
symptoms: {
main_complaint: userText,
context: conversationContext
}
};
const triageResult = this.triageService.evaluateSymptoms(triageInput);
// Step 2: Get guidelines from RAG
const guidelineInput = {
symptoms: userText,
suspected_conditions: [],
triage_level: triageResult.triage
};
const guidelines = await this.ragService.searchGuidelines(guidelineInput);
// Step 3: Synthesize response
const finalResult = await this.synthesizeFinalResponse(
userText,
{ top_conditions: [] },
triageResult,
guidelines,
conversationContext
);
// Attach guidelines count to result for report generation
(finalResult as any).guidelines_count = guidelines.length;
// Step 4: Find best matching hospital if emergency/urgent and location provided
// This tool is called LAST in the agent workflow
// Extract condition from suspected_conditions
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}`);
// Append hospital info to message markdown
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');
// Continue without hospital info
}
} else if (this.shouldSuggestHospital(userText)) {
// Also suggest hospital if user explicitly requests it
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}`);
// Append hospital info to message markdown
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');
// Add a note to the response that location is needed
(finalResult as any).needs_location_for_hospital = true;
}
}
return finalResult;
} catch (error) {
logger.error({ error }, 'Error in text-only triage');
throw error;
}
}
/**
* Determine which CV model to use based on user text
*/
private determineCVType(userText: string): 'derm' | 'eye' | 'wound' {
const lowerText = userText.toLowerCase();
// Check for eye-related keywords
if (lowerText.includes('mắt') || lowerText.includes('eye') ||
lowerText.includes('nhìn') || lowerText.includes('đỏ mắt')) {
return 'eye';
}
// Check for wound-related keywords
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';
}
// Default to dermatology
return 'derm';
}
/**
* Call appropriate CV model
*/
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);
}
}
/**
* Use LLM to synthesize final structured response
*/
private async synthesizeFinalResponse(
userText: string,
cvResult: any,
triageResult: any,
guidelines: any[],
conversationContext?: string
): Promise<TriageResult> {
// Determine CV model used
const cvModelUsed = cvResult.top_conditions.length > 0
? (cvResult.top_conditions[0] as any).model_used || 'derm_cv'
: 'none';
// Format guidelines for better readability
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.`;
// Log prompt and input data before sending to LLM
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;
// Extract markdown content (full response is markdown)
const markdownContent = response.trim();
// Build TriageResult from markdown response
const triageLevel = triageResult.triage as TriageLevel;
const suspectedCondition = cvResult.top_conditions.length > 0 ? cvResult.top_conditions[0].name : undefined;
// Extract key information from markdown for backward compatibility
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
},
// Add markdown response as additional field (extend TriageResult)
message: markdownContent
} as any;
// Log final response
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;
}
/**
* Handle casual conversation/greeting - lightweight response
*/
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
);
}
}
/**
* Handle out of scope queries - lightweight response
*/
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
);
}
}
/**
* Build lightweight response structure
*/
private buildLightweightResponse(
markdown: string,
triageLevel: TriageLevel,
userText?: string
): TriageResult {
// Extract first meaningful line for action field
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'
}
};
}
/**
* Check if user text suggests they need hospital recommendation
*/
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));
}
}
|