mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 18:36:30 +01:00 
			
		
		
		
	fancier (but longer waiting time) messages
This commit is contained in:
		| @@ -4,6 +4,8 @@ import { OpenAIService } from './providers/openai_service.js'; | |||||||
| import { AnthropicService } from './providers/anthropic_service.js'; | import { AnthropicService } from './providers/anthropic_service.js'; | ||||||
| import { OllamaService } from './providers/ollama_service.js'; | import { OllamaService } from './providers/ollama_service.js'; | ||||||
| import log from '../log.js'; | import log from '../log.js'; | ||||||
|  | import contextExtractor from './context_extractor.js'; | ||||||
|  | import semanticContextService from './semantic_context_service.js'; | ||||||
|  |  | ||||||
| type ServiceProviders = 'openai' | 'anthropic' | 'ollama'; | type ServiceProviders = 'openai' | 'anthropic' | 'ollama'; | ||||||
|  |  | ||||||
| @@ -159,6 +161,26 @@ export class AIServiceManager { | |||||||
|         // If we get here, all providers failed |         // If we get here, all providers failed | ||||||
|         throw new Error(`All AI providers failed: ${lastError?.message || 'Unknown error'}`); |         throw new Error(`All AI providers failed: ${lastError?.message || 'Unknown error'}`); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     setupEventListeners() { | ||||||
|  |         // Setup event listeners for AI services | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get the context extractor service | ||||||
|  |      * @returns The context extractor instance | ||||||
|  |      */ | ||||||
|  |     getContextExtractor() { | ||||||
|  |         return contextExtractor; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get the semantic context service for advanced context handling | ||||||
|  |      * @returns The semantic context service instance | ||||||
|  |      */ | ||||||
|  |     getSemanticContextService() { | ||||||
|  |         return semanticContextService; | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| // Don't create singleton immediately, use a lazy-loading pattern | // Don't create singleton immediately, use a lazy-loading pattern | ||||||
| @@ -185,5 +207,12 @@ export default { | |||||||
|     }, |     }, | ||||||
|     async generateChatCompletion(messages: Message[], options: ChatCompletionOptions = {}): Promise<ChatResponse> { |     async generateChatCompletion(messages: Message[], options: ChatCompletionOptions = {}): Promise<ChatResponse> { | ||||||
|         return getInstance().generateChatCompletion(messages, options); |         return getInstance().generateChatCompletion(messages, options); | ||||||
|  |     }, | ||||||
|  |     // Add our new methods | ||||||
|  |     getContextExtractor() { | ||||||
|  |         return getInstance().getContextExtractor(); | ||||||
|  |     }, | ||||||
|  |     getSemanticContextService() { | ||||||
|  |         return getInstance().getSemanticContextService(); | ||||||
|     } |     } | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -152,10 +152,28 @@ export class ChatService { | |||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Add context from the current note to the chat |      * Add context from the current note to the chat | ||||||
|  |      * | ||||||
|  |      * @param sessionId - The ID of the chat session | ||||||
|  |      * @param noteId - The ID of the note to add context from | ||||||
|  |      * @param useSmartContext - Whether to use smart context extraction (default: true) | ||||||
|  |      * @returns The updated chat session | ||||||
|      */ |      */ | ||||||
|     async addNoteContext(sessionId: string, noteId: string): Promise<ChatSession> { |     async addNoteContext(sessionId: string, noteId: string, useSmartContext = true): Promise<ChatSession> { | ||||||
|         const session = await this.getOrCreateSession(sessionId); |         const session = await this.getOrCreateSession(sessionId); | ||||||
|         const context = await contextExtractor.getFullContext(noteId); |  | ||||||
|  |         // Get the last user message to use as context for semantic search | ||||||
|  |         const lastUserMessage = [...session.messages].reverse() | ||||||
|  |             .find(msg => msg.role === 'user' && msg.content.length > 10)?.content || ''; | ||||||
|  |  | ||||||
|  |         let context; | ||||||
|  |  | ||||||
|  |         if (useSmartContext && lastUserMessage) { | ||||||
|  |             // Use smart context that considers the query for better relevance | ||||||
|  |             context = await contextExtractor.getSmartContext(noteId, lastUserMessage); | ||||||
|  |         } else { | ||||||
|  |             // Fall back to full context if smart context is disabled or no query available | ||||||
|  |             context = await contextExtractor.getFullContext(noteId); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         const contextMessage: Message = { |         const contextMessage: Message = { | ||||||
|             role: 'user', |             role: 'user', | ||||||
| @@ -168,6 +186,61 @@ export class ChatService { | |||||||
|         return session; |         return session; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Add semantically relevant context from a note based on a specific query | ||||||
|  |      * | ||||||
|  |      * @param sessionId - The ID of the chat session | ||||||
|  |      * @param noteId - The ID of the note to add context from | ||||||
|  |      * @param query - The specific query to find relevant information for | ||||||
|  |      * @returns The updated chat session | ||||||
|  |      */ | ||||||
|  |     async addSemanticNoteContext(sessionId: string, noteId: string, query: string): Promise<ChatSession> { | ||||||
|  |         const session = await this.getOrCreateSession(sessionId); | ||||||
|  |  | ||||||
|  |         // Use semantic context that considers the query for better relevance | ||||||
|  |         const context = await contextExtractor.getSemanticContext(noteId, query); | ||||||
|  |  | ||||||
|  |         const contextMessage: Message = { | ||||||
|  |             role: 'user', | ||||||
|  |             content: `Here is the relevant information from my notes based on my query "${query}":\n\n${context}\n\nPlease help me understand this information in relation to my query.` | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         session.messages.push(contextMessage); | ||||||
|  |         await chatStorageService.updateChat(session.id, session.messages); | ||||||
|  |  | ||||||
|  |         return session; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Send a context-aware message with automatically included semantic context from a note | ||||||
|  |      * This method combines the query with relevant note context before sending to the AI | ||||||
|  |      * | ||||||
|  |      * @param sessionId - The ID of the chat session | ||||||
|  |      * @param content - The user's message content | ||||||
|  |      * @param noteId - The ID of the note to add context from | ||||||
|  |      * @param options - Optional completion options | ||||||
|  |      * @param streamCallback - Optional streaming callback | ||||||
|  |      * @returns The updated chat session | ||||||
|  |      */ | ||||||
|  |     async sendContextAwareMessage( | ||||||
|  |         sessionId: string, | ||||||
|  |         content: string, | ||||||
|  |         noteId: string, | ||||||
|  |         options?: ChatCompletionOptions, | ||||||
|  |         streamCallback?: (content: string, isDone: boolean) => void | ||||||
|  |     ): Promise<ChatSession> { | ||||||
|  |         const session = await this.getOrCreateSession(sessionId); | ||||||
|  |  | ||||||
|  |         // Get semantically relevant context based on the user's message | ||||||
|  |         const context = await contextExtractor.getSmartContext(noteId, content); | ||||||
|  |  | ||||||
|  |         // Combine the user's message with the relevant context | ||||||
|  |         const enhancedContent = `${content}\n\nHere's relevant information from my notes that may help:\n\n${context}`; | ||||||
|  |  | ||||||
|  |         // Send the enhanced message | ||||||
|  |         return this.sendMessage(sessionId, enhancedContent, options, streamCallback); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Get all user's chat sessions |      * Get all user's chat sessions | ||||||
|      */ |      */ | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ import sanitizeHtml from 'sanitize-html'; | |||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Utility class for extracting context from notes to provide to AI models |  * Utility class for extracting context from notes to provide to AI models | ||||||
|  |  * Enhanced with advanced capabilities for handling large notes and specialized content | ||||||
|  */ |  */ | ||||||
| export class ContextExtractor { | export class ContextExtractor { | ||||||
|     /** |     /** | ||||||
| @@ -24,6 +25,158 @@ export class ContextExtractor { | |||||||
|         return this.formatNoteContent(note.content, note.type, note.mime, note.title); |         return this.formatNoteContent(note.content, note.type, note.mime, note.title); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Split a large note into smaller, semantically meaningful chunks | ||||||
|  |      * This is useful for handling large notes that exceed the context window of LLMs | ||||||
|  |      * | ||||||
|  |      * @param noteId - The ID of the note to chunk | ||||||
|  |      * @param maxChunkSize - Maximum size of each chunk in characters | ||||||
|  |      * @returns Array of content chunks, or empty array if note not found | ||||||
|  |      */ | ||||||
|  |     async getChunkedNoteContent(noteId: string, maxChunkSize = 2000): Promise<string[]> { | ||||||
|  |         const content = await this.getNoteContent(noteId); | ||||||
|  |         if (!content) return []; | ||||||
|  |  | ||||||
|  |         // Split into semantic chunks (paragraphs, sections, etc.) | ||||||
|  |         return this.splitContentIntoChunks(content, maxChunkSize); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Split text content into semantically meaningful chunks based on natural boundaries | ||||||
|  |      * like paragraphs, headings, and code blocks | ||||||
|  |      * | ||||||
|  |      * @param content - The text content to split | ||||||
|  |      * @param maxChunkSize - Maximum size of each chunk in characters | ||||||
|  |      * @returns Array of content chunks | ||||||
|  |      */ | ||||||
|  |     private splitContentIntoChunks(content: string, maxChunkSize: number): string[] { | ||||||
|  |         // Look for semantic boundaries (headings, blank lines, etc.) | ||||||
|  |         const headingPattern = /^(#+)\s+(.+)$/gm; | ||||||
|  |         const codeBlockPattern = /```[\s\S]+?```/gm; | ||||||
|  |  | ||||||
|  |         // Replace code blocks with placeholders to avoid splitting inside them | ||||||
|  |         const codeBlocks: string[] = []; | ||||||
|  |         let contentWithPlaceholders = content.replace(codeBlockPattern, (match) => { | ||||||
|  |             const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`; | ||||||
|  |             codeBlocks.push(match); | ||||||
|  |             return placeholder; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Split content at headings and paragraphs | ||||||
|  |         const sections: string[] = []; | ||||||
|  |         let currentSection = ''; | ||||||
|  |  | ||||||
|  |         // First split by headings | ||||||
|  |         const lines = contentWithPlaceholders.split('\n'); | ||||||
|  |         for (const line of lines) { | ||||||
|  |             const isHeading = headingPattern.test(line); | ||||||
|  |             headingPattern.lastIndex = 0; // Reset regex | ||||||
|  |  | ||||||
|  |             // If this is a heading and we already have content, start a new section | ||||||
|  |             if (isHeading && currentSection.trim().length > 0) { | ||||||
|  |                 sections.push(currentSection.trim()); | ||||||
|  |                 currentSection = line; | ||||||
|  |             } else { | ||||||
|  |                 currentSection += (currentSection ? '\n' : '') + line; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Add the last section if there's any content | ||||||
|  |         if (currentSection.trim().length > 0) { | ||||||
|  |             sections.push(currentSection.trim()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Now combine smaller sections to respect maxChunkSize | ||||||
|  |         const chunks: string[] = []; | ||||||
|  |         let currentChunk = ''; | ||||||
|  |  | ||||||
|  |         for (const section of sections) { | ||||||
|  |             // If adding this section exceeds maxChunkSize and we already have content, | ||||||
|  |             // finalize the current chunk and start a new one | ||||||
|  |             if ((currentChunk + section).length > maxChunkSize && currentChunk.length > 0) { | ||||||
|  |                 chunks.push(currentChunk); | ||||||
|  |                 currentChunk = section; | ||||||
|  |             } else { | ||||||
|  |                 currentChunk += (currentChunk ? '\n\n' : '') + section; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Add the last chunk if there's any content | ||||||
|  |         if (currentChunk.length > 0) { | ||||||
|  |             chunks.push(currentChunk); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Restore code blocks in all chunks | ||||||
|  |         return chunks.map(chunk => { | ||||||
|  |             return chunk.replace(/__CODE_BLOCK_(\d+)__/g, (_, index) => { | ||||||
|  |                 return codeBlocks[parseInt(index)]; | ||||||
|  |             }); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Generate a summary of a note's content | ||||||
|  |      * Useful for providing a condensed version of very large notes | ||||||
|  |      * | ||||||
|  |      * @param noteId - The ID of the note to summarize | ||||||
|  |      * @param maxLength - Cut-off length to trigger summarization | ||||||
|  |      * @returns Summary of the note or the original content if small enough | ||||||
|  |      */ | ||||||
|  |     async getNoteSummary(noteId: string, maxLength = 5000): Promise<string> { | ||||||
|  |         const content = await this.getNoteContent(noteId); | ||||||
|  |         if (!content || content.length < maxLength) return content || ''; | ||||||
|  |  | ||||||
|  |         // For larger content, generate a summary | ||||||
|  |         return this.summarizeContent(content); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Summarize content by extracting key information | ||||||
|  |      * This uses a heuristic approach to find important sentences and paragraphs | ||||||
|  |      * | ||||||
|  |      * @param content - The content to summarize | ||||||
|  |      * @returns A summarized version of the content | ||||||
|  |      */ | ||||||
|  |     private summarizeContent(content: string): string { | ||||||
|  |         // Extract title/heading if present | ||||||
|  |         const titleMatch = content.match(/^# (.+)$/m); | ||||||
|  |         const title = titleMatch ? titleMatch[1] : 'Untitled Note'; | ||||||
|  |  | ||||||
|  |         // Extract all headings for an outline | ||||||
|  |         const headings: string[] = []; | ||||||
|  |         const headingMatches = content.matchAll(/^(#+)\s+(.+)$/gm); | ||||||
|  |         for (const match of headingMatches) { | ||||||
|  |             const level = match[1].length; | ||||||
|  |             const text = match[2]; | ||||||
|  |             headings.push(`${'  '.repeat(level-1)}- ${text}`); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Extract first sentence of each paragraph for a summary | ||||||
|  |         const paragraphs = content.split(/\n\s*\n/); | ||||||
|  |         const firstSentences = paragraphs | ||||||
|  |             .filter(p => p.trim().length > 0 && !p.trim().startsWith('#') && !p.trim().startsWith('```')) | ||||||
|  |             .map(p => { | ||||||
|  |                 const sentenceMatch = p.match(/^[^.!?]+[.!?]/); | ||||||
|  |                 return sentenceMatch ? sentenceMatch[0].trim() : p.substring(0, Math.min(150, p.length)).trim() + '...'; | ||||||
|  |             }) | ||||||
|  |             .slice(0, 5); // Limit to 5 sentences | ||||||
|  |  | ||||||
|  |         // Create the summary | ||||||
|  |         let summary = `# Summary of: ${title}\n\n`; | ||||||
|  |  | ||||||
|  |         if (headings.length > 0) { | ||||||
|  |             summary += `## Document Outline\n${headings.join('\n')}\n\n`; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (firstSentences.length > 0) { | ||||||
|  |             summary += `## Key Points\n${firstSentences.map(s => `- ${s}`).join('\n')}\n\n`; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         summary += `(Note: This is an automatically generated summary of a larger document with ${content.length} characters)`; | ||||||
|  |  | ||||||
|  |         return summary; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Get a set of parent notes to provide hierarchical context |      * Get a set of parent notes to provide hierarchical context | ||||||
|      */ |      */ | ||||||
| @@ -89,6 +242,7 @@ export class ContextExtractor { | |||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Format the content of a note based on its type |      * Format the content of a note based on its type | ||||||
|  |      * Enhanced with better handling for large and specialized content types | ||||||
|      */ |      */ | ||||||
|     private formatNoteContent(content: string, type: string, mime: string, title: string): string { |     private formatNoteContent(content: string, type: string, mime: string, title: string): string { | ||||||
|         let formattedContent = `# ${title}\n\n`; |         let formattedContent = `# ${title}\n\n`; | ||||||
| @@ -98,10 +252,19 @@ export class ContextExtractor { | |||||||
|                 // Remove HTML formatting for text notes |                 // Remove HTML formatting for text notes | ||||||
|                 formattedContent += this.sanitizeHtml(content); |                 formattedContent += this.sanitizeHtml(content); | ||||||
|                 break; |                 break; | ||||||
|  |  | ||||||
|             case 'code': |             case 'code': | ||||||
|                 // Format code notes with code blocks |                 // Improved code handling with language detection | ||||||
|                 formattedContent += '```\n' + content + '\n```'; |                 const codeLanguage = this.detectCodeLanguage(content, mime); | ||||||
|  |  | ||||||
|  |                 // For large code files, extract structure rather than full content | ||||||
|  |                 if (content.length > 8000) { | ||||||
|  |                     formattedContent += this.extractCodeStructure(content, codeLanguage); | ||||||
|  |                 } else { | ||||||
|  |                     formattedContent += `\`\`\`${codeLanguage}\n${content}\n\`\`\``; | ||||||
|  |                 } | ||||||
|                 break; |                 break; | ||||||
|  |  | ||||||
|             case 'canvas': |             case 'canvas': | ||||||
|                 if (mime === 'application/json') { |                 if (mime === 'application/json') { | ||||||
|                     try { |                     try { | ||||||
| @@ -249,6 +412,230 @@ export class ContextExtractor { | |||||||
|         return formattedContent; |         return formattedContent; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Detect the programming language of code content | ||||||
|  |      * | ||||||
|  |      * @param content - The code content to analyze | ||||||
|  |      * @param mime - MIME type (if available) | ||||||
|  |      * @returns The detected language or empty string | ||||||
|  |      */ | ||||||
|  |     private detectCodeLanguage(content: string, mime: string): string { | ||||||
|  |         // First check if mime type provides a hint | ||||||
|  |         if (mime) { | ||||||
|  |             const mimeMap: Record<string, string> = { | ||||||
|  |                 'text/x-python': 'python', | ||||||
|  |                 'text/javascript': 'javascript', | ||||||
|  |                 'application/javascript': 'javascript', | ||||||
|  |                 'text/typescript': 'typescript', | ||||||
|  |                 'application/typescript': 'typescript', | ||||||
|  |                 'text/x-java': 'java', | ||||||
|  |                 'text/html': 'html', | ||||||
|  |                 'text/css': 'css', | ||||||
|  |                 'text/x-c': 'c', | ||||||
|  |                 'text/x-c++': 'cpp', | ||||||
|  |                 'text/x-csharp': 'csharp', | ||||||
|  |                 'text/x-go': 'go', | ||||||
|  |                 'text/x-ruby': 'ruby', | ||||||
|  |                 'text/x-php': 'php', | ||||||
|  |                 'text/x-swift': 'swift', | ||||||
|  |                 'text/x-rust': 'rust', | ||||||
|  |                 'text/markdown': 'markdown', | ||||||
|  |                 'text/x-sql': 'sql', | ||||||
|  |                 'text/x-yaml': 'yaml', | ||||||
|  |                 'application/json': 'json', | ||||||
|  |                 'text/x-shell': 'bash' | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             for (const [mimePattern, language] of Object.entries(mimeMap)) { | ||||||
|  |                 if (mime.includes(mimePattern)) { | ||||||
|  |                     return language; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Check for common language patterns in the content | ||||||
|  |         const firstLines = content.split('\n', 20).join('\n'); | ||||||
|  |  | ||||||
|  |         const languagePatterns: Record<string, RegExp> = { | ||||||
|  |             'python': /^(import\s+|from\s+\w+\s+import|def\s+\w+\s*\(|class\s+\w+\s*:)/m, | ||||||
|  |             'javascript': /^(const\s+\w+\s*=|let\s+\w+\s*=|var\s+\w+\s*=|function\s+\w+\s*\(|import\s+.*from\s+)/m, | ||||||
|  |             'typescript': /^(interface\s+\w+|type\s+\w+\s*=|class\s+\w+\s*{)/m, | ||||||
|  |             'html': /^<!DOCTYPE html>|<html>|<head>|<body>/m, | ||||||
|  |             'css': /^(\.\w+\s*{|\#\w+\s*{|@media|@import)/m, | ||||||
|  |             'java': /^(public\s+class|import\s+java|package\s+)/m, | ||||||
|  |             'cpp': /^(#include\s+<\w+>|namespace\s+\w+|void\s+\w+\s*\()/m, | ||||||
|  |             'csharp': /^(using\s+System|namespace\s+\w+|public\s+class)/m, | ||||||
|  |             'go': /^(package\s+\w+|import\s+\(|func\s+\w+\s*\()/m, | ||||||
|  |             'ruby': /^(require\s+|class\s+\w+\s*<|def\s+\w+)/m, | ||||||
|  |             'php': /^(<\?php|namespace\s+\w+|use\s+\w+)/m, | ||||||
|  |             'sql': /^(SELECT|INSERT|UPDATE|DELETE|CREATE TABLE|ALTER TABLE)/im, | ||||||
|  |             'bash': /^(#!\/bin\/sh|#!\/bin\/bash|function\s+\w+\s*\(\))/m, | ||||||
|  |             'markdown': /^(#\s+|##\s+|###\s+|\*\s+|-\s+|>\s+)/m, | ||||||
|  |             'json': /^({[\s\n]*"|[\s\n]*\[)/m, | ||||||
|  |             'yaml': /^(---|\w+:\s+)/m | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         for (const [language, pattern] of Object.entries(languagePatterns)) { | ||||||
|  |             if (pattern.test(firstLines)) { | ||||||
|  |                 return language; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Default to empty string if we can't detect the language | ||||||
|  |         return ''; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Extract the structure of a code file rather than its full content | ||||||
|  |      * Useful for providing high-level understanding of large code files | ||||||
|  |      * | ||||||
|  |      * @param content - The full code content | ||||||
|  |      * @param language - The programming language | ||||||
|  |      * @returns A structured representation of the code | ||||||
|  |      */ | ||||||
|  |     private extractCodeStructure(content: string, language: string): string { | ||||||
|  |         const lines = content.split('\n'); | ||||||
|  |         const maxLines = 8000; | ||||||
|  |  | ||||||
|  |         // If it's not that much over the limit, just include the whole thing | ||||||
|  |         if (lines.length <= maxLines * 1.2) { | ||||||
|  |             return `\`\`\`${language}\n${content}\n\`\`\``; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // For large files, extract important structural elements based on language | ||||||
|  |         let extractedStructure = ''; | ||||||
|  |         let importSection = ''; | ||||||
|  |         let classDefinitions = []; | ||||||
|  |         let functionDefinitions = []; | ||||||
|  |         let otherImportantLines = []; | ||||||
|  |  | ||||||
|  |         // Extract imports/includes, class/function definitions based on language | ||||||
|  |         if (['javascript', 'typescript', 'python', 'java', 'csharp'].includes(language)) { | ||||||
|  |             // Find imports | ||||||
|  |             for (let i = 0; i < Math.min(100, lines.length); i++) { | ||||||
|  |                 if (lines[i].match(/^(import|from|using|require|#include|package)\s+/)) { | ||||||
|  |                     importSection += lines[i] + '\n'; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Find class definitions | ||||||
|  |             for (let i = 0; i < lines.length; i++) { | ||||||
|  |                 if (lines[i].match(/^(class|interface|type)\s+\w+/)) { | ||||||
|  |                     const endBracketLine = this.findMatchingEnd(lines, i, language); | ||||||
|  |                     if (endBracketLine > i && endBracketLine <= i + 10) { | ||||||
|  |                         // Include small class definitions entirely | ||||||
|  |                         classDefinitions.push(lines.slice(i, endBracketLine + 1).join('\n')); | ||||||
|  |                         i = endBracketLine; | ||||||
|  |                     } else { | ||||||
|  |                         // For larger classes, just show the definition and methods | ||||||
|  |                         let className = lines[i]; | ||||||
|  |                         classDefinitions.push(className); | ||||||
|  |  | ||||||
|  |                         // Look for methods in this class | ||||||
|  |                         for (let j = i + 1; j < Math.min(endBracketLine, lines.length); j++) { | ||||||
|  |                             if (lines[j].match(/^\s+(function|def|public|private|protected)\s+\w+/)) { | ||||||
|  |                                 classDefinitions.push('  ' + lines[j].trim()); | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if (endBracketLine > 0 && endBracketLine < lines.length) { | ||||||
|  |                             i = endBracketLine; | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Find function definitions not inside classes | ||||||
|  |             for (let i = 0; i < lines.length; i++) { | ||||||
|  |                 if (lines[i].match(/^(function|def|const\s+\w+\s*=\s*\(|let\s+\w+\s*=\s*\(|var\s+\w+\s*=\s*\()/)) { | ||||||
|  |                     functionDefinitions.push(lines[i]); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Build the extracted structure | ||||||
|  |         extractedStructure += `# Code Structure (${lines.length} lines total)\n\n`; | ||||||
|  |  | ||||||
|  |         if (importSection) { | ||||||
|  |             extractedStructure += "## Imports/Dependencies\n```" + language + "\n" + importSection + "```\n\n"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (classDefinitions.length > 0) { | ||||||
|  |             extractedStructure += "## Classes/Interfaces\n```" + language + "\n" + classDefinitions.join('\n\n') + "\n```\n\n"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (functionDefinitions.length > 0) { | ||||||
|  |             extractedStructure += "## Functions\n```" + language + "\n" + functionDefinitions.join('\n\n') + "\n```\n\n"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Add beginning and end of the file for context | ||||||
|  |         extractedStructure += "## Beginning of File\n```" + language + "\n" + | ||||||
|  |             lines.slice(0, Math.min(50, lines.length)).join('\n') + "\n```\n\n"; | ||||||
|  |  | ||||||
|  |         if (lines.length > 100) { | ||||||
|  |             extractedStructure += "## End of File\n```" + language + "\n" + | ||||||
|  |                 lines.slice(Math.max(0, lines.length - 50)).join('\n') + "\n```\n\n"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return extractedStructure; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Find the line number of the matching ending bracket/block | ||||||
|  |      * | ||||||
|  |      * @param lines - Array of code lines | ||||||
|  |      * @param startLine - Starting line number | ||||||
|  |      * @param language - Programming language | ||||||
|  |      * @returns The line number of the matching end, or -1 if not found | ||||||
|  |      */ | ||||||
|  |     private findMatchingEnd(lines: string[], startLine: number, language: string): number { | ||||||
|  |         let depth = 0; | ||||||
|  |         let inClass = false; | ||||||
|  |  | ||||||
|  |         // Different languages have different ways to define blocks | ||||||
|  |         if (['javascript', 'typescript', 'java', 'csharp', 'cpp'].includes(language)) { | ||||||
|  |             // Curly brace languages | ||||||
|  |             for (let i = startLine; i < lines.length; i++) { | ||||||
|  |                 const line = lines[i]; | ||||||
|  |                 // Count opening braces | ||||||
|  |                 for (const char of line) { | ||||||
|  |                     if (char === '{') depth++; | ||||||
|  |                     if (char === '}') { | ||||||
|  |                         depth--; | ||||||
|  |                         if (depth === 0 && inClass) return i; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Check if this line contains the class declaration | ||||||
|  |                 if (i === startLine && line.includes('{')) { | ||||||
|  |                     inClass = true; | ||||||
|  |                 } else if (i === startLine) { | ||||||
|  |                     // If the first line doesn't have an opening brace, look at the next few lines | ||||||
|  |                     if (i + 1 < lines.length && lines[i + 1].includes('{')) { | ||||||
|  |                         inClass = true; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } else if (language === 'python') { | ||||||
|  |             // Indentation-based language | ||||||
|  |             const baseIndentation = lines[startLine].match(/^\s*/)?.[0].length || 0; | ||||||
|  |  | ||||||
|  |             for (let i = startLine + 1; i < lines.length; i++) { | ||||||
|  |                 // Skip empty lines | ||||||
|  |                 if (lines[i].trim() === '') continue; | ||||||
|  |  | ||||||
|  |                 const currentIndentation = lines[i].match(/^\s*/)?.[0].length || 0; | ||||||
|  |  | ||||||
|  |                 // If we're back to the same or lower indentation level, we've reached the end | ||||||
|  |                 if (currentIndentation <= baseIndentation) { | ||||||
|  |                     return i - 1; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return -1; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Sanitize HTML content to plain text |      * Sanitize HTML content to plain text | ||||||
|      */ |      */ | ||||||
| @@ -328,6 +715,88 @@ export class ContextExtractor { | |||||||
|             linkedContext |             linkedContext | ||||||
|         ].filter(Boolean).join('\n\n'); |         ].filter(Boolean).join('\n\n'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get semantically ranked context based on semantic similarity to a query | ||||||
|  |      * This method delegates to the semantic context service for the actual ranking | ||||||
|  |      * | ||||||
|  |      * @param noteId - The ID of the current note | ||||||
|  |      * @param query - The user's query to compare against | ||||||
|  |      * @param maxResults - Maximum number of related notes to include | ||||||
|  |      * @returns Context with the most semantically relevant related notes | ||||||
|  |      */ | ||||||
|  |     async getSemanticContext(noteId: string, query: string, maxResults = 5): Promise<string> { | ||||||
|  |         try { | ||||||
|  |             // This requires the semantic context service to be available | ||||||
|  |             // We're using a dynamic import to avoid circular dependencies | ||||||
|  |             const { default: aiServiceManager } = await import('./ai_service_manager.js'); | ||||||
|  |             const semanticContext = aiServiceManager.getInstance().getSemanticContextService(); | ||||||
|  |  | ||||||
|  |             if (!semanticContext) { | ||||||
|  |                 return this.getFullContext(noteId); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return await semanticContext.getSemanticContext(noteId, query, maxResults); | ||||||
|  |         } catch (error) { | ||||||
|  |             // Fall back to regular context if semantic ranking fails | ||||||
|  |             console.error('Error in semantic context ranking:', error); | ||||||
|  |             return this.getFullContext(noteId); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get progressively loaded context based on depth level | ||||||
|  |      * This provides different levels of context detail depending on the depth parameter | ||||||
|  |      * | ||||||
|  |      * @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<string> { | ||||||
|  |         try { | ||||||
|  |             // This requires the semantic context service to be available | ||||||
|  |             // We're using a dynamic import to avoid circular dependencies | ||||||
|  |             const { default: aiServiceManager } = await import('./ai_service_manager.js'); | ||||||
|  |             const semanticContext = aiServiceManager.getInstance().getSemanticContextService(); | ||||||
|  |  | ||||||
|  |             if (!semanticContext) { | ||||||
|  |                 return this.getFullContext(noteId); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return await semanticContext.getProgressiveContext(noteId, depth); | ||||||
|  |         } catch (error) { | ||||||
|  |             // Fall back to regular context if progressive loading fails | ||||||
|  |             console.error('Error in progressive context loading:', error); | ||||||
|  |             return this.getFullContext(noteId); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Get smart context based on the query complexity | ||||||
|  |      * This automatically selects the appropriate context depth and relevance | ||||||
|  |      * | ||||||
|  |      * @param noteId - The ID of the note to get context for | ||||||
|  |      * @param query - The user's query for semantic relevance matching | ||||||
|  |      * @returns The optimal context for answering the query | ||||||
|  |      */ | ||||||
|  |     async getSmartContext(noteId: string, query: string): Promise<string> { | ||||||
|  |         try { | ||||||
|  |             // This requires the semantic context service to be available | ||||||
|  |             // We're using a dynamic import to avoid circular dependencies | ||||||
|  |             const { default: aiServiceManager } = await import('./ai_service_manager.js'); | ||||||
|  |             const semanticContext = aiServiceManager.getInstance().getSemanticContextService(); | ||||||
|  |  | ||||||
|  |             if (!semanticContext) { | ||||||
|  |                 return this.getFullContext(noteId); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return await semanticContext.getSmartContext(noteId, query); | ||||||
|  |         } catch (error) { | ||||||
|  |             // Fall back to regular context if smart context fails | ||||||
|  |             console.error('Error in smart context selection:', error); | ||||||
|  |             return this.getFullContext(noteId); | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| // Singleton instance | // Singleton instance | ||||||
|   | |||||||
							
								
								
									
										401
									
								
								src/services/llm/semantic_context_service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										401
									
								
								src/services/llm/semantic_context_service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,401 @@ | |||||||
|  | import contextExtractor from './context_extractor.js'; | ||||||
|  | import * as vectorStore from './embeddings/vector_store.js'; | ||||||
|  | import sql from '../sql.js'; | ||||||
|  | import { cosineSimilarity } from './embeddings/vector_store.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 { | ||||||
|  |     /** | ||||||
|  |      * 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<any> { | ||||||
|  |         // 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<Float32Array | null> { | ||||||
|  |         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<Array<{noteId: string, title: string, relevance: number}>> { | ||||||
|  |         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 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<string> { | ||||||
|  |         // 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 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<string> { | ||||||
|  |         // Start with the note content | ||||||
|  |         const noteContent = await 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 contextExtractor.getParentContext(noteId); | ||||||
|  |         if (depth <= 2) return `${parentContext}\n\n${noteContent}`; | ||||||
|  |  | ||||||
|  |         // Add child context for depth >= 3 | ||||||
|  |         const childContext = await contextExtractor.getChildContext(noteId); | ||||||
|  |         if (depth <= 3) return `${parentContext}\n\n${noteContent}\n\n${childContext}`; | ||||||
|  |  | ||||||
|  |         // Add linked notes for depth >= 4 | ||||||
|  |         const linkedContext = await 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<string> { | ||||||
|  |         // 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; | ||||||
		Reference in New Issue
	
	Block a user