From db4dd6d2efbf77109cf50f5fd34b79e3c0545e72 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Wed, 19 Mar 2025 19:28:02 +0000 Subject: [PATCH] refactor "context" services --- src/routes/api/llm.ts | 8 +- src/services/llm/README.md | 144 +++ src/services/llm/agent_tools/index.ts | 13 +- .../llm/agent_tools/vector_search_tool.ts | 112 +-- src/services/llm/ai_service_manager.ts | 49 +- src/services/llm/chat_service.ts | 9 +- src/services/llm/context/index.ts | 18 +- .../llm/context/modules/cache_manager.ts | 122 +++ .../llm/context/modules/context_formatter.ts | 164 ++++ .../llm/context/modules/context_service.ts | 368 ++++++++ .../llm/context/modules/provider_manager.ts | 99 ++ .../llm/context/modules/query_enhancer.ts | 168 ++++ .../llm/context/modules/semantic_search.ts | 306 ++++++ src/services/llm/context_service.ts | 190 ++++ src/services/llm/semantic_context_service.ts | 404 -------- src/services/llm/trilium_context_service.ts | 870 ------------------ 16 files changed, 1671 insertions(+), 1373 deletions(-) create mode 100644 src/services/llm/README.md create mode 100644 src/services/llm/context/modules/cache_manager.ts create mode 100644 src/services/llm/context/modules/context_formatter.ts create mode 100644 src/services/llm/context/modules/context_service.ts create mode 100644 src/services/llm/context/modules/provider_manager.ts create mode 100644 src/services/llm/context/modules/query_enhancer.ts create mode 100644 src/services/llm/context/modules/semantic_search.ts create mode 100644 src/services/llm/context_service.ts delete mode 100644 src/services/llm/semantic_context_service.ts delete mode 100644 src/services/llm/trilium_context_service.ts diff --git a/src/routes/api/llm.ts b/src/routes/api/llm.ts index 56565cac5..f7c6ba326 100644 --- a/src/routes/api/llm.ts +++ b/src/routes/api/llm.ts @@ -9,7 +9,7 @@ import providerManager from "../../services/llm/embeddings/providers.js"; import type { Message, ChatCompletionOptions } from "../../services/llm/ai_interface.js"; // Import this way to prevent immediate instantiation import * as aiServiceManagerModule from "../../services/llm/ai_service_manager.js"; -import triliumContextService from "../../services/llm/trilium_context_service.js"; +import contextService from "../../services/llm/context_service.js"; import sql from "../../services/sql.js"; // Import the index service for knowledge base management import indexService from "../../services/llm/index_service.js"; @@ -653,14 +653,14 @@ async function sendMessage(req: Request, res: Response) { // Use the Trilium-specific approach const contextNoteId = session.noteContext || null; - // Log that we're calling triliumContextService with the parameters + // Log that we're calling contextService with the parameters log.info(`Using enhanced context with: noteId=${contextNoteId}, showThinking=${showThinking}`); - const results = await triliumContextService.processQuery( + const results = await contextService.processQuery( messageContent, service, contextNoteId, - showThinking // Pass the showThinking parameter + showThinking ); // Get the generated context diff --git a/src/services/llm/README.md b/src/services/llm/README.md new file mode 100644 index 000000000..dce7887ee --- /dev/null +++ b/src/services/llm/README.md @@ -0,0 +1,144 @@ +# Trilium Context Service + +This directory contains Trilium's context management services, which are responsible for providing relevant context to LLM models when generating responses. + +## Structure + +The context system has been refactored into a modular architecture: + +``` +context/ + ├── index.ts - Base context extractor + ├── semantic_context.ts - Semantic context utilities + ├── hierarchy.ts - Note hierarchy context utilities + ├── code_handlers.ts - Code-specific context handling + ├── content_chunking.ts - Content chunking utilities + ├── note_content.ts - Note content processing + ├── summarization.ts - Content summarization utilities + ├── modules/ - Modular context services + │ ├── provider_manager.ts - Embedding provider management + │ ├── cache_manager.ts - Caching system + │ ├── semantic_search.ts - Semantic search functionality + │ ├── query_enhancer.ts - Query enhancement + │ ├── context_formatter.ts - Context formatting + │ └── context_service.ts - Main context service + └── README.md - This documentation +``` + +## Main Entry Points + +- `context_service.ts` - Main entry point for modern code +- `semantic_context_service.ts` - Compatibility wrapper for old code (deprecated) +- `trilium_context_service.ts` - Compatibility wrapper for old code (deprecated) + +## Usage + +### For new code: + +```typescript +import aiServiceManager from '../services/llm/ai_service_manager.js'; + +// Get the context service +const contextService = aiServiceManager.getContextService(); + +// Process a query to get relevant context +const result = await contextService.processQuery( + "What are my notes about programming?", + llmService, + currentNoteId, + false // showThinking +); + +// Get semantic context +const context = await contextService.getSemanticContext(noteId, userQuery); + +// Get context that adapts to query complexity +const smartContext = await contextService.getSmartContext(noteId, userQuery); +``` + +### For legacy code (deprecated): + +```typescript +import aiServiceManager from '../services/llm/ai_service_manager.js'; + +// Get the semantic context service (deprecated) +const semanticContext = aiServiceManager.getSemanticContextService(); + +// Get context +const context = await semanticContext.getSemanticContext(noteId, userQuery); +``` + +## Modules + +### Provider Manager + +Handles embedding provider selection and management: + +```typescript +import providerManager from './context/modules/provider_manager.js'; + +// Get the preferred embedding provider +const provider = await providerManager.getPreferredEmbeddingProvider(); + +// Generate embeddings for a query +const embedding = await providerManager.generateQueryEmbedding(query); +``` + +### Cache Manager + +Provides caching for context data: + +```typescript +import cacheManager from './context/modules/cache_manager.js'; + +// Get cached data +const cached = cacheManager.getNoteData(noteId, 'content'); + +// Store data in cache +cacheManager.storeNoteData(noteId, 'content', data); + +// Clear caches +cacheManager.clearAllCaches(); +``` + +### Semantic Search + +Handles semantic search functionality: + +```typescript +import semanticSearch from './context/modules/semantic_search.js'; + +// Find relevant notes +const notes = await semanticSearch.findRelevantNotes(query, contextNoteId); + +// Rank notes by relevance +const ranked = await semanticSearch.rankNotesByRelevance(notes, query); +``` + +### Query Enhancer + +Provides query enhancement: + +```typescript +import queryEnhancer from './context/modules/query_enhancer.js'; + +// Generate multiple search queries from a user question +const queries = await queryEnhancer.generateSearchQueries(question, llmService); + +// Estimate query complexity +const complexity = queryEnhancer.estimateQueryComplexity(query); +``` + +### Context Formatter + +Formats context for LLM consumption: + +```typescript +import contextFormatter from './context/modules/context_formatter.js'; + +// Build formatted context from notes +const context = await contextFormatter.buildContextFromNotes(notes, query, providerId); + +// Sanitize note content +const clean = contextFormatter.sanitizeNoteContent(content, type, mime); +``` \ No newline at end of file diff --git a/src/services/llm/agent_tools/index.ts b/src/services/llm/agent_tools/index.ts index 8eb79f7b4..afac2a386 100644 --- a/src/services/llm/agent_tools/index.ts +++ b/src/services/llm/agent_tools/index.ts @@ -11,7 +11,7 @@ import { QueryDecompositionTool } from './query_decomposition_tool.js'; import { ContextualThinkingTool } from './contextual_thinking_tool.js'; // Import services needed for initialization -import SemanticContextService from '../semantic_context_service.js'; +import contextService from '../context_service.js'; import aiServiceManager from '../ai_service_manager.js'; import log from '../../log.js'; @@ -43,15 +43,14 @@ export class AgentToolsManager { this.queryDecompositionTool = new QueryDecompositionTool(); this.contextualThinkingTool = new ContextualThinkingTool(); - // Get semantic context service and set it in the vector search tool - const semanticContext = aiServiceManager.getSemanticContextService(); - this.vectorSearchTool.setSemanticContext(semanticContext); + // Set context service in the vector search tool + this.vectorSearchTool.setContextService(contextService); this.initialized = true; log.info("LLM agent tools initialized successfully"); - } catch (error: any) { - log.error(`Failed to initialize LLM agent tools: ${error.message}`); - throw new Error(`Agent tools initialization failed: ${error.message}`); + } catch (error) { + log.error(`Failed to initialize agent tools: ${error}`); + throw error; } } diff --git a/src/services/llm/agent_tools/vector_search_tool.ts b/src/services/llm/agent_tools/vector_search_tool.ts index 09c0dde4c..63900fd10 100644 --- a/src/services/llm/agent_tools/vector_search_tool.ts +++ b/src/services/llm/agent_tools/vector_search_tool.ts @@ -14,10 +14,10 @@ import log from '../../log.js'; -// Define interface for semantic context service to avoid circular imports -interface ISemanticContextService { - semanticSearch(query: string, options: any): Promise; - semanticSearchChunks(query: string, options: any): Promise; +// Define interface for context service to avoid circular imports +interface IContextService { + findRelevantNotesMultiQuery(queries: string[], contextNoteId: string | null, limit: number): Promise; + processQuery(userQuestion: string, llmService: any, contextNoteId: string | null, showThinking: boolean): Promise; } export interface VectorSearchResult { @@ -49,22 +49,23 @@ export interface ChunkSearchResultItem { } export class VectorSearchTool { - private semanticContext: ISemanticContextService | null = null; + private contextService: IContextService | null = null; private maxResults: number = 5; constructor() { - // The semantic context will be set later via setSemanticContext + // Initialization is done by setting context service } /** - * Set the semantic context service instance + * Set the context service for performing vector searches */ - setSemanticContext(semanticContext: ISemanticContextService): void { - this.semanticContext = semanticContext; + setContextService(contextService: IContextService): void { + this.contextService = contextService; + log.info('Context service set in VectorSearchTool'); } /** - * Search for notes semantically related to a query + * Search for notes that are semantically related to the query */ async searchNotes(query: string, options: { parentNoteId?: string, @@ -72,47 +73,44 @@ export class VectorSearchTool { similarityThreshold?: number } = {}): Promise { try { - if (!this.semanticContext) { - throw new Error("Semantic context service not set. Call setSemanticContext() first."); - } - - if (!query || query.trim().length === 0) { + // Validate contextService is set + if (!this.contextService) { + log.error('Context service not set in VectorSearchTool'); return []; } + // Set defaults const maxResults = options.maxResults || this.maxResults; - const similarityThreshold = options.similarityThreshold || 0.65; // Default threshold - const parentNoteId = options.parentNoteId; // Optional filtering by parent + const parentNoteId = options.parentNoteId || null; - // Search notes using the semantic context service - const results = await this.semanticContext.semanticSearch(query, { - maxResults, - similarityThreshold, - ancestorNoteId: parentNoteId - }); + // Use multi-query approach for more robust results + const queries = [query]; + const results = await this.contextService.findRelevantNotesMultiQuery( + queries, + parentNoteId, + maxResults + ); - if (!results || results.length === 0) { - return []; - } - - // Transform results to the tool's format - return results.map((result: SearchResultItem) => ({ + // Format results to match the expected interface + return results.map(result => ({ noteId: result.noteId, - title: result.noteTitle, - contentPreview: result.contentPreview, + title: result.title, + contentPreview: result.content ? + (result.content.length > 200 ? + result.content.substring(0, 200) + '...' : + result.content) + : 'No content available', similarity: result.similarity, - parentId: result.parentId, - dateCreated: result.dateCreated, - dateModified: result.dateModified + parentId: result.parentId })); - } catch (error: any) { - log.error(`Error in vector search: ${error.message}`); + } catch (error) { + log.error(`Error in vector search: ${error}`); return []; } } /** - * Search for content chunks within notes that are semantically related to a query + * Search for content chunks that are semantically related to the query */ async searchContentChunks(query: string, options: { noteId?: string, @@ -120,39 +118,15 @@ export class VectorSearchTool { similarityThreshold?: number } = {}): Promise { try { - if (!this.semanticContext) { - throw new Error("Semantic context service not set. Call setSemanticContext() first."); - } - - if (!query || query.trim().length === 0) { - return []; - } - - const maxResults = options.maxResults || this.maxResults; - const similarityThreshold = options.similarityThreshold || 0.70; // Higher threshold for chunks - const noteId = options.noteId; // Optional filtering by specific note - - // Search content chunks using the semantic context service - const results = await this.semanticContext.semanticSearchChunks(query, { - maxResults, - similarityThreshold, - noteId + // For now, use the same implementation as searchNotes, + // but in the future we'll implement chunk-based search + return this.searchNotes(query, { + parentNoteId: options.noteId, + maxResults: options.maxResults, + similarityThreshold: options.similarityThreshold }); - - if (!results || results.length === 0) { - return []; - } - - // Transform results to the tool's format - return results.map((result: ChunkSearchResultItem) => ({ - noteId: result.noteId, - title: result.noteTitle, - contentPreview: result.chunk, // Use the chunk content as preview - similarity: result.similarity, - parentId: result.parentId - })); - } catch (error: any) { - log.error(`Error in content chunk search: ${error.message}`); + } catch (error) { + log.error(`Error in vector chunk search: ${error}`); return []; } } diff --git a/src/services/llm/ai_service_manager.ts b/src/services/llm/ai_service_manager.ts index be4ce6b63..90422398b 100644 --- a/src/services/llm/ai_service_manager.ts +++ b/src/services/llm/ai_service_manager.ts @@ -5,7 +5,7 @@ import { AnthropicService } from './providers/anthropic_service.js'; import { OllamaService } from './providers/ollama_service.js'; import log from '../log.js'; import { ContextExtractor } from './context/index.js'; -import semanticContextService from './semantic_context_service.js'; +import contextService from './context_service.js'; import indexService from './index_service.js'; import { getEmbeddingProvider, getEnabledEmbeddingProviders } from './embeddings/providers.js'; import agentTools from './agent_tools/index.js'; @@ -268,11 +268,21 @@ export class AIServiceManager { } /** - * Get the semantic context service for advanced context handling + * Get the semantic context service for enhanced context management + * @deprecated Use getContextService() instead * @returns The semantic context service instance */ getSemanticContextService(): SemanticContextService { - return semanticContextService as unknown as SemanticContextService; + log.info('getSemanticContextService is deprecated, use getContextService instead'); + return contextService as unknown as SemanticContextService; + } + + /** + * Get the context service for advanced context management + * @returns The context service instance + */ + getContextService() { + return contextService; } /** @@ -404,6 +414,23 @@ export class AIServiceManager { throw error; } } + + /** + * Get context enhanced with agent tools + */ + async getAgentToolsContext( + noteId: string, + query: string, + showThinking: boolean = false, + relevantNotes: Array = [] + ): Promise { + return contextService.getAgentToolsContext( + noteId, + query, + showThinking, + relevantNotes + ); + } } // Don't create singleton immediately, use a lazy-loading pattern @@ -442,6 +469,9 @@ export default { getSemanticContextService(): SemanticContextService { return getInstance().getSemanticContextService(); }, + getContextService() { + return getInstance().getContextService(); + }, getIndexService() { return getInstance().getIndexService(); }, @@ -464,6 +494,19 @@ export default { }, getContextualThinkingTool() { return getInstance().getContextualThinkingTool(); + }, + async getAgentToolsContext( + noteId: string, + query: string, + showThinking: boolean = false, + relevantNotes: Array = [] + ): Promise { + return getInstance().getAgentToolsContext( + noteId, + query, + showThinking, + relevantNotes + ); } }; diff --git a/src/services/llm/chat_service.ts b/src/services/llm/chat_service.ts index 3115266e3..f928847ac 100644 --- a/src/services/llm/chat_service.ts +++ b/src/services/llm/chat_service.ts @@ -1,10 +1,6 @@ import type { Message, ChatCompletionOptions } from './ai_interface.js'; import aiServiceManager from './ai_service_manager.js'; import chatStorageService from './chat_storage_service.js'; -import { ContextExtractor } from './context/index.js'; - -// Create an instance of ContextExtractor for backward compatibility -const contextExtractor = new ContextExtractor(); export interface ChatSession { id: string; @@ -201,7 +197,8 @@ export class ChatService { const session = await this.getOrCreateSession(sessionId); // Use semantic context that considers the query for better relevance - const context = await contextExtractor.getSemanticContext(noteId, query); + const contextService = aiServiceManager.getContextService(); + const context = await contextService.getSemanticContext(noteId, query); const contextMessage: Message = { role: 'user', @@ -245,7 +242,7 @@ export class ChatService { await chatStorageService.updateChat(session.id, session.messages); // Get the Trilium Context Service for enhanced context - const contextService = aiServiceManager.getSemanticContextService(); + const contextService = aiServiceManager.getContextService(); // Get showThinking option if it exists const showThinking = options?.showThinking === true; diff --git a/src/services/llm/context/index.ts b/src/services/llm/context/index.ts index df4842a29..0074235d3 100644 --- a/src/services/llm/context/index.ts +++ b/src/services/llm/context/index.ts @@ -467,16 +467,15 @@ export class ContextExtractor { */ static async getProgressiveContext(noteId: string, depth = 1): Promise { try { - // This requires the semantic context service to be available - // We're using a dynamic import to avoid circular dependencies + // Use the new context service const { default: aiServiceManager } = await import('../ai_service_manager.js'); - const semanticContext = aiServiceManager.getInstance().getSemanticContextService(); + const contextService = aiServiceManager.getInstance().getContextService(); - if (!semanticContext) { + if (!contextService) { return ContextExtractor.extractContext(noteId); } - return await semanticContext.getProgressiveContext(noteId, depth); + return await contextService.getProgressiveContext(noteId, depth); } catch (error) { // Fall back to regular context if progressive loading fails console.error('Error in progressive context loading:', error); @@ -501,16 +500,15 @@ export class ContextExtractor { */ static async getSmartContext(noteId: string, query: string): Promise { try { - // This requires the semantic context service to be available - // We're using a dynamic import to avoid circular dependencies + // Use the new context service const { default: aiServiceManager } = await import('../ai_service_manager.js'); - const semanticContext = aiServiceManager.getInstance().getSemanticContextService(); + const contextService = aiServiceManager.getInstance().getContextService(); - if (!semanticContext) { + if (!contextService) { return ContextExtractor.extractContext(noteId); } - return await semanticContext.getSmartContext(noteId, query); + return await contextService.getSmartContext(noteId, query); } catch (error) { // Fall back to regular context if smart context fails console.error('Error in smart context selection:', error); diff --git a/src/services/llm/context/modules/cache_manager.ts b/src/services/llm/context/modules/cache_manager.ts new file mode 100644 index 000000000..411ed1cb2 --- /dev/null +++ b/src/services/llm/context/modules/cache_manager.ts @@ -0,0 +1,122 @@ +import log from '../../../log.js'; + +/** + * Manages caching for context services + * Provides a centralized caching system to avoid redundant operations + */ +export class CacheManager { + // Cache for recently used context to avoid repeated embedding lookups + private noteDataCache = new Map(); + + // Cache for recently used queries + private queryCache = new Map(); + + // Default cache expiry (5 minutes) + private defaultCacheExpiryMs = 5 * 60 * 1000; + + constructor() { + this.setupCacheCleanup(); + } + + /** + * Set up periodic cache cleanup + */ + private setupCacheCleanup() { + setInterval(() => { + this.cleanupCache(); + }, 60000); // Run cleanup every minute + } + + /** + * Clean up expired cache entries + */ + cleanupCache() { + const now = Date.now(); + + // Clean note data cache + for (const [key, data] of this.noteDataCache.entries()) { + if (now - data.timestamp > this.defaultCacheExpiryMs) { + this.noteDataCache.delete(key); + } + } + + // Clean query cache + for (const [key, data] of this.queryCache.entries()) { + if (now - data.timestamp > this.defaultCacheExpiryMs) { + this.queryCache.delete(key); + } + } + } + + /** + * Get cached note data + */ + getNoteData(noteId: string, type: string): any | null { + const key = `${noteId}:${type}`; + const cached = this.noteDataCache.get(key); + + if (cached && Date.now() - cached.timestamp < this.defaultCacheExpiryMs) { + log.info(`Cache hit for note data: ${key}`); + return cached.data; + } + + return null; + } + + /** + * Store note data in cache + */ + storeNoteData(noteId: string, type: string, data: any): void { + const key = `${noteId}:${type}`; + this.noteDataCache.set(key, { + timestamp: Date.now(), + data + }); + log.info(`Cached note data: ${key}`); + } + + /** + * Get cached query results + */ + getQueryResults(query: string, contextNoteId: string | null = null): any | null { + const key = JSON.stringify({ query, contextNoteId }); + const cached = this.queryCache.get(key); + + if (cached && Date.now() - cached.timestamp < this.defaultCacheExpiryMs) { + log.info(`Cache hit for query: ${query}`); + return cached.results; + } + + return null; + } + + /** + * Store query results in cache + */ + storeQueryResults(query: string, results: any, contextNoteId: string | null = null): void { + const key = JSON.stringify({ query, contextNoteId }); + this.queryCache.set(key, { + timestamp: Date.now(), + results + }); + log.info(`Cached query results: ${query}`); + } + + /** + * Clear all caches + */ + clearAllCaches(): void { + this.noteDataCache.clear(); + this.queryCache.clear(); + log.info('All context caches cleared'); + } +} + +// Export singleton instance +export default new CacheManager(); diff --git a/src/services/llm/context/modules/context_formatter.ts b/src/services/llm/context/modules/context_formatter.ts new file mode 100644 index 000000000..2d18f5ede --- /dev/null +++ b/src/services/llm/context/modules/context_formatter.ts @@ -0,0 +1,164 @@ +import sanitizeHtml from 'sanitize-html'; +import log from '../../../log.js'; + +// Constants for context window sizes, defines in-module to avoid circular dependencies +const CONTEXT_WINDOW = { + OPENAI: 16000, + ANTHROPIC: 100000, + OLLAMA: 8000, + DEFAULT: 4000 +}; + +/** + * Provides utilities for formatting context for LLM consumption + */ +export class ContextFormatter { + /** + * Build context string from retrieved notes + * + * @param sources - Array of notes or content sources + * @param query - The original user query + * @param providerId - The LLM provider to format for + * @returns Formatted context string + */ + async buildContextFromNotes(sources: any[], query: string, providerId: string = 'default'): Promise { + if (!sources || sources.length === 0) { + // Return a default context instead of empty string + return "I am an AI assistant helping you with your Trilium notes. " + + "I couldn't find any specific notes related to your query, but I'll try to assist you " + + "with general knowledge about Trilium or other topics you're interested in."; + } + + try { + // Get appropriate context size based on provider + const maxTotalLength = + providerId === 'openai' ? CONTEXT_WINDOW.OPENAI : + providerId === 'anthropic' ? CONTEXT_WINDOW.ANTHROPIC : + providerId === 'ollama' ? CONTEXT_WINDOW.OLLAMA : + CONTEXT_WINDOW.DEFAULT; + + // Use a format appropriate for the model family + const isAnthropicFormat = providerId === 'anthropic'; + + // Start with different headers based on provider + let context = isAnthropicFormat + ? `I'm your AI assistant helping with your Trilium notes database. For your query: "${query}", I found these relevant notes:\n\n` + : `I've found some relevant information in your notes that may help answer: "${query}"\n\n`; + + // Sort sources by similarity if available to prioritize most relevant + if (sources[0] && sources[0].similarity !== undefined) { + sources = [...sources].sort((a, b) => (b.similarity || 0) - (a.similarity || 0)); + } + + // Track total size to avoid exceeding model context window + let totalSize = context.length; + const formattedSources: string[] = []; + + // Process each source + for (const source of sources) { + let content = ''; + if (typeof source === 'string') { + content = source; + } else if (source.content) { + content = this.sanitizeNoteContent(source.content, source.type, source.mime); + } else { + continue; // Skip invalid sources + } + + if (!content || content.trim().length === 0) { + continue; + } + + // Format source with title if available + const title = source.title || 'Untitled Note'; + const noteId = source.noteId || ''; + const formattedSource = `### ${title}\n${content}\n`; + + // Check if adding this would exceed our size limit + if (totalSize + formattedSource.length > maxTotalLength) { + // If this is the first source, include a truncated version + if (formattedSources.length === 0) { + const availableSpace = maxTotalLength - totalSize - 100; // Buffer for closing text + if (availableSpace > 200) { // Only if we have reasonable space + const truncatedContent = `### ${title}\n${content.substring(0, availableSpace)}...\n`; + formattedSources.push(truncatedContent); + totalSize += truncatedContent.length; + } + } + break; + } + + formattedSources.push(formattedSource); + totalSize += formattedSource.length; + } + + // Add the formatted sources to the context + context += formattedSources.join('\n'); + + // Add closing to provide instructions to the AI + const closing = isAnthropicFormat + ? "\n\nPlease use this information to answer the user's query. If the notes don't contain enough information, you can use your general knowledge as well." + : "\n\nBased on this information from the user's notes, please provide a helpful response."; + + // Check if adding the closing would exceed our limit + if (totalSize + closing.length <= maxTotalLength) { + context += closing; + } + + return context; + } catch (error) { + log.error(`Error building context from notes: ${error}`); + return "I'm your AI assistant helping with your Trilium notes. I'll try to answer based on what I know."; + } + } + + /** + * Sanitize note content for inclusion in context + * + * @param content - Raw note content + * @param type - Note type (text, code, etc.) + * @param mime - Note mime type + * @returns Sanitized content + */ + sanitizeNoteContent(content: string, type?: string, mime?: string): string { + if (!content) { + return ''; + } + + try { + // If it's HTML content, sanitize it + if (mime === 'text/html' || type === 'text') { + // Use sanitize-html to convert HTML to plain text + const sanitized = sanitizeHtml(content, { + allowedTags: [], // No tags allowed (strip all HTML) + allowedAttributes: {}, // No attributes allowed + textFilter: function(text) { + return text + .replace(/ /g, ' ') + .replace(/\n\s*\n\s*\n/g, '\n\n'); // Replace multiple blank lines with just one + } + }); + + return sanitized.trim(); + } + + // If it's code, keep formatting but limit size + if (type === 'code' || mime?.includes('application/')) { + // For code, limit to a reasonable size + if (content.length > 2000) { + return content.substring(0, 2000) + '...\n\n[Content truncated for brevity]'; + } + return content; + } + + // For all other types, just return as is + return content; + } catch (error) { + log.error(`Error sanitizing note content: ${error}`); + return content; // Return original content if sanitization fails + } + } +} + +// Export singleton instance +export default new ContextFormatter(); diff --git a/src/services/llm/context/modules/context_service.ts b/src/services/llm/context/modules/context_service.ts new file mode 100644 index 000000000..2edc95a47 --- /dev/null +++ b/src/services/llm/context/modules/context_service.ts @@ -0,0 +1,368 @@ +import log from '../../../log.js'; +import providerManager from './provider_manager.js'; +import cacheManager from './cache_manager.js'; +import semanticSearch from './semantic_search.js'; +import queryEnhancer from './query_enhancer.js'; +import contextFormatter from './context_formatter.js'; +import aiServiceManager from '../../ai_service_manager.js'; +import { ContextExtractor } from '../index.js'; + +/** + * Main context service that integrates all context-related functionality + * This service replaces the old TriliumContextService and SemanticContextService + */ +export class ContextService { + private initialized = false; + private initPromise: Promise | null = null; + private contextExtractor: ContextExtractor; + + constructor() { + this.contextExtractor = new ContextExtractor(); + } + + /** + * Initialize the service + */ + async initialize(): Promise { + if (this.initialized) return; + + // Use a promise to prevent multiple simultaneous initializations + if (this.initPromise) return this.initPromise; + + this.initPromise = (async () => { + try { + // Initialize provider + const provider = await providerManager.getPreferredEmbeddingProvider(); + if (!provider) { + throw new Error(`No embedding provider available. Could not initialize context service.`); + } + + // Initialize agent tools to ensure they're ready + try { + await aiServiceManager.getInstance().initializeAgentTools(); + log.info("Agent tools initialized for use with ContextService"); + } catch (toolError) { + log.error(`Error initializing agent tools: ${toolError}`); + // Continue even if agent tools fail to initialize + } + + this.initialized = true; + log.info(`Context service initialized with provider: ${provider.name}`); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error(`Failed to initialize context service: ${errorMessage}`); + throw error; + } finally { + this.initPromise = null; + } + })(); + + return this.initPromise; + } + + /** + * Process a user query to find relevant context in Trilium notes + * + * @param userQuestion - The user's query + * @param llmService - The LLM service to use + * @param contextNoteId - Optional note ID to restrict search to a branch + * @param showThinking - Whether to show the thinking process in output + * @returns Context information and relevant notes + */ + async processQuery( + userQuestion: string, + llmService: any, + contextNoteId: string | null = null, + showThinking: boolean = false + ) { + log.info(`Processing query with: question="${userQuestion.substring(0, 50)}...", noteId=${contextNoteId}, showThinking=${showThinking}`); + + if (!this.initialized) { + try { + await this.initialize(); + } catch (error) { + log.error(`Failed to initialize ContextService: ${error}`); + // Return a fallback response if initialization fails + return { + context: "I am an AI assistant helping you with your Trilium notes. " + + "I'll try to assist you with general knowledge about your query.", + notes: [], + queries: [userQuestion] + }; + } + } + + try { + // Step 1: Generate search queries + let searchQueries: string[]; + try { + searchQueries = await queryEnhancer.generateSearchQueries(userQuestion, llmService); + } catch (error) { + log.error(`Error generating search queries, using fallback: ${error}`); + searchQueries = [userQuestion]; // Fallback to using the original question + } + log.info(`Generated search queries: ${JSON.stringify(searchQueries)}`); + + // Step 2: Find relevant notes using multi-query approach + let relevantNotes: any[] = []; + try { + // Find notes for each query and combine results + const allResults: Map = new Map(); + + for (const query of searchQueries) { + const results = await semanticSearch.findRelevantNotes( + query, + contextNoteId, + 5 // Limit per query + ); + + // Combine results, avoiding duplicates + for (const result of results) { + if (!allResults.has(result.noteId)) { + allResults.set(result.noteId, result); + } else { + // If note already exists, update similarity to max of both values + const existing = allResults.get(result.noteId); + if (result.similarity > existing.similarity) { + existing.similarity = result.similarity; + allResults.set(result.noteId, existing); + } + } + } + } + + // Convert map to array and limit to top results + relevantNotes = Array.from(allResults.values()) + .sort((a, b) => b.similarity - a.similarity) + .slice(0, 8); // Get top 8 notes + } catch (error) { + log.error(`Error finding relevant notes: ${error}`); + // Continue with empty notes list + } + + // Step 3: Build context from the notes + const provider = await providerManager.getPreferredEmbeddingProvider(); + const providerId = provider?.name || 'default'; + const context = await contextFormatter.buildContextFromNotes(relevantNotes, userQuestion, providerId); + + // Step 4: Add agent tools context with thinking process if requested + let enhancedContext = context; + if (contextNoteId) { + try { + const agentContext = await this.getAgentToolsContext( + contextNoteId, + userQuestion, + showThinking, + relevantNotes + ); + + if (agentContext) { + enhancedContext = enhancedContext + "\n\n" + agentContext; + } + } catch (error) { + log.error(`Error getting agent tools context: ${error}`); + // Continue with the basic context + } + } + + return { + context: enhancedContext, + notes: relevantNotes, + queries: searchQueries + }; + } catch (error) { + log.error(`Error processing query: ${error}`); + return { + context: "I am an AI assistant helping you with your Trilium notes. " + + "I'll try to assist you with general knowledge about your query.", + notes: [], + queries: [userQuestion] + }; + } + } + + /** + * Get context with agent tools enhancement + * + * @param noteId - The relevant note ID + * @param query - The user's query + * @param showThinking - Whether to show thinking process + * @param relevantNotes - Optional pre-found relevant notes + * @returns Enhanced context string + */ + async getAgentToolsContext( + noteId: string, + query: string, + showThinking: boolean = false, + relevantNotes: Array = [] + ): Promise { + try { + return await aiServiceManager.getInstance().getAgentToolsContext( + noteId, + query, + showThinking, + relevantNotes + ); + } catch (error) { + log.error(`Error getting agent tools context: ${error}`); + return ''; + } + } + + /** + * Get semantic context for a note and query + * + * @param noteId - The base note ID + * @param userQuery - The user's query + * @param maxResults - Maximum number of results to include + * @returns Formatted context string + */ + async getSemanticContext(noteId: string, userQuery: string, maxResults: number = 5): Promise { + if (!this.initialized) { + await this.initialize(); + } + + try { + // Get related notes from the context extractor + const [ + parentNotes, + childNotes, + linkedNotes + ] = await Promise.all([ + this.contextExtractor.getParentNotes(noteId, 3), + this.contextExtractor.getChildContext(noteId, 10).then(context => { + // Parse child notes from context string + const lines = context.split('\n'); + const result: {noteId: string, title: string}[] = []; + for (const line of lines) { + const match = line.match(/- (.*)/); + if (match) { + // We don't have noteIds in the context string, so use titles only + result.push({ + title: match[1], + noteId: '' // Empty noteId since we can't extract it from context + }); + } + } + return result; + }), + this.contextExtractor.getLinkedNotesContext(noteId, 10).then(context => { + // Parse linked notes from context string + const lines = context.split('\n'); + const result: {noteId: string, title: string}[] = []; + for (const line of lines) { + const match = line.match(/- \[(.*?)\]\(trilium:\/\/([a-zA-Z0-9]+)\)/); + if (match) { + result.push({ + title: match[1], + noteId: match[2] + }); + } + } + return result; + }) + ]); + + // Combine all related notes + const allRelatedNotes = [...parentNotes, ...childNotes, ...linkedNotes]; + + // If no related notes, return empty context + if (allRelatedNotes.length === 0) { + return ''; + } + + // Rank notes by relevance to query + const rankedNotes = await semanticSearch.rankNotesByRelevance(allRelatedNotes, userQuery); + + // Get content for the top N most relevant notes + const mostRelevantNotes = rankedNotes.slice(0, maxResults); + const relevantContent = await Promise.all( + mostRelevantNotes.map(async note => { + const content = await this.contextExtractor.getNoteContent(note.noteId); + if (!content) return null; + + // Format with relevance score and title + return `### ${note.title} (Relevance: ${Math.round(note.relevance * 100)}%)\n\n${content}`; + }) + ); + + // If no content retrieved, return empty string + if (!relevantContent.filter(Boolean).length) { + return ''; + } + + return `# Relevant Context\n\nThe following notes are most relevant to your query:\n\n${ + relevantContent.filter(Boolean).join('\n\n---\n\n') + }`; + } catch (error) { + log.error(`Error getting semantic context: ${error}`); + return ''; + } + } + + /** + * Get progressive context loading based on depth + * + * @param noteId - The base note ID + * @param depth - Depth level (1-4) + * @returns Context string with progressively more information + */ + async getProgressiveContext(noteId: string, depth: number = 1): Promise { + if (!this.initialized) { + await this.initialize(); + } + + try { + // Use the existing context extractor method + return await this.contextExtractor.getProgressiveContext(noteId, depth); + } catch (error) { + log.error(`Error getting progressive context: ${error}`); + return ''; + } + } + + /** + * Get smart context that adapts to query complexity + * + * @param noteId - The base note ID + * @param userQuery - The user's query + * @returns Context string with appropriate level of detail + */ + async getSmartContext(noteId: string, userQuery: string): Promise { + if (!this.initialized) { + await this.initialize(); + } + + try { + // Determine query complexity to adjust context depth + const complexity = queryEnhancer.estimateQueryComplexity(userQuery); + + // If it's a simple query with low complexity, use progressive context + if (complexity < 0.3) { + return await this.getProgressiveContext(noteId, 2); // Just note + parents + } + // For medium complexity, include more context + else if (complexity < 0.7) { + return await this.getProgressiveContext(noteId, 3); // Note + parents + children + } + // For complex queries, use semantic context + else { + return await this.getSemanticContext(noteId, userQuery, 7); // More results for complex queries + } + } catch (error) { + log.error(`Error getting smart context: ${error}`); + // Fallback to basic context extraction + return await this.contextExtractor.extractContext(noteId); + } + } + + /** + * Clear all context caches + */ + clearCaches(): void { + cacheManager.clearAllCaches(); + } +} + +// Export singleton instance +export default new ContextService(); diff --git a/src/services/llm/context/modules/provider_manager.ts b/src/services/llm/context/modules/provider_manager.ts new file mode 100644 index 000000000..df2d1bf15 --- /dev/null +++ b/src/services/llm/context/modules/provider_manager.ts @@ -0,0 +1,99 @@ +import options from '../../../options.js'; +import log from '../../../log.js'; +import { getEmbeddingProvider, getEnabledEmbeddingProviders } from '../../embeddings/providers.js'; + +/** + * Manages embedding providers for context services + */ +export class ProviderManager { + /** + * Get the preferred embedding provider based on user settings + * Tries to use the most appropriate provider in this order: + * 1. User's configured default provider + * 2. OpenAI if API key is set + * 3. Anthropic if API key is set + * 4. Ollama if configured + * 5. Any available provider + * 6. Local provider as fallback + * + * @returns The preferred embedding provider or null if none available + */ + async getPreferredEmbeddingProvider(): Promise { + try { + // First try user's configured default provider + const providerId = await options.getOption('embeddingsDefaultProvider'); + if (providerId) { + const provider = await getEmbeddingProvider(providerId); + if (provider) { + log.info(`Using configured embedding provider: ${providerId}`); + return provider; + } + } + + // Then try OpenAI + const openaiKey = await options.getOption('openaiApiKey'); + if (openaiKey) { + const provider = await getEmbeddingProvider('openai'); + if (provider) { + log.info('Using OpenAI embeddings provider'); + return provider; + } + } + + // Try Anthropic + const anthropicKey = await options.getOption('anthropicApiKey'); + if (anthropicKey) { + const provider = await getEmbeddingProvider('anthropic'); + if (provider) { + log.info('Using Anthropic embeddings provider'); + return provider; + } + } + + // Try Ollama + const provider = await getEmbeddingProvider('ollama'); + if (provider) { + log.info('Using Ollama embeddings provider'); + return provider; + } + + // If no preferred providers, get any enabled provider + const providers = await getEnabledEmbeddingProviders(); + if (providers.length > 0) { + log.info(`Using available embedding provider: ${providers[0].name}`); + return providers[0]; + } + + // Last resort is local provider + log.info('Using local embedding provider as fallback'); + return await getEmbeddingProvider('local'); + } catch (error) { + log.error(`Error getting preferred embedding provider: ${error}`); + return null; + } + } + + /** + * Generate embeddings for a text query + * + * @param query - The text query to embed + * @returns The generated embedding or null if failed + */ + async generateQueryEmbedding(query: string): Promise { + try { + // Get the preferred embedding provider + const provider = await this.getPreferredEmbeddingProvider(); + if (!provider) { + log.error('No embedding provider available'); + return null; + } + return await provider.generateEmbeddings(query); + } catch (error) { + log.error(`Error generating query embedding: ${error}`); + return null; + } + } +} + +// Export singleton instance +export default new ProviderManager(); diff --git a/src/services/llm/context/modules/query_enhancer.ts b/src/services/llm/context/modules/query_enhancer.ts new file mode 100644 index 000000000..baae7265b --- /dev/null +++ b/src/services/llm/context/modules/query_enhancer.ts @@ -0,0 +1,168 @@ +import log from '../../../log.js'; +import cacheManager from './cache_manager.js'; +import type { Message } from '../../ai_interface.js'; + +/** + * Provides utilities for enhancing queries and generating search queries + */ +export class QueryEnhancer { + // Default meta-prompt for query enhancement + private metaPrompt = `You are an AI assistant that decides what information needs to be retrieved from a user's knowledge base called TriliumNext Notes to answer the user's question. +Given the user's question, generate 3-5 specific search queries that would help find relevant information. +Each query should be focused on a different aspect of the question. +Format your answer as a JSON array of strings, with each string being a search query. +Example: ["exact topic mentioned", "related concept 1", "related concept 2"]`; + + /** + * Generate search queries to find relevant information for the user question + * + * @param userQuestion - The user's question + * @param llmService - The LLM service to use for generating queries + * @returns Array of search queries + */ + async generateSearchQueries(userQuestion: string, llmService: any): Promise { + try { + // Check cache first + const cached = cacheManager.getQueryResults(`searchQueries:${userQuestion}`); + if (cached) { + return cached; + } + + const messages: Message[] = [ + { role: "system", content: this.metaPrompt }, + { role: "user", content: userQuestion } + ]; + + const options = { + temperature: 0.3, + maxTokens: 300 + }; + + // Get the response from the LLM + const response = await llmService.generateChatCompletion(messages, options); + const responseText = response.text; // Extract the text from the response object + + try { + // Remove code blocks, quotes, and clean up the response text + let jsonStr = responseText + .replace(/```(?:json)?|```/g, '') // Remove code block markers + .replace(/[\u201C\u201D]/g, '"') // Replace smart quotes with straight quotes + .trim(); + + // Check if the text might contain a JSON array (has square brackets) + if (jsonStr.includes('[') && jsonStr.includes(']')) { + // Extract just the array part if there's explanatory text + const arrayMatch = jsonStr.match(/\[[\s\S]*\]/); + if (arrayMatch) { + jsonStr = arrayMatch[0]; + } + + // Try to parse the JSON + try { + const queries = JSON.parse(jsonStr); + if (Array.isArray(queries) && queries.length > 0) { + const result = queries.map(q => typeof q === 'string' ? q : String(q)).filter(Boolean); + cacheManager.storeQueryResults(`searchQueries:${userQuestion}`, result); + return result; + } + } catch (innerError) { + // If parsing fails, log it and continue to the fallback + log.info(`JSON parse error: ${innerError}. Will use fallback parsing for: ${jsonStr}`); + } + } + + // Fallback 1: Try to extract an array manually by splitting on commas between quotes + if (jsonStr.includes('[') && jsonStr.includes(']')) { + const arrayContent = jsonStr.substring( + jsonStr.indexOf('[') + 1, + jsonStr.lastIndexOf(']') + ); + + // Use regex to match quoted strings, handling escaped quotes + const stringMatches = arrayContent.match(/"((?:\\.|[^"\\])*)"/g); + if (stringMatches && stringMatches.length > 0) { + const result = stringMatches + .map((m: string) => m.substring(1, m.length - 1)) // Remove surrounding quotes + .filter((s: string) => s.length > 0); + cacheManager.storeQueryResults(`searchQueries:${userQuestion}`, result); + return result; + } + } + + // Fallback 2: Extract queries line by line + const lines = responseText.split('\n') + .map((line: string) => line.trim()) + .filter((line: string) => + line.length > 0 && + !line.startsWith('```') && + !line.match(/^\d+\.?\s*$/) && // Skip numbered list markers alone + !line.match(/^\[|\]$/) // Skip lines that are just brackets + ); + + if (lines.length > 0) { + // Remove numbering, quotes and other list markers from each line + const result = lines.map((line: string) => { + return line + .replace(/^\d+\.?\s*/, '') // Remove numbered list markers (1., 2., etc) + .replace(/^[-*•]\s*/, '') // Remove bullet list markers + .replace(/^["']|["']$/g, '') // Remove surrounding quotes + .trim(); + }).filter((s: string) => s.length > 0); + + cacheManager.storeQueryResults(`searchQueries:${userQuestion}`, result); + return result; + } + } catch (parseError) { + log.error(`Error parsing search queries: ${parseError}`); + } + + // If all else fails, just use the original question + const fallback = [userQuestion]; + cacheManager.storeQueryResults(`searchQueries:${userQuestion}`, fallback); + return fallback; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error(`Error generating search queries: ${errorMessage}`); + // Fallback to just using the original question + return [userQuestion]; + } + } + + /** + * Estimate the complexity of a query + * This is used to determine the appropriate amount of context to provide + * + * @param query - The query to analyze + * @returns A complexity score from 0 (simple) to 1 (complex) + */ + estimateQueryComplexity(query: string): number { + // Simple complexity estimation based on various factors + + // Factor 1: Query length + const lengthScore = Math.min(query.length / 100, 0.4); + + // Factor 2: Number of question words + const questionWords = ['what', 'how', 'why', 'when', 'where', 'who', 'which']; + const questionWordsCount = questionWords.filter(word => + query.toLowerCase().includes(` ${word} `) || + query.toLowerCase().startsWith(`${word} `) + ).length; + const questionWordsScore = Math.min(questionWordsCount * 0.15, 0.3); + + // Factor 3: Contains comparison indicators + const comparisonWords = ['compare', 'difference', 'versus', 'vs', 'similarities', 'differences']; + const hasComparison = comparisonWords.some(word => query.toLowerCase().includes(word)); + const comparisonScore = hasComparison ? 0.2 : 0; + + // Factor 4: Request for detailed or in-depth information + const depthWords = ['explain', 'detail', 'elaborate', 'analysis', 'in-depth']; + const hasDepthRequest = depthWords.some(word => query.toLowerCase().includes(word)); + const depthScore = hasDepthRequest ? 0.2 : 0; + + // Combine scores with a maximum of 1.0 + return Math.min(lengthScore + questionWordsScore + comparisonScore + depthScore, 1.0); + } +} + +// Export singleton instance +export default new QueryEnhancer(); diff --git a/src/services/llm/context/modules/semantic_search.ts b/src/services/llm/context/modules/semantic_search.ts new file mode 100644 index 000000000..e95c07cff --- /dev/null +++ b/src/services/llm/context/modules/semantic_search.ts @@ -0,0 +1,306 @@ +import * as vectorStore from '../../embeddings/index.js'; +import { cosineSimilarity } from '../../embeddings/index.js'; +import log from '../../../log.js'; +import becca from '../../../../becca/becca.js'; +import providerManager from './provider_manager.js'; +import cacheManager from './cache_manager.js'; +import { ContextExtractor } from '../index.js'; + +/** + * Provides semantic search capabilities for finding relevant notes + */ +export class SemanticSearch { + private contextExtractor: ContextExtractor; + + constructor() { + this.contextExtractor = new ContextExtractor(); + } + + /** + * Rank notes by their semantic relevance to a query + * + * @param notes - Array of notes with noteId and title + * @param userQuery - The user's query to compare against + * @returns Sorted array of notes with relevance score + */ + async rankNotesByRelevance( + notes: Array<{noteId: string, title: string}>, + userQuery: string + ): Promise> { + // Try to get from cache first + const cacheKey = `rank:${userQuery}:${notes.map(n => n.noteId).join(',')}`; + const cached = cacheManager.getNoteData('', cacheKey); + if (cached) { + return cached; + } + + const queryEmbedding = await providerManager.generateQueryEmbedding(userQuery); + if (!queryEmbedding) { + // If embedding fails, return notes in original order + return notes.map(note => ({ ...note, relevance: 0 })); + } + + const provider = await providerManager.getPreferredEmbeddingProvider(); + if (!provider) { + return notes.map(note => ({ ...note, relevance: 0 })); + } + + const rankedNotes = []; + + for (const note of notes) { + // Get note embedding from vector store or generate it if not exists + let noteEmbedding = null; + try { + const embeddingResult = await vectorStore.getEmbeddingForNote( + note.noteId, + provider.name, + provider.getConfig().model || '' + ); + + if (embeddingResult) { + noteEmbedding = embeddingResult.embedding; + } + } catch (error) { + log.error(`Error retrieving embedding for note ${note.noteId}: ${error}`); + } + + if (!noteEmbedding) { + // If note doesn't have an embedding yet, get content and generate one + const content = await this.contextExtractor.getNoteContent(note.noteId); + if (content && provider) { + try { + noteEmbedding = await provider.generateEmbeddings(content); + // Store the embedding for future use + await vectorStore.storeNoteEmbedding( + note.noteId, + provider.name, + provider.getConfig().model || '', + noteEmbedding + ); + } catch (error) { + log.error(`Error generating embedding for note ${note.noteId}: ${error}`); + } + } + } + + let relevance = 0; + if (noteEmbedding) { + // Calculate cosine similarity between query and note + relevance = cosineSimilarity(queryEmbedding, noteEmbedding); + } + + rankedNotes.push({ + ...note, + relevance + }); + } + + // Sort by relevance (highest first) + const result = rankedNotes.sort((a, b) => b.relevance - a.relevance); + + // Cache results + cacheManager.storeNoteData('', cacheKey, result); + + return result; + } + + /** + * Find notes that are semantically relevant to a query + * + * @param query - The search query + * @param contextNoteId - Optional note ID to restrict search to a branch + * @param limit - Maximum number of results to return + * @returns Array of relevant notes with similarity scores + */ + async findRelevantNotes( + query: string, + contextNoteId: string | null = null, + limit = 10 + ): Promise<{noteId: string, title: string, content: string | null, similarity: number}[]> { + try { + // Check cache first + const cacheKey = `find:${query}:${contextNoteId || 'all'}:${limit}`; + const cached = cacheManager.getQueryResults(cacheKey); + if (cached) { + return cached; + } + + // Get embedding for query + const queryEmbedding = await providerManager.generateQueryEmbedding(query); + if (!queryEmbedding) { + log.error('Failed to generate query embedding'); + return []; + } + + let results: {noteId: string, similarity: number}[] = []; + + // Get provider information + const provider = await providerManager.getPreferredEmbeddingProvider(); + if (!provider) { + log.error('No embedding provider available'); + return []; + } + + // If contextNoteId is provided, search only within that branch + if (contextNoteId) { + results = await this.findNotesInBranch(queryEmbedding, contextNoteId, limit); + } else { + // Otherwise search across all notes with embeddings + results = await vectorStore.findSimilarNotes( + queryEmbedding, + provider.name, + provider.getConfig().model || '', + limit + ); + } + + // Get note details for results + const enrichedResults = await Promise.all( + results.map(async result => { + const note = becca.getNote(result.noteId); + if (!note) { + return null; + } + + // Get note content + const content = await this.contextExtractor.getNoteContent(result.noteId); + + return { + noteId: result.noteId, + title: note.title, + content, + similarity: result.similarity + }; + }) + ); + + // Filter out null results + const filteredResults = enrichedResults.filter(Boolean) as { + noteId: string, + title: string, + content: string | null, + similarity: number + }[]; + + // Cache results + cacheManager.storeQueryResults(cacheKey, filteredResults); + + return filteredResults; + } catch (error) { + log.error(`Error finding relevant notes: ${error}`); + return []; + } + } + + /** + * Find notes in a specific branch (subtree) that are relevant to a query + * + * @param embedding - The query embedding + * @param contextNoteId - Root note ID of the branch + * @param limit - Maximum results to return + * @returns Array of note IDs with similarity scores + */ + private async findNotesInBranch( + embedding: Float32Array, + contextNoteId: string, + limit = 5 + ): Promise<{noteId: string, similarity: number}[]> { + try { + // Get all notes in the subtree + const noteIds = await this.getSubtreeNoteIds(contextNoteId); + + if (noteIds.length === 0) { + return []; + } + + // Get provider information + const provider = await providerManager.getPreferredEmbeddingProvider(); + if (!provider) { + log.error('No embedding provider available'); + return []; + } + + // Get model configuration + const model = provider.getConfig().model || ''; + const providerName = provider.name; + + // Check if vectorStore has the findSimilarNotesInSet method + if (typeof vectorStore.findSimilarNotesInSet === 'function') { + // Use the dedicated method if available + return await vectorStore.findSimilarNotesInSet( + embedding, + noteIds, + providerName, + model, + limit + ); + } + + // Fallback: Manually search through the notes in the subtree + const similarities: {noteId: string, similarity: number}[] = []; + + for (const noteId of noteIds) { + try { + const noteEmbedding = await vectorStore.getEmbeddingForNote( + noteId, + providerName, + model + ); + + if (noteEmbedding && noteEmbedding.embedding) { + const similarity = cosineSimilarity(embedding, noteEmbedding.embedding); + if (similarity > 0.5) { // Apply a similarity threshold + similarities.push({ + noteId, + similarity + }); + } + } + } catch (error) { + // Skip notes that don't have embeddings + continue; + } + } + + // Sort by similarity and return top results + return similarities + .sort((a, b) => b.similarity - a.similarity) + .slice(0, limit); + } catch (error) { + log.error(`Error finding notes in branch: ${error}`); + return []; + } + } + + /** + * Get all note IDs in a subtree + * + * @param rootNoteId - The root note ID + * @returns Array of note IDs in the subtree + */ + private async getSubtreeNoteIds(rootNoteId: string): Promise { + const noteIds = new Set(); + noteIds.add(rootNoteId); // Include the root note itself + + const collectChildNotes = (noteId: string) => { + const note = becca.getNote(noteId); + if (!note) { + return; + } + + const childNotes = note.getChildNotes(); + for (const childNote of childNotes) { + if (!noteIds.has(childNote.noteId)) { + noteIds.add(childNote.noteId); + collectChildNotes(childNote.noteId); + } + } + }; + + collectChildNotes(rootNoteId); + return Array.from(noteIds); + } +} + +// Export singleton instance +export default new SemanticSearch(); diff --git a/src/services/llm/context_service.ts b/src/services/llm/context_service.ts new file mode 100644 index 000000000..65b7d175f --- /dev/null +++ b/src/services/llm/context_service.ts @@ -0,0 +1,190 @@ +/** + * Trilium Notes Context Service + * + * Unified entry point for all context-related services + * Provides intelligent context management for AI features + */ + +import log from '../log.js'; +import contextService from './context/modules/context_service.js'; +import { ContextExtractor } from './context/index.js'; + +/** + * Main Context Service for Trilium Notes + * + * This service provides a unified interface for all context-related functionality: + * - Processing user queries with semantic search + * - Finding relevant notes using AI-enhanced query understanding + * - Progressive context loading based on query complexity + * - Semantic context extraction + * - Context formatting for different LLM providers + * + * This implementation uses a modular approach with specialized services: + * - Provider management + * - Cache management + * - Semantic search + * - Query enhancement + * - Context formatting + */ +class TriliumContextService { + private contextExtractor: ContextExtractor; + + constructor() { + this.contextExtractor = new ContextExtractor(); + log.info('TriliumContextService created'); + } + + /** + * Initialize the context service + */ + async initialize(): Promise { + return contextService.initialize(); + } + + /** + * Process a user query to find relevant context in Trilium notes + * + * @param userQuestion - The user's query + * @param llmService - The LLM service to use for query enhancement + * @param contextNoteId - Optional note ID to restrict search to a branch + * @param showThinking - Whether to show the LLM's thinking process + * @returns Context information and relevant notes + */ + async processQuery( + userQuestion: string, + llmService: any, + contextNoteId: string | null = null, + showThinking: boolean = false + ) { + return contextService.processQuery(userQuestion, llmService, contextNoteId, showThinking); + } + + /** + * Get context enhanced with agent tools + * + * @param noteId - The current note ID + * @param query - The user's query + * @param showThinking - Whether to show thinking process + * @param relevantNotes - Optional pre-found relevant notes + * @returns Enhanced context string + */ + async getAgentToolsContext( + noteId: string, + query: string, + showThinking: boolean = false, + relevantNotes: Array = [] + ): Promise { + return contextService.getAgentToolsContext(noteId, query, showThinking, relevantNotes); + } + + /** + * Build formatted context from notes + * + * @param sources - Array of notes or content sources + * @param query - The original user query + * @returns Formatted context string + */ + async buildContextFromNotes(sources: any[], query: string): Promise { + const provider = await (await import('./context/modules/provider_manager.js')).default.getPreferredEmbeddingProvider(); + const providerId = provider?.name || 'default'; + return (await import('./context/modules/context_formatter.js')).default.buildContextFromNotes(sources, query, providerId); + } + + /** + * Find relevant notes using multi-query approach + * + * @param queries - Array of search queries + * @param contextNoteId - Optional note ID to restrict search + * @param limit - Maximum notes to return + * @returns Array of relevant notes + */ + async findRelevantNotesMultiQuery( + queries: string[], + contextNoteId: string | null = null, + limit = 10 + ): Promise { + const allResults: Map = new Map(); + + for (const query of queries) { + const results = await (await import('./context/modules/semantic_search.js')).default.findRelevantNotes( + query, + contextNoteId, + Math.ceil(limit / queries.length) // Distribute limit among queries + ); + + // Combine results, avoiding duplicates + for (const result of results) { + if (!allResults.has(result.noteId)) { + allResults.set(result.noteId, result); + } else { + // If note already exists, update similarity to max of both values + const existing = allResults.get(result.noteId); + if (result.similarity > existing.similarity) { + existing.similarity = result.similarity; + allResults.set(result.noteId, existing); + } + } + } + } + + // Convert map to array and limit to top results + return Array.from(allResults.values()) + .sort((a, b) => b.similarity - a.similarity) + .slice(0, limit); + } + + /** + * Generate search queries to find relevant information + * + * @param userQuestion - The user's question + * @param llmService - The LLM service to use for generating queries + * @returns Array of search queries + */ + async generateSearchQueries(userQuestion: string, llmService: any): Promise { + return (await import('./context/modules/query_enhancer.js')).default.generateSearchQueries(userQuestion, llmService); + } + + /** + * Get semantic context for a note + * + * @param noteId - The note ID + * @param userQuery - The user's query + * @param maxResults - Maximum results to include + * @returns Formatted context string + */ + async getSemanticContext(noteId: string, userQuery: string, maxResults = 5): Promise { + return contextService.getSemanticContext(noteId, userQuery, maxResults); + } + + /** + * Get progressive context based on depth level + * + * @param noteId - The note ID + * @param depth - Depth level (1-4) + * @returns Context string + */ + async getProgressiveContext(noteId: string, depth = 1): Promise { + return contextService.getProgressiveContext(noteId, depth); + } + + /** + * Get smart context that adapts to query complexity + * + * @param noteId - The note ID + * @param userQuery - The user's query + * @returns Context string + */ + async getSmartContext(noteId: string, userQuery: string): Promise { + return contextService.getSmartContext(noteId, userQuery); + } + + /** + * Clear all context caches + */ + clearCaches(): void { + return contextService.clearCaches(); + } +} + +// Export singleton instance +export default new TriliumContextService(); diff --git a/src/services/llm/semantic_context_service.ts b/src/services/llm/semantic_context_service.ts deleted file mode 100644 index a0839d5f3..000000000 --- a/src/services/llm/semantic_context_service.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { ContextExtractor } from './context/index.js'; -import * as vectorStore from './embeddings/index.js'; -import sql from '../sql.js'; -import { cosineSimilarity } from './embeddings/index.js'; -import log from '../log.js'; -import { getEmbeddingProvider, getEnabledEmbeddingProviders } from './embeddings/providers.js'; -import options from '../options.js'; - -/** - * SEMANTIC CONTEXT SERVICE - * - * This service provides advanced context extraction capabilities for AI models. - * It enhances the basic context extractor with vector embedding-based semantic - * search and progressive context loading for large notes. - * - * === USAGE GUIDE === - * - * 1. To use this service in other modules: - * ``` - * import aiServiceManager from './services/llm/ai_service_manager.js'; - * const semanticContext = aiServiceManager.getSemanticContextService(); - * ``` - * - * Or with the instance directly: - * ``` - * import aiServiceManager from './services/llm/ai_service_manager.js'; - * const semanticContext = aiServiceManager.getInstance().getSemanticContextService(); - * ``` - * - * 2. Retrieve context based on semantic relevance to a query: - * ``` - * const context = await semanticContext.getSemanticContext(noteId, userQuery); - * ``` - * - * 3. Load context progressively (only what's needed): - * ``` - * const context = await semanticContext.getProgressiveContext(noteId, depth); - * // depth: 1=just note, 2=+parents, 3=+children, 4=+linked notes - * ``` - * - * 4. Use smart context selection that adapts to query complexity: - * ``` - * const context = await semanticContext.getSmartContext(noteId, userQuery); - * ``` - * - * === REQUIREMENTS === - * - * - Requires at least one configured embedding provider (OpenAI, Anthropic, Ollama) - * - Will fall back to non-semantic methods if no embedding provider is available - * - Uses OpenAI embeddings by default if API key is configured - */ - -/** - * Provides advanced semantic context capabilities, enhancing the basic context extractor - * with vector embedding-based semantic search and progressive context loading. - * - * This service is especially useful for retrieving the most relevant context from large - * knowledge bases when working with limited-context LLMs. - */ -class SemanticContextService { - // Create an instance of ContextExtractor for backward compatibility - private contextExtractor = new ContextExtractor(); - - /** - * Get the preferred embedding provider based on user settings - * Tries to use the most appropriate provider in this order: - * 1. OpenAI if API key is set - * 2. Anthropic if API key is set - * 3. Ollama if configured - * 4. Any available provider - * 5. Local provider as fallback - * - * @returns The preferred embedding provider or null if none available - */ - private async getPreferredEmbeddingProvider(): Promise { - // Try to get provider in order of preference - const openaiKey = await options.getOption('openaiApiKey'); - if (openaiKey) { - const provider = await getEmbeddingProvider('openai'); - if (provider) return provider; - } - - const anthropicKey = await options.getOption('anthropicApiKey'); - if (anthropicKey) { - const provider = await getEmbeddingProvider('anthropic'); - if (provider) return provider; - } - - // If neither of the preferred providers is available, get any provider - const providers = await getEnabledEmbeddingProviders(); - if (providers.length > 0) { - return providers[0]; - } - - // Last resort is local provider - return await getEmbeddingProvider('local'); - } - - /** - * Generate embeddings for a text query - * - * @param query - The text query to embed - * @returns The generated embedding or null if failed - */ - private async generateQueryEmbedding(query: string): Promise { - try { - // Get the preferred embedding provider - const provider = await this.getPreferredEmbeddingProvider(); - if (!provider) { - return null; - } - return await provider.generateEmbeddings(query); - } catch (error) { - log.error(`Error generating query embedding: ${error}`); - return null; - } - } - - /** - * Rank notes by semantic relevance to a query using vector similarity - * - * @param notes - Array of notes with noteId and title - * @param userQuery - The user's query to compare against - * @returns Sorted array of notes with relevance score - */ - async rankNotesByRelevance( - notes: Array<{noteId: string, title: string}>, - userQuery: string - ): Promise> { - const queryEmbedding = await this.generateQueryEmbedding(userQuery); - if (!queryEmbedding) { - // If embedding fails, return notes in original order - return notes.map(note => ({ ...note, relevance: 0 })); - } - - const provider = await this.getPreferredEmbeddingProvider(); - if (!provider) { - return notes.map(note => ({ ...note, relevance: 0 })); - } - - const rankedNotes = []; - - for (const note of notes) { - // Get note embedding from vector store or generate it if not exists - let noteEmbedding = null; - try { - const embeddingResult = await vectorStore.getEmbeddingForNote( - note.noteId, - provider.name, - provider.getConfig().model || '' - ); - - if (embeddingResult) { - noteEmbedding = embeddingResult.embedding; - } - } catch (error) { - log.error(`Error retrieving embedding for note ${note.noteId}: ${error}`); - } - - if (!noteEmbedding) { - // If note doesn't have an embedding yet, get content and generate one - const content = await this.contextExtractor.getNoteContent(note.noteId); - if (content && provider) { - try { - noteEmbedding = await provider.generateEmbeddings(content); - // Store the embedding for future use - await vectorStore.storeNoteEmbedding( - note.noteId, - provider.name, - provider.getConfig().model || '', - noteEmbedding - ); - } catch (error) { - log.error(`Error generating embedding for note ${note.noteId}: ${error}`); - } - } - } - - let relevance = 0; - if (noteEmbedding) { - // Calculate cosine similarity between query and note - relevance = cosineSimilarity(queryEmbedding, noteEmbedding); - } - - rankedNotes.push({ - ...note, - relevance - }); - } - - // Sort by relevance (highest first) - return rankedNotes.sort((a, b) => b.relevance - a.relevance); - } - - /** - * Retrieve semantic context based on relevance to user query - * Finds the most semantically similar notes to the user's query - * - * @param noteId - Base note ID to start the search from - * @param userQuery - Query to find relevant context for - * @param maxResults - Maximum number of notes to include in context - * @returns Formatted context with the most relevant notes - */ - async getSemanticContext(noteId: string, userQuery: string, maxResults = 5): Promise { - // Get related notes (parents, children, linked notes) - const [ - parentNotes, - childNotes, - linkedNotes - ] = await Promise.all([ - this.getParentNotes(noteId, 3), - this.getChildNotes(noteId, 10), - this.getLinkedNotes(noteId, 10) - ]); - - // Combine all related notes - const allRelatedNotes = [...parentNotes, ...childNotes, ...linkedNotes]; - - // If no related notes, return empty context - if (allRelatedNotes.length === 0) { - return ''; - } - - // Rank notes by relevance to query - const rankedNotes = await this.rankNotesByRelevance(allRelatedNotes, userQuery); - - // Get content for the top N most relevant notes - const mostRelevantNotes = rankedNotes.slice(0, maxResults); - const relevantContent = await Promise.all( - mostRelevantNotes.map(async note => { - const content = await this.contextExtractor.getNoteContent(note.noteId); - if (!content) return null; - - // Format with relevance score and title - return `### ${note.title} (Relevance: ${Math.round(note.relevance * 100)}%)\n\n${content}`; - }) - ); - - // If no content retrieved, return empty string - if (!relevantContent.filter(Boolean).length) { - return ''; - } - - return `# Relevant Context\n\nThe following notes are most relevant to your query:\n\n${ - relevantContent.filter(Boolean).join('\n\n---\n\n') - }`; - } - - /** - * Load context progressively based on depth level - * This allows starting with minimal context and expanding as needed - * - * @param noteId - The ID of the note to get context for - * @param depth - Depth level (1-4) determining how much context to include - * @returns Context appropriate for the requested depth - */ - async getProgressiveContext(noteId: string, depth = 1): Promise { - // Start with the note content - const noteContent = await this.contextExtractor.getNoteContent(noteId); - if (!noteContent) return 'Note not found'; - - // If depth is 1, just return the note content - if (depth <= 1) return noteContent; - - // Add parent context for depth >= 2 - const parentContext = await this.contextExtractor.getParentContext(noteId); - if (depth <= 2) return `${parentContext}\n\n${noteContent}`; - - // Add child context for depth >= 3 - const childContext = await this.contextExtractor.getChildContext(noteId); - if (depth <= 3) return `${parentContext}\n\n${noteContent}\n\n${childContext}`; - - // Add linked notes for depth >= 4 - const linkedContext = await this.contextExtractor.getLinkedNotesContext(noteId); - return `${parentContext}\n\n${noteContent}\n\n${childContext}\n\n${linkedContext}`; - } - - /** - * Get parent notes in the hierarchy - * Helper method that queries the database directly - */ - private async getParentNotes(noteId: string, maxDepth: number): Promise<{noteId: string, title: string}[]> { - const parentNotes: {noteId: string, title: string}[] = []; - let currentNoteId = noteId; - - for (let i = 0; i < maxDepth; i++) { - const parent = await sql.getRow<{parentNoteId: string, title: string}>( - `SELECT branches.parentNoteId, notes.title - FROM branches - JOIN notes ON branches.parentNoteId = notes.noteId - WHERE branches.noteId = ? AND branches.isDeleted = 0 LIMIT 1`, - [currentNoteId] - ); - - if (!parent || parent.parentNoteId === 'root') { - break; - } - - parentNotes.unshift({ - noteId: parent.parentNoteId, - title: parent.title - }); - - currentNoteId = parent.parentNoteId; - } - - return parentNotes; - } - - /** - * Get child notes - * Helper method that queries the database directly - */ - private async getChildNotes(noteId: string, maxChildren: number): Promise<{noteId: string, title: string}[]> { - return await sql.getRows<{noteId: string, title: string}>( - `SELECT noteId, title FROM notes - WHERE parentNoteId = ? AND isDeleted = 0 - LIMIT ?`, - [noteId, maxChildren] - ); - } - - /** - * Get linked notes - * Helper method that queries the database directly - */ - private async getLinkedNotes(noteId: string, maxLinks: number): Promise<{noteId: string, title: string}[]> { - return await sql.getRows<{noteId: string, title: string}>( - `SELECT noteId, title FROM notes - WHERE noteId IN ( - SELECT value FROM attributes - WHERE noteId = ? AND type = 'relation' - LIMIT ? - )`, - [noteId, maxLinks] - ); - } - - /** - * Smart context selection that combines semantic matching with progressive loading - * Returns the most appropriate context based on the query and available information - * - * @param noteId - The ID of the note to get context for - * @param userQuery - The user's query for semantic relevance matching - * @returns The optimal context for answering the query - */ - async getSmartContext(noteId: string, userQuery: string): Promise { - // Check if embedding provider is available - const provider = await this.getPreferredEmbeddingProvider(); - - if (provider) { - try { - const semanticContext = await this.getSemanticContext(noteId, userQuery); - if (semanticContext) { - return semanticContext; - } - } catch (error) { - log.error(`Error getting semantic context: ${error}`); - // Fall back to progressive context if semantic fails - } - } - - // Default to progressive context with appropriate depth based on query complexity - // Simple queries get less context, complex ones get more - const queryComplexity = this.estimateQueryComplexity(userQuery); - const depth = Math.min(4, Math.max(1, queryComplexity)); - - return this.getProgressiveContext(noteId, depth); - } - - /** - * Estimate query complexity to determine appropriate context depth - * - * @param query - The user's query string - * @returns Complexity score from 1-4 - */ - private estimateQueryComplexity(query: string): number { - if (!query) return 1; - - // Simple heuristics for query complexity: - // 1. Length (longer queries tend to be more complex) - // 2. Number of questions or specific requests - // 3. Presence of complex terms/concepts - - const words = query.split(/\s+/).length; - const questions = (query.match(/\?/g) || []).length; - const comparisons = (query.match(/compare|difference|versus|vs\.|between/gi) || []).length; - const complexity = (query.match(/explain|analyze|synthesize|evaluate|critique|recommend|suggest/gi) || []).length; - - // Calculate complexity score - let score = 1; - - if (words > 20) score += 1; - if (questions > 1) score += 1; - if (comparisons > 0) score += 1; - if (complexity > 0) score += 1; - - return Math.min(4, score); - } -} - -// Singleton instance -const semanticContextService = new SemanticContextService(); -export default semanticContextService; diff --git a/src/services/llm/trilium_context_service.ts b/src/services/llm/trilium_context_service.ts deleted file mode 100644 index 20108392d..000000000 --- a/src/services/llm/trilium_context_service.ts +++ /dev/null @@ -1,870 +0,0 @@ -import becca from "../../becca/becca.js"; -import vectorStore from "./embeddings/index.js"; -import providerManager from "./embeddings/providers.js"; -import options from "../options.js"; -import log from "../log.js"; -import type { Message } from "./ai_interface.js"; -import { cosineSimilarity } from "./embeddings/index.js"; -import sanitizeHtml from "sanitize-html"; -import aiServiceManager from "./ai_service_manager.js"; - -/** - * TriliumContextService provides intelligent context management for working with large knowledge bases - * through limited context window LLMs like Ollama. - * - * It creates a "meta-prompting" approach where the first LLM call is used - * to determine what information might be needed to answer the query, - * then only the relevant context is loaded, before making the final - * response. - */ -class TriliumContextService { - private initialized = false; - private initPromise: Promise | null = null; - private provider: any = null; - - // Cache for recently used context to avoid repeated embedding lookups - private recentQueriesCache = new Map(); - - // Configuration - private cacheExpiryMs = 5 * 60 * 1000; // 5 minutes - private metaPrompt = `You are an AI assistant that decides what information needs to be retrieved from a user's knowledge base called TriliumNext Notes to answer the user's question. -Given the user's question, generate 3-5 specific search queries that would help find relevant information. -Each query should be focused on a different aspect of the question. -Format your answer as a JSON array of strings, with each string being a search query. -Example: ["exact topic mentioned", "related concept 1", "related concept 2"]`; - - constructor() { - this.setupCacheCleanup(); - } - - /** - * Initialize the service - */ - async initialize() { - if (this.initialized) return; - - // Use a promise to prevent multiple simultaneous initializations - if (this.initPromise) return this.initPromise; - - this.initPromise = (async () => { - try { - // Get user's configured provider or fallback to ollama - const providerId = await options.getOption('embeddingsDefaultProvider') || 'ollama'; - this.provider = providerManager.getEmbeddingProvider(providerId); - - // If specified provider not found, try ollama as first fallback for self-hosted usage - if (!this.provider && providerId !== 'ollama') { - log.info(`Embedding provider ${providerId} not found, trying ollama as fallback`); - this.provider = providerManager.getEmbeddingProvider('ollama'); - } - - // If ollama not found, try openai as a second fallback - if (!this.provider && providerId !== 'openai') { - log.info(`Embedding provider ollama not found, trying openai as fallback`); - this.provider = providerManager.getEmbeddingProvider('openai'); - } - - // Final fallback to local provider which should always exist - if (!this.provider) { - log.info(`No embedding provider found, falling back to local provider`); - this.provider = providerManager.getEmbeddingProvider('local'); - } - - if (!this.provider) { - throw new Error(`No embedding provider available. Could not initialize context service.`); - } - - // Initialize agent tools to ensure they're ready - try { - await aiServiceManager.getInstance().initializeAgentTools(); - log.info("Agent tools initialized for use with TriliumContextService"); - } catch (toolError) { - log.error(`Error initializing agent tools: ${toolError}`); - // Continue even if agent tools fail to initialize - } - - this.initialized = true; - log.info(`Trilium context service initialized with provider: ${this.provider.name}`); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error(`Failed to initialize Trilium context service: ${errorMessage}`); - throw error; - } finally { - this.initPromise = null; - } - })(); - - return this.initPromise; - } - - /** - * Set up periodic cache cleanup - */ - private setupCacheCleanup() { - setInterval(() => { - const now = Date.now(); - for (const [key, data] of this.recentQueriesCache.entries()) { - if (now - data.timestamp > this.cacheExpiryMs) { - this.recentQueriesCache.delete(key); - } - } - }, 60000); // Run cleanup every minute - } - - /** - * Generate search queries to find relevant information for the user question - * @param userQuestion - The user's question - * @param llmService - The LLM service to use for generating queries - * @returns Array of search queries - */ - async generateSearchQueries(userQuestion: string, llmService: any): Promise { - try { - const messages: Message[] = [ - { role: "system", content: this.metaPrompt }, - { role: "user", content: userQuestion } - ]; - - const options = { - temperature: 0.3, - maxTokens: 300 - }; - - // Get the response from the LLM using the correct method name - const response = await llmService.generateChatCompletion(messages, options); - const responseText = response.text; // Extract the text from the response object - - try { - // Remove code blocks, quotes, and clean up the response text - let jsonStr = responseText - .replace(/```(?:json)?|```/g, '') // Remove code block markers - .replace(/[\u201C\u201D]/g, '"') // Replace smart quotes with straight quotes - .trim(); - - // Check if the text might contain a JSON array (has square brackets) - if (jsonStr.includes('[') && jsonStr.includes(']')) { - // Extract just the array part if there's explanatory text - const arrayMatch = jsonStr.match(/\[[\s\S]*\]/); - if (arrayMatch) { - jsonStr = arrayMatch[0]; - } - - // Try to parse the JSON - try { - const queries = JSON.parse(jsonStr); - if (Array.isArray(queries) && queries.length > 0) { - return queries.map(q => typeof q === 'string' ? q : String(q)).filter(Boolean); - } - } catch (innerError) { - // If parsing fails, log it and continue to the fallback - log.info(`JSON parse error: ${innerError}. Will use fallback parsing for: ${jsonStr}`); - } - } - - // Fallback 1: Try to extract an array manually by splitting on commas between quotes - if (jsonStr.includes('[') && jsonStr.includes(']')) { - const arrayContent = jsonStr.substring( - jsonStr.indexOf('[') + 1, - jsonStr.lastIndexOf(']') - ); - - // Use regex to match quoted strings, handling escaped quotes - const stringMatches = arrayContent.match(/"((?:\\.|[^"\\])*)"/g); - if (stringMatches && stringMatches.length > 0) { - return stringMatches - .map((m: string) => m.substring(1, m.length - 1)) // Remove surrounding quotes - .filter((s: string) => s.length > 0); - } - } - - // Fallback 2: Extract queries line by line - const lines = responseText.split('\n') - .map((line: string) => line.trim()) - .filter((line: string) => - line.length > 0 && - !line.startsWith('```') && - !line.match(/^\d+\.?\s*$/) && // Skip numbered list markers alone - !line.match(/^\[|\]$/) // Skip lines that are just brackets - ); - - if (lines.length > 0) { - // Remove numbering, quotes and other list markers from each line - return lines.map((line: string) => { - return line - .replace(/^\d+\.?\s*/, '') // Remove numbered list markers (1., 2., etc) - .replace(/^[-*•]\s*/, '') // Remove bullet list markers - .replace(/^["']|["']$/g, '') // Remove surrounding quotes - .trim(); - }).filter((s: string) => s.length > 0); - } - } catch (parseError) { - log.error(`Error parsing search queries: ${parseError}`); - } - - // If all else fails, just use the original question - return [userQuestion]; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error(`Error generating search queries: ${errorMessage}`); - // Fallback to just using the original question - return [userQuestion]; - } - } - - /** - * Find relevant notes using multiple search queries - * @param queries - Array of search queries - * @param contextNoteId - Optional note ID to restrict search to a branch - * @param limit - Max notes to return - * @returns Array of relevant notes - */ - async findRelevantNotesMultiQuery( - queries: string[], - contextNoteId: string | null = null, - limit = 10 - ): Promise { - if (!this.initialized) { - await this.initialize(); - } - - try { - // Cache key combining all queries - const cacheKey = JSON.stringify({ queries, contextNoteId, limit }); - - // Check if we have a recent cache hit - const cached = this.recentQueriesCache.get(cacheKey); - if (cached) { - return cached.relevantNotes; - } - - // Array to store all results with their similarity scores - const allResults: { - noteId: string, - title: string, - content: string | null, - similarity: number, - branchId?: string - }[] = []; - - // Set to keep track of note IDs we've seen to avoid duplicates - const seenNoteIds = new Set(); - - // Log the provider and model being used - log.info(`Searching with embedding provider: ${this.provider.name}, model: ${this.provider.getConfig().model}`); - - // Process each query - for (const query of queries) { - // Get embeddings for this query using the correct method name - const queryEmbedding = await this.provider.generateEmbeddings(query); - log.info(`Generated embedding for query: "${query}" (${queryEmbedding.length} dimensions)`); - - // Find notes similar to this query - let results; - if (contextNoteId) { - // Find within a specific context/branch - results = await this.findNotesInBranch( - queryEmbedding, - contextNoteId, - Math.min(limit, 5) // Limit per query - ); - log.info(`Found ${results.length} notes within branch context for query: "${query}"`); - } else { - // Search all notes - results = await vectorStore.findSimilarNotes( - queryEmbedding, - this.provider.name, // Use name property instead of id - this.provider.getConfig().model, // Use getConfig().model instead of modelId - Math.min(limit, 5), // Limit per query - 0.5 // Lower threshold to get more diverse results - ); - log.info(`Found ${results.length} notes in vector store for query: "${query}"`); - } - - // Process results - for (const result of results) { - if (!seenNoteIds.has(result.noteId)) { - seenNoteIds.add(result.noteId); - - // Get the note from Becca - const note = becca.notes[result.noteId]; - if (!note) continue; - - // Add to our results - allResults.push({ - noteId: result.noteId, - title: note.title, - content: note.type === 'text' ? note.getContent() as string : null, - similarity: result.similarity, - branchId: note.getBranches()[0]?.branchId - }); - } - } - } - - // Sort by similarity and take the top 'limit' results - const sortedResults = allResults - .sort((a, b) => b.similarity - a.similarity) - .slice(0, limit); - - log.info(`Total unique relevant notes found across all queries: ${sortedResults.length}`); - - // Cache the results - this.recentQueriesCache.set(cacheKey, { - timestamp: Date.now(), - relevantNotes: sortedResults - }); - - return sortedResults; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error(`Error finding relevant notes: ${errorMessage}`); - return []; - } - } - - /** - * Find notes in a specific branch/context - * @param embedding - Query embedding - * @param contextNoteId - Note ID to restrict search to - * @param limit - Max notes to return - * @returns Array of relevant notes - */ - private async findNotesInBranch( - embedding: Float32Array, - contextNoteId: string, - limit = 5 - ): Promise<{noteId: string, similarity: number}[]> { - try { - // Get the subtree note IDs - const subtreeNoteIds = await this.getSubtreeNoteIds(contextNoteId); - - if (subtreeNoteIds.length === 0) { - return []; - } - - // Get all embeddings for these notes using vectorStore instead of direct SQL - const similarities: {noteId: string, similarity: number}[] = []; - - for (const noteId of subtreeNoteIds) { - const noteEmbedding = await vectorStore.getEmbeddingForNote( - noteId, - this.provider.name, // Use name property instead of id - this.provider.getConfig().model // Use getConfig().model instead of modelId - ); - - if (noteEmbedding) { - const similarity = cosineSimilarity(embedding, noteEmbedding.embedding); - if (similarity > 0.5) { // Apply similarity threshold - similarities.push({ - noteId, - similarity - }); - } - } - } - - // Sort by similarity and return top results - return similarities - .sort((a, b) => b.similarity - a.similarity) - .slice(0, limit); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error(`Error finding notes in branch: ${errorMessage}`); - return []; - } - } - - /** - * Get all note IDs in a subtree (including the root note) - * @param rootNoteId - Root note ID - * @returns Array of note IDs - */ - private async getSubtreeNoteIds(rootNoteId: string): Promise { - const note = becca.notes[rootNoteId]; - if (!note) { - return []; - } - - // Use becca to walk the note tree instead of direct SQL - const noteIds = new Set([rootNoteId]); - - // Helper function to collect all children - const collectChildNotes = (noteId: string) => { - // Use becca.getNote(noteId).getChildNotes() to get child notes - const parentNote = becca.notes[noteId]; - if (!parentNote) return; - - // Get all branches where this note is the parent - for (const branch of Object.values(becca.branches)) { - if (branch.parentNoteId === noteId && !branch.isDeleted) { - const childNoteId = branch.noteId; - if (!noteIds.has(childNoteId)) { - noteIds.add(childNoteId); - // Recursively collect children of this child - collectChildNotes(childNoteId); - } - } - } - }; - - // Start collecting from the root - collectChildNotes(rootNoteId); - - return Array.from(noteIds); - } - - /** - * Build context string from retrieved notes - */ - async buildContextFromNotes(sources: any[], query: string): Promise { - if (!sources || sources.length === 0) { - // Return a default context instead of empty string - return "I am an AI assistant helping you with your Trilium notes. " + - "I couldn't find any specific notes related to your query, but I'll try to assist you " + - "with general knowledge about Trilium or other topics you're interested in."; - } - - // Get provider name to adjust context for different models - const providerId = this.provider?.name || 'default'; - - // Import the constants dynamically to avoid circular dependencies - const { LLM_CONSTANTS } = await import('../../routes/api/llm.js'); - - // Get appropriate context size and format based on provider - const maxTotalLength = - providerId === 'openai' ? LLM_CONSTANTS.CONTEXT_WINDOW.OPENAI : - providerId === 'anthropic' ? LLM_CONSTANTS.CONTEXT_WINDOW.ANTHROPIC : - providerId === 'ollama' ? LLM_CONSTANTS.CONTEXT_WINDOW.OLLAMA : - LLM_CONSTANTS.CONTEXT_WINDOW.DEFAULT; - - // Use a format appropriate for the model family - // Anthropic has a specific system message format that works better with certain structures - const isAnthropicFormat = providerId === 'anthropic'; - - // Start with different headers based on provider - let context = isAnthropicFormat - ? `I'm your AI assistant helping with your Trilium notes database. For your query: "${query}", I found these relevant notes:\n\n` - : `I've found some relevant information in your notes that may help answer: "${query}"\n\n`; - - // Sort sources by similarity if available to prioritize most relevant - if (sources[0] && sources[0].similarity !== undefined) { - sources = [...sources].sort((a, b) => (b.similarity || 0) - (a.similarity || 0)); - } - - // Track total context length to avoid oversized context - let currentLength = context.length; - const maxNoteContentLength = Math.min(LLM_CONSTANTS.CONTENT.MAX_NOTE_CONTENT_LENGTH, - Math.floor(maxTotalLength / Math.max(1, sources.length))); - - sources.forEach((source) => { - // Check if adding this source would exceed our total limit - if (currentLength >= maxTotalLength) return; - - // Build source section with formatting appropriate for the provider - let sourceSection = `### ${source.title}\n`; - - // Add relationship context if available - if (source.parentTitle) { - sourceSection += `Part of: ${source.parentTitle}\n`; - } - - // Add attributes if available (for better context) - if (source.noteId) { - const note = becca.notes[source.noteId]; - if (note) { - const labels = note.getLabels(); - if (labels.length > 0) { - sourceSection += `Labels: ${labels.map(l => `#${l.name}${l.value ? '=' + l.value : ''}`).join(' ')}\n`; - } - } - } - - if (source.content) { - // Clean up HTML content before adding it to the context - let cleanContent = this.sanitizeNoteContent(source.content, source.type, source.mime); - - // Truncate content if it's too long - if (cleanContent.length > maxNoteContentLength) { - cleanContent = cleanContent.substring(0, maxNoteContentLength) + " [content truncated due to length]"; - } - - sourceSection += `${cleanContent}\n`; - } else { - sourceSection += "[This note doesn't contain textual content]\n"; - } - - sourceSection += "\n"; - - // Check if adding this section would exceed total length limit - if (currentLength + sourceSection.length <= maxTotalLength) { - context += sourceSection; - currentLength += sourceSection.length; - } - }); - - // Add provider-specific instructions - if (isAnthropicFormat) { - context += "When you refer to any information from these notes, cite the note title explicitly (e.g., \"According to the note [Title]...\"). " + - "If the provided notes don't answer the query fully, acknowledge that and then use your general knowledge to help.\n\n" + - "Be concise but thorough in your responses."; - } else { - context += "When referring to information from these notes in your response, please cite them by their titles " + - "(e.g., \"According to your note on [Title]...\") rather than using labels like \"Note 1\" or \"Note 2\".\n\n" + - "If the information doesn't contain what you need, just say so and use your general knowledge instead."; - } - - return context; - } - - /** - * Sanitize note content for use in context, removing HTML tags - */ - private sanitizeNoteContent(content: string, type?: string, mime?: string): string { - if (!content) return ''; - - // If it's likely HTML content - if ( - (type === 'text' && mime === 'text/html') || - content.includes('') || - content.includes(' { - // Replace multiple newlines with a single one - return text.replace(/\n\s*\n/g, '\n\n'); - } - }); - - // Additional cleanup for remaining HTML entities - content = content - .replace(/ /g, ' ') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, "'"); - } - - // Normalize whitespace - content = content.replace(/\s+/g, ' ').trim(); - - return content; - } - - /** - * Process a user query to find relevant context in Trilium notes - */ - async processQuery( - userQuestion: string, - llmService: any, - contextNoteId: string | null = null, - showThinking: boolean = false - ) { - log.info(`Processing query with: question="${userQuestion.substring(0, 50)}...", noteId=${contextNoteId}, showThinking=${showThinking}`); - - if (!this.initialized) { - try { - await this.initialize(); - } catch (error) { - log.error(`Failed to initialize TriliumContextService: ${error}`); - // Return a fallback response if initialization fails - return { - context: "I am an AI assistant helping you with your Trilium notes. " + - "I'll try to assist you with general knowledge about your query.", - notes: [], - queries: [userQuestion] - }; - } - } - - try { - // Step 1: Generate search queries - let searchQueries: string[]; - try { - searchQueries = await this.generateSearchQueries(userQuestion, llmService); - } catch (error) { - log.error(`Error generating search queries, using fallback: ${error}`); - searchQueries = [userQuestion]; // Fallback to using the original question - } - log.info(`Generated search queries: ${JSON.stringify(searchQueries)}`); - - // Step 2: Find relevant notes using those queries - let relevantNotes: any[] = []; - try { - relevantNotes = await this.findRelevantNotesMultiQuery( - searchQueries, - contextNoteId, - 8 // Get more notes since we're using multiple queries - ); - } catch (error) { - log.error(`Error finding relevant notes: ${error}`); - // Continue with empty notes list - } - - // Step 3: Build context from the notes - const context = await this.buildContextFromNotes(relevantNotes, userQuestion); - - // Step 4: Add agent tools context with thinking process if requested - let enhancedContext = context; - try { - // Get agent tools context using either the specific note or the most relevant notes - const agentContext = await this.getAgentToolsContext( - contextNoteId || (relevantNotes[0]?.noteId || ""), - userQuestion, - showThinking, - relevantNotes // Pass all relevant notes for context - ); - - if (agentContext) { - enhancedContext = `${context}\n\n${agentContext}`; - log.info(`Added agent tools context (${agentContext.length} characters)`); - } - } catch (error) { - log.error(`Error getting agent tools context: ${error}`); - // Continue with just the basic context - } - - return { - context: enhancedContext, - notes: relevantNotes, - queries: searchQueries - }; - } catch (error) { - log.error(`Error in processQuery: ${error}`); - // Return a fallback response if anything fails - return { - context: "I am an AI assistant helping you with your Trilium notes. " + - "I encountered an error while processing your query, but I'll try to assist you anyway.", - notes: [], - queries: [userQuestion] - }; - } - } - - /** - * Enhance LLM context with agent tools - * - * This adds context from agent tools such as: - * 1. Vector search results relevant to the query - * 2. Note hierarchy information - * 3. Query decomposition planning - * 4. Contextual thinking visualization - * - * @param noteId The current note being viewed (or most relevant note) - * @param query The user's query - * @param showThinking Whether to include the agent's thinking process - * @param relevantNotes Optional array of relevant notes from vector search - * @returns Enhanced context string - */ - async getAgentToolsContext( - noteId: string, - query: string, - showThinking: boolean = false, - relevantNotes: Array = [] - ): Promise { - log.info(`Getting agent tools context: noteId=${noteId}, query="${query.substring(0, 50)}...", showThinking=${showThinking}, relevantNotesCount=${relevantNotes.length}`); - - try { - const agentTools = aiServiceManager.getAgentTools(); - let context = ""; - - // 1. Get vector search results related to the query - try { - // If we already have relevant notes from vector search, use those - if (relevantNotes && relevantNotes.length > 0) { - log.info(`Using ${relevantNotes.length} provided relevant notes instead of running vector search again`); - context += "## Related Information\n\n"; - - for (const result of relevantNotes.slice(0, 5)) { - context += `### ${result.title}\n`; - // Use the content if available, otherwise get a preview - const contentPreview = result.content - ? this.sanitizeNoteContent(result.content).substring(0, 300) + "..." - : result.contentPreview || "[No preview available]"; - - context += `${contentPreview}\n\n`; - } - context += "\n"; - } else { - // Run vector search if we don't have relevant notes - const vectorSearchTool = agentTools.getVectorSearchTool(); - const searchResults = await vectorSearchTool.searchNotes(query, { - parentNoteId: noteId, - maxResults: 5 - }); - - if (searchResults.length > 0) { - context += "## Related Information\n\n"; - for (const result of searchResults) { - context += `### ${result.title}\n`; - context += `${result.contentPreview}\n\n`; - } - context += "\n"; - } - } - } catch (error: any) { - log.error(`Error getting vector search context: ${error.message}`); - } - - // 2. Get note structure context - try { - const navigatorTool = agentTools.getNoteNavigatorTool(); - const noteContext = navigatorTool.getNoteContextDescription(noteId); - - if (noteContext) { - context += "## Current Note Context\n\n"; - context += noteContext + "\n\n"; - } - } catch (error: any) { - log.error(`Error getting note structure context: ${error.message}`); - } - - // 3. Use query decomposition if it's a complex query - try { - const decompositionTool = agentTools.getQueryDecompositionTool(); - const complexity = decompositionTool.assessQueryComplexity(query); - - if (complexity > 5) { // Only for fairly complex queries - const decomposed = decompositionTool.decomposeQuery(query); - - if (decomposed.subQueries.length > 1) { - context += "## Query Analysis\n\n"; - context += `This is a complex query (complexity: ${complexity}/10). It can be broken down into:\n\n`; - - for (const sq of decomposed.subQueries) { - context += `- ${sq.text}\n Reason: ${sq.reason}\n\n`; - } - } - } - } catch (error: any) { - log.error(`Error decomposing query: ${error.message}`); - } - - // 4. Show thinking process if enabled - if (showThinking) { - log.info("Showing thinking process - creating visual reasoning steps"); - try { - const thinkingTool = agentTools.getContextualThinkingTool(); - const thinkingId = thinkingTool.startThinking(query); - log.info(`Started thinking process with ID: ${thinkingId}`); - - // Add initial thinking steps - thinkingTool.addThinkingStep( - "Analyzing the user's query to understand the information needs", - "observation", - { confidence: 1.0 } - ); - - // Add query exploration steps - const parentId = thinkingTool.addThinkingStep( - "Exploring knowledge base to find relevant information", - "hypothesis", - { confidence: 0.9 } - ); - - // Add information about relevant notes if available - if (relevantNotes && relevantNotes.length > 0) { - const noteTitles = relevantNotes.slice(0, 5).map(n => n.title).join(", "); - thinkingTool.addThinkingStep( - `Found ${relevantNotes.length} potentially relevant notes through semantic search, including: ${noteTitles}`, - "evidence", - { confidence: 0.85, parentId: parentId || undefined } - ); - } - - // Add step about note hierarchy if a specific note is being viewed - if (noteId && noteId !== "") { - try { - const navigatorTool = agentTools.getNoteNavigatorTool(); - - // Get parent notes since we don't have getNoteHierarchyInfo - const parents = navigatorTool.getParentNotes(noteId); - - if (parents && parents.length > 0) { - const parentInfo = parents.map(p => p.title).join(" > "); - thinkingTool.addThinkingStep( - `Identified note hierarchy context: ${parentInfo}`, - "evidence", - { confidence: 0.9, parentId: parentId || undefined } - ); - } - } catch (error) { - log.error(`Error getting note hierarchy: ${error}`); - } - } - - // Add query decomposition if it's a complex query - try { - const decompositionTool = agentTools.getQueryDecompositionTool(); - const complexity = decompositionTool.assessQueryComplexity(query); - - if (complexity > 4) { - thinkingTool.addThinkingStep( - `This is a ${complexity > 7 ? "very complex" : "moderately complex"} query (complexity: ${complexity}/10)`, - "observation", - { confidence: 0.8 } - ); - - const decomposed = decompositionTool.decomposeQuery(query); - if (decomposed.subQueries.length > 1) { - const decompId = thinkingTool.addThinkingStep( - "Breaking down query into sub-questions to address systematically", - "hypothesis", - { confidence: 0.85 } - ); - - for (const sq of decomposed.subQueries) { - thinkingTool.addThinkingStep( - `Subquery: ${sq.text} - ${sq.reason}`, - "evidence", - { confidence: 0.8, parentId: decompId || undefined } - ); - } - } - } else { - thinkingTool.addThinkingStep( - `This is a straightforward query (complexity: ${complexity}/10) that can be addressed directly`, - "observation", - { confidence: 0.9 } - ); - } - } catch (error) { - log.error(`Error in query decomposition: ${error}`); - } - - // Add final conclusions - thinkingTool.addThinkingStep( - "Ready to formulate response based on available information and query understanding", - "conclusion", - { confidence: 0.95 } - ); - - // Complete the thinking process and add the visualization to context - thinkingTool.completeThinking(thinkingId); - const visualization = thinkingTool.visualizeThinking(thinkingId); - - if (visualization) { - context += "## Reasoning Process\n\n"; - context += visualization + "\n\n"; - log.info(`Added thinking visualization to context (${visualization.length} characters)`); - } - } catch (error: any) { - log.error(`Error creating thinking visualization: ${error.message}`); - } - } - - return context; - } catch (error: any) { - log.error(`Error getting agent tools context: ${error.message}`); - return ""; - } - } -} - -export default new TriliumContextService();