mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-07-29 02:52:27 +08:00
refactor "context" services
This commit is contained in:
parent
352204bf78
commit
db4dd6d2ef
@ -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
|
||||
|
144
src/services/llm/README.md
Normal file
144
src/services/llm/README.md
Normal file
@ -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);
|
||||
```
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<any[]>;
|
||||
semanticSearchChunks(query: string, options: any): Promise<any[]>;
|
||||
// Define interface for context service to avoid circular imports
|
||||
interface IContextService {
|
||||
findRelevantNotesMultiQuery(queries: string[], contextNoteId: string | null, limit: number): Promise<any[]>;
|
||||
processQuery(userQuestion: string, llmService: any, contextNoteId: string | null, showThinking: boolean): Promise<any>;
|
||||
}
|
||||
|
||||
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<VectorSearchResult[]> {
|
||||
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<VectorSearchResult[]> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
@ -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<any> = []
|
||||
): Promise<string> {
|
||||
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<any> = []
|
||||
): Promise<string> {
|
||||
return getInstance().getAgentToolsContext(
|
||||
noteId,
|
||||
query,
|
||||
showThinking,
|
||||
relevantNotes
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -467,16 +467,15 @@ export class ContextExtractor {
|
||||
*/
|
||||
static 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
|
||||
// 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<string> {
|
||||
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);
|
||||
|
122
src/services/llm/context/modules/cache_manager.ts
Normal file
122
src/services/llm/context/modules/cache_manager.ts
Normal file
@ -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<string, {
|
||||
timestamp: number,
|
||||
data: any
|
||||
}>();
|
||||
|
||||
// Cache for recently used queries
|
||||
private queryCache = new Map<string, {
|
||||
timestamp: number,
|
||||
results: any
|
||||
}>();
|
||||
|
||||
// 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();
|
164
src/services/llm/context/modules/context_formatter.ts
Normal file
164
src/services/llm/context/modules/context_formatter.ts
Normal file
@ -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<string> {
|
||||
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();
|
368
src/services/llm/context/modules/context_service.ts
Normal file
368
src/services/llm/context/modules/context_service.ts
Normal file
@ -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<void> | null = null;
|
||||
private contextExtractor: ContextExtractor;
|
||||
|
||||
constructor() {
|
||||
this.contextExtractor = new ContextExtractor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the service
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
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<string, any> = 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<any> = []
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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();
|
99
src/services/llm/context/modules/provider_manager.ts
Normal file
99
src/services/llm/context/modules/provider_manager.ts
Normal file
@ -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<any> {
|
||||
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<Float32Array | null> {
|
||||
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();
|
168
src/services/llm/context/modules/query_enhancer.ts
Normal file
168
src/services/llm/context/modules/query_enhancer.ts
Normal file
@ -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<string[]> {
|
||||
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();
|
306
src/services/llm/context/modules/semantic_search.ts
Normal file
306
src/services/llm/context/modules/semantic_search.ts
Normal file
@ -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<Array<{noteId: string, title: string, relevance: number}>> {
|
||||
// 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<string[]> {
|
||||
const noteIds = new Set<string>();
|
||||
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();
|
190
src/services/llm/context_service.ts
Normal file
190
src/services/llm/context_service.ts
Normal file
@ -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<void> {
|
||||
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<any> = []
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
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<any[]> {
|
||||
const allResults: Map<string, any> = 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<string[]> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
return contextService.getSmartContext(noteId, userQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all context caches
|
||||
*/
|
||||
clearCaches(): void {
|
||||
return contextService.clearCaches();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export default new TriliumContextService();
|
@ -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<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 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<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 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<string> {
|
||||
// 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<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;
|
@ -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<void> | null = null;
|
||||
private provider: any = null;
|
||||
|
||||
// Cache for recently used context to avoid repeated embedding lookups
|
||||
private recentQueriesCache = new Map<string, {
|
||||
timestamp: number,
|
||||
relevantNotes: any[]
|
||||
}>();
|
||||
|
||||
// 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<string[]> {
|
||||
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<any[]> {
|
||||
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<string>();
|
||||
|
||||
// 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<string[]> {
|
||||
const note = becca.notes[rootNoteId];
|
||||
if (!note) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use becca to walk the note tree instead of direct SQL
|
||||
const noteIds = new Set<string>([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<string> {
|
||||
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('<div') ||
|
||||
content.includes('<p>') ||
|
||||
content.includes('<span')
|
||||
) {
|
||||
// Use sanitizeHtml to remove all HTML tags
|
||||
content = sanitizeHtml(content, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {},
|
||||
textFilter: (text) => {
|
||||
// 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<any> = []
|
||||
): Promise<string> {
|
||||
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();
|
Loading…
x
Reference in New Issue
Block a user