| import { SupabaseService } from './supabase.service.js'; |
| import { logger } from '../utils/logger.js'; |
| import { GeminiEmbedding } from '../agent/gemini-embedding.js'; |
|
|
| export interface Specialty { |
| id: string; |
| name: string; |
| name_en?: string; |
| description?: string; |
| } |
|
|
| export interface Disease { |
| id: string; |
| specialty_id: string; |
| name: string; |
| synonyms?: string[]; |
| icd10_code?: string; |
| description?: string; |
| } |
|
|
| export interface InfoDomain { |
| id: string; |
| name: string; |
| name_en?: string; |
| order_index: number; |
| description?: string; |
| } |
|
|
| export interface StructuredKnowledgeQuery { |
| specialty?: string; |
| disease?: string; |
| infoDomain?: string; |
| query: string; |
| } |
|
|
| |
| |
| |
| |
| export class KnowledgeBaseService { |
| private embedding: GeminiEmbedding; |
|
|
| constructor(private supabaseService: SupabaseService) { |
| this.embedding = new GeminiEmbedding(); |
| } |
|
|
| |
| |
| |
| async getSpecialties(): Promise<Specialty[]> { |
| try { |
| const { data, error } = await this.supabaseService.getClient() |
| .from('specialties') |
| .select('*') |
| .order('name'); |
|
|
| if (error) throw error; |
| return data || []; |
| } catch (error) { |
| logger.error(`Error fetching specialties: ${error}`); |
| return []; |
| } |
| } |
|
|
| |
| |
| |
| async getDiseasesBySpecialty(specialtyName: string): Promise<Disease[]> { |
| try { |
| const { data, error } = await this.supabaseService.getClient() |
| .from('diseases') |
| .select('*, specialties!inner(name)') |
| .eq('specialties.name', specialtyName) |
| .order('name'); |
|
|
| if (error) throw error; |
| return data || []; |
| } catch (error) { |
| logger.error(`Error fetching diseases for specialty ${specialtyName}: ${error}`); |
| return []; |
| } |
| } |
|
|
| |
| |
| |
| async getInfoDomains(): Promise<InfoDomain[]> { |
| try { |
| const { data, error } = await this.supabaseService.getClient() |
| .from('info_domains') |
| .select('*') |
| .order('order_index'); |
|
|
| if (error) throw error; |
| return data || []; |
| } catch (error) { |
| logger.error(`Error fetching info domains: ${error}`); |
| return []; |
| } |
| } |
|
|
| |
| |
| |
| async findDisease(query: string): Promise<Disease | null> { |
| try { |
| logger.info('='.repeat(80)); |
| logger.info('[MCP CSDL] findDisease INPUT:'); |
| logger.info(` Query: "${query}"`); |
| |
| const normalizedQuery = query.toLowerCase().trim(); |
|
|
| |
| logger.info('[MCP CSDL] Trying exact match...'); |
| const { data: exactMatch } = await this.supabaseService.getClient() |
| .from('diseases') |
| .select('*') |
| .ilike('name', normalizedQuery) |
| .limit(1) |
| .single(); |
|
|
| if (exactMatch) { |
| logger.info('[MCP CSDL] findDisease OUTPUT (exact match):'); |
| logger.info(` Disease: ${exactMatch.name} (ID: ${exactMatch.id})`); |
| logger.info('='.repeat(80)); |
| return exactMatch; |
| } |
|
|
| |
| logger.info('[MCP CSDL] Trying fuzzy match on name...'); |
| const { data: fuzzyMatch } = await this.supabaseService.getClient() |
| .from('diseases') |
| .select('*') |
| .ilike('name', `%${normalizedQuery}%`) |
| .limit(1) |
| .single(); |
|
|
| if (fuzzyMatch) { |
| logger.info('[MCP CSDL] findDisease OUTPUT (fuzzy match):'); |
| logger.info(` Disease: ${fuzzyMatch.name} (ID: ${fuzzyMatch.id})`); |
| logger.info('='.repeat(80)); |
| return fuzzyMatch; |
| } |
|
|
| |
| logger.info('[MCP CSDL] Trying synonym match...'); |
| const { data: allDiseases } = await this.supabaseService.getClient() |
| .from('diseases') |
| .select('*'); |
|
|
| if (allDiseases) { |
| for (const disease of allDiseases) { |
| if (disease.synonyms) { |
| for (const synonym of disease.synonyms) { |
| if (synonym.toLowerCase().includes(normalizedQuery) || |
| normalizedQuery.includes(synonym.toLowerCase())) { |
| logger.info('[MCP CSDL] findDisease OUTPUT (synonym match):'); |
| logger.info(` Disease: ${disease.name} (ID: ${disease.id})`); |
| logger.info(` Matched synonym: "${synonym}"`); |
| logger.info('='.repeat(80)); |
| return disease; |
| } |
| } |
| } |
| } |
| } |
|
|
| logger.info('[MCP CSDL] findDisease OUTPUT: Not found'); |
| logger.info('='.repeat(80)); |
| return null; |
| } catch (error) { |
| logger.error('[MCP CSDL] ERROR in findDisease:'); |
| logger.error(JSON.stringify(error, null, 2)); |
| logger.info('='.repeat(80)); |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| async findInfoDomain(query: string): Promise<InfoDomain | null> { |
| try { |
| const normalizedQuery = query.toLowerCase().trim(); |
|
|
| |
| const { data: exactMatch } = await this.supabaseService.getClient() |
| .from('info_domains') |
| .select('*') |
| .ilike('name', normalizedQuery) |
| .limit(1) |
| .single(); |
|
|
| if (exactMatch) return exactMatch; |
|
|
| |
| const { data: fuzzyMatch } = await this.supabaseService.getClient() |
| .from('info_domains') |
| .select('*') |
| .ilike('name', `%${normalizedQuery}%`) |
| .limit(1) |
| .single(); |
|
|
| if (fuzzyMatch) return fuzzyMatch; |
|
|
| |
| const { data: enMatch } = await this.supabaseService.getClient() |
| .from('info_domains') |
| .select('*') |
| .ilike('name_en', `%${normalizedQuery}%`) |
| .limit(1) |
| .single(); |
|
|
| if (enMatch) return enMatch; |
|
|
| return null; |
| } catch (error) { |
| logger.error(`Error finding info domain: ${error}`); |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| async queryStructuredKnowledge( |
| params: StructuredKnowledgeQuery |
| ): Promise<any[]> { |
| try { |
| logger.info('='.repeat(80)); |
| logger.info('[MCP CSDL] queryStructuredKnowledge INPUT:'); |
| logger.info(JSON.stringify(params, null, 2)); |
|
|
| |
| if (params.query) { |
| logger.info('[MCP CSDL] Using vector search with query...'); |
| |
| const queryEmbedding = await this.embedding.embedQuery(params.query); |
|
|
| const rpcParams = { |
| query_embedding: queryEmbedding, |
| match_threshold: 0.3, |
| match_count: 10, |
| filter_specialty: params.specialty || null, |
| filter_disease: params.disease || null, |
| filter_specialty_id: null, |
| filter_disease_id: null, |
| filter_info_domain_id: null |
| }; |
| |
| logger.info('[MCP CSDL] Calling match_medical_knowledge RPC...'); |
| logger.info(`[MCP CSDL] RPC params: { match_threshold: 0.3, match_count: 10, filter_specialty: ${params.specialty || 'null'}, filter_disease: ${params.disease || 'null'} }`); |
|
|
| const { data, error } = await this.supabaseService.getClient().rpc( |
| 'match_medical_knowledge', |
| rpcParams |
| ); |
|
|
| if (error) { |
| logger.error('[MCP CSDL] ERROR calling match_medical_knowledge RPC:'); |
| logger.error(JSON.stringify(error, null, 2)); |
| logger.info('[MCP CSDL] Falling back to text search...'); |
| |
| return this.fallbackTextSearch(params); |
| } |
|
|
| |
| if (!data || data.length === 0) { |
| logger.info('[MCP CSDL] Vector search returned no results, falling back to text search'); |
| return this.fallbackTextSearch(params); |
| } |
|
|
| |
| let results = data || []; |
| if (params.infoDomain) { |
| logger.info(`[MCP CSDL] Filtering by infoDomain: "${params.infoDomain}"`); |
| const beforeFilter = results.length; |
| results = results.filter((item: any) => |
| item.section_title && item.section_title.toLowerCase().includes(params.infoDomain!.toLowerCase()) |
| ); |
| logger.info(`[MCP CSDL] Filtered from ${beforeFilter} to ${results.length} results`); |
| } |
|
|
| logger.info(`[MCP CSDL] queryStructuredKnowledge OUTPUT: Found ${results.length} structured knowledge chunks via vector search`); |
| results.forEach((item: any, index: number) => { |
| logger.info(`[MCP CSDL] Result ${index + 1}:`); |
| logger.info(` - Disease: ${item.disease || 'N/A'}`); |
| logger.info(` - Section: ${item.section_title || 'N/A'}`); |
| logger.info(` - Similarity: ${item.similarity?.toFixed(4) || 'N/A'}`); |
| logger.info(` - Content preview: ${item.content?.substring(0, 150) || 'N/A'}...`); |
| }); |
| logger.info('='.repeat(80)); |
| return results; |
| } |
| |
| |
| logger.info('[MCP CSDL] No query text provided, using fallback text search...'); |
| return this.fallbackTextSearch(params); |
|
|
| } catch (error) { |
| logger.error('[MCP CSDL] ERROR in queryStructuredKnowledge:'); |
| logger.error(JSON.stringify(error, null, 2)); |
| logger.info('='.repeat(80)); |
| return []; |
| } |
| } |
|
|
| private async fallbackTextSearch(params: StructuredKnowledgeQuery): Promise<any[]> { |
| logger.info('[MCP CSDL] fallbackTextSearch - Building query...'); |
| |
| let query = this.supabaseService.getClient() |
| .from('medical_knowledge_chunks') |
| .select('*'); |
|
|
| |
| if (params.specialty) { |
| logger.info(`[MCP CSDL] Filtering by specialty: "${params.specialty}"`); |
| query = query.ilike('specialty', params.specialty); |
| } |
|
|
| if (params.disease) { |
| logger.info(`[MCP CSDL] Filtering by disease: "${params.disease}"`); |
| query = query.ilike('disease', params.disease); |
| } |
|
|
| if (params.infoDomain) { |
| logger.info(`[MCP CSDL] Filtering by infoDomain: "${params.infoDomain}"`); |
| query = query.ilike('section_title', params.infoDomain); |
| } |
|
|
| |
| query = query.limit(10); |
|
|
| const { data, error } = await query; |
|
|
| if (error) { |
| logger.error('[MCP CSDL] ERROR in fallbackTextSearch:'); |
| logger.error(JSON.stringify(error, null, 2)); |
| throw error; |
| } |
|
|
| logger.info(`[MCP CSDL] fallbackTextSearch OUTPUT: Found ${data?.length || 0} structured knowledge chunks via text search`); |
| if (data && data.length > 0) { |
| data.forEach((item: any, index: number) => { |
| logger.info(`[MCP CSDL] Result ${index + 1}:`); |
| logger.info(` - Disease: ${item.disease || 'N/A'}`); |
| logger.info(` - Section: ${item.section_title || 'N/A'}`); |
| logger.info(` - Content preview: ${item.content?.substring(0, 150) || 'N/A'}...`); |
| }); |
| } |
| logger.info('='.repeat(80)); |
| return data || []; |
| } |
| } |
|
|
|
|