|
|
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 || []; |
|
|
} |
|
|
} |
|
|
|
|
|
|