mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-08-10 18:39:22 +08:00
add some more useful tools
CLOSER.... works?
This commit is contained in:
parent
26b1b08129
commit
7725b924e9
@ -34,6 +34,7 @@ export interface ChatCompletionOptions {
|
||||
stream?: boolean; // Whether to stream the response
|
||||
enableTools?: boolean; // Whether to enable tool calling
|
||||
tools?: any[]; // Tools to provide to the LLM
|
||||
useAdvancedContext?: boolean; // Whether to use advanced context enrichment
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
|
@ -1,9 +1,21 @@
|
||||
import type { Message, ChatCompletionOptions } from './ai_interface.js';
|
||||
import type { Message, ChatCompletionOptions, ChatResponse } from './ai_interface.js';
|
||||
import chatStorageService from './chat_storage_service.js';
|
||||
import log from '../log.js';
|
||||
import { CONTEXT_PROMPTS, ERROR_PROMPTS } from './constants/llm_prompt_constants.js';
|
||||
import { ChatPipeline } from './pipeline/chat_pipeline.js';
|
||||
import type { ChatPipelineConfig, StreamCallback } from './pipeline/interfaces.js';
|
||||
import aiServiceManager from './ai_service_manager.js';
|
||||
import type { ChatPipelineInput } from './pipeline/interfaces.js';
|
||||
|
||||
// Update the ChatCompletionOptions interface to include the missing properties
|
||||
// TODO fix
|
||||
declare module './ai_interface.js' {
|
||||
interface ChatCompletionOptions {
|
||||
pipeline?: string;
|
||||
noteId?: string;
|
||||
useAdvancedContext?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
@ -365,7 +377,7 @@ export class ChatService {
|
||||
const pipeline = this.getPipeline(pipelineType);
|
||||
return pipeline.getMetrics();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reset pipeline metrics
|
||||
*/
|
||||
@ -398,8 +410,62 @@ export class ChatService {
|
||||
// Take first 30 chars if too long
|
||||
return firstLine.substring(0, 27) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a chat completion with a sequence of messages
|
||||
* @param messages Messages array to send to the AI provider
|
||||
* @param options Chat completion options
|
||||
*/
|
||||
async generateChatCompletion(messages: Message[], options: ChatCompletionOptions = {}): Promise<ChatResponse> {
|
||||
log.info(`========== CHAT SERVICE FLOW CHECK ==========`);
|
||||
log.info(`Entered generateChatCompletion in ChatService`);
|
||||
log.info(`Using pipeline for chat completion: ${this.getPipeline(options.pipeline).constructor.name}`);
|
||||
log.info(`Tool support enabled: ${options.enableTools !== false}`);
|
||||
|
||||
try {
|
||||
// Get AI service
|
||||
const service = await aiServiceManager.getService();
|
||||
if (!service) {
|
||||
throw new Error('No AI service available');
|
||||
}
|
||||
|
||||
log.info(`Using AI service: ${service.getName()}`);
|
||||
|
||||
// Prepare query extraction
|
||||
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
|
||||
const query = lastUserMessage ? lastUserMessage.content : undefined;
|
||||
|
||||
// For advanced context processing, use the pipeline
|
||||
if (options.useAdvancedContext && query) {
|
||||
log.info(`Using chat pipeline for advanced context with query: ${query.substring(0, 50)}...`);
|
||||
|
||||
// Create a pipeline input with the query and messages
|
||||
const pipelineInput: ChatPipelineInput = {
|
||||
messages,
|
||||
options,
|
||||
query,
|
||||
noteId: options.noteId
|
||||
};
|
||||
|
||||
// Execute the pipeline
|
||||
const pipeline = this.getPipeline(options.pipeline);
|
||||
const response = await pipeline.execute(pipelineInput);
|
||||
log.info(`Pipeline execution complete, response contains tools: ${response.tool_calls ? 'yes' : 'no'}`);
|
||||
if (response.tool_calls) {
|
||||
log.info(`Tool calls in pipeline response: ${response.tool_calls.length}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// If not using advanced context, use direct service call
|
||||
return await service.generateChatCompletion(messages, options);
|
||||
} catch (error: any) {
|
||||
console.error('Error in generateChatCompletion:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const chatService = new ChatService();
|
||||
export default chatService;
|
||||
export default chatService;
|
||||
|
@ -19,6 +19,13 @@ export interface LLMServiceInterface {
|
||||
stream?: boolean;
|
||||
systemPrompt?: string;
|
||||
}): Promise<ChatResponse>;
|
||||
|
||||
/**
|
||||
* Generate search queries by decomposing a complex query into simpler ones
|
||||
* @param query The original user query to decompose
|
||||
* @returns An array of decomposed search queries
|
||||
*/
|
||||
generateSearchQueries?(query: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { ChatPipelineInput, ChatPipelineConfig, PipelineMetrics, StreamCallback } from './interfaces.js';
|
||||
import type { ChatResponse, StreamChunk } from '../ai_interface.js';
|
||||
import type { ChatResponse, StreamChunk, Message } from '../ai_interface.js';
|
||||
import { ContextExtractionStage } from './stages/context_extraction_stage.js';
|
||||
import { SemanticContextExtractionStage } from './stages/semantic_context_extraction_stage.js';
|
||||
import { AgentToolsContextStage } from './stages/agent_tools_context_stage.js';
|
||||
@ -12,6 +12,7 @@ import { VectorSearchStage } from './stages/vector_search_stage.js';
|
||||
import toolRegistry from '../tools/tool_registry.js';
|
||||
import toolInitializer from '../tools/tool_initializer.js';
|
||||
import log from '../../log.js';
|
||||
import type { LLMServiceInterface } from '../interfaces/agent_tool_interfaces.js';
|
||||
|
||||
/**
|
||||
* Pipeline for managing the entire chat flow
|
||||
@ -80,6 +81,7 @@ export class ChatPipeline {
|
||||
* This is the main entry point that orchestrates all pipeline stages
|
||||
*/
|
||||
async execute(input: ChatPipelineInput): Promise<ChatResponse> {
|
||||
log.info(`========== STARTING CHAT PIPELINE ==========`);
|
||||
log.info(`Executing chat pipeline with ${input.messages.length} messages`);
|
||||
const startTime = Date.now();
|
||||
this.metrics.totalExecutions++;
|
||||
@ -113,89 +115,107 @@ export class ChatPipeline {
|
||||
|
||||
// First, select the appropriate model based on query complexity and content length
|
||||
const modelSelectionStartTime = Date.now();
|
||||
log.info(`========== MODEL SELECTION ==========`);
|
||||
const modelSelection = await this.stages.modelSelection.execute({
|
||||
options: input.options,
|
||||
query: input.query,
|
||||
contentLength
|
||||
});
|
||||
this.updateStageMetrics('modelSelection', modelSelectionStartTime);
|
||||
log.info(`Selected model: ${modelSelection.options.model || 'default'}, enableTools: ${modelSelection.options.enableTools}`);
|
||||
|
||||
// Determine if we should use tools or semantic context
|
||||
const useTools = modelSelection.options.enableTools === true;
|
||||
const useEnhancedContext = input.options?.useAdvancedContext === true;
|
||||
|
||||
// Determine which pipeline flow to use
|
||||
let context: string | undefined;
|
||||
// Early return if we don't have a query or enhanced context is disabled
|
||||
if (!input.query || !useEnhancedContext) {
|
||||
log.info(`========== SIMPLE QUERY MODE ==========`);
|
||||
log.info('Enhanced context disabled or no query provided, skipping context enrichment');
|
||||
|
||||
// For context-aware chats, get the appropriate context
|
||||
if (input.noteId && input.query) {
|
||||
const contextStartTime = Date.now();
|
||||
if (input.showThinking) {
|
||||
// Get enhanced context with agent tools if thinking is enabled
|
||||
const agentContext = await this.stages.agentToolsContext.execute({
|
||||
noteId: input.noteId,
|
||||
query: input.query,
|
||||
showThinking: input.showThinking
|
||||
});
|
||||
context = agentContext.context;
|
||||
this.updateStageMetrics('agentToolsContext', contextStartTime);
|
||||
} else if (!useTools) {
|
||||
// Only get semantic context if tools are NOT enabled
|
||||
// When tools are enabled, we'll let the LLM request context via tools instead
|
||||
log.info('Getting semantic context for note using pipeline stages');
|
||||
|
||||
// First use the vector search stage to find relevant notes
|
||||
const vectorSearchStartTime = Date.now();
|
||||
log.info(`Executing vector search stage for query: "${input.query?.substring(0, 50)}..."`);
|
||||
|
||||
const vectorSearchResult = await this.stages.vectorSearch.execute({
|
||||
query: input.query || '',
|
||||
noteId: input.noteId,
|
||||
options: {
|
||||
maxResults: 10,
|
||||
useEnhancedQueries: true,
|
||||
threshold: 0.6
|
||||
}
|
||||
});
|
||||
|
||||
this.updateStageMetrics('vectorSearch', vectorSearchStartTime);
|
||||
|
||||
log.info(`Vector search found ${vectorSearchResult.searchResults.length} relevant notes`);
|
||||
|
||||
// Then pass to the semantic context stage to build the formatted context
|
||||
const semanticContext = await this.stages.semanticContextExtraction.execute({
|
||||
noteId: input.noteId,
|
||||
query: input.query,
|
||||
messages: input.messages
|
||||
});
|
||||
|
||||
context = semanticContext.context;
|
||||
this.updateStageMetrics('semanticContextExtraction', contextStartTime);
|
||||
} else {
|
||||
log.info('Tools are enabled - using minimal direct context to avoid race conditions');
|
||||
// Get context from current note directly without semantic search
|
||||
if (input.noteId) {
|
||||
try {
|
||||
const contextExtractor = new (await import('../../llm/context/index.js')).ContextExtractor();
|
||||
// Just get the direct content of the current note
|
||||
context = await contextExtractor.extractContext(input.noteId, {
|
||||
includeContent: true,
|
||||
includeParents: true,
|
||||
includeChildren: true,
|
||||
includeLinks: true,
|
||||
includeSimilar: false // Skip semantic search to avoid race conditions
|
||||
});
|
||||
log.info(`Direct context extracted (${context.length} chars) without semantic search`);
|
||||
} catch (error: any) {
|
||||
log.error(`Error extracting direct context: ${error.message}`);
|
||||
context = ""; // Fallback to empty context if extraction fails
|
||||
}
|
||||
} else {
|
||||
context = ""; // No note ID, so no context
|
||||
}
|
||||
}
|
||||
// Prepare messages without additional context
|
||||
const messagePreparationStartTime = Date.now();
|
||||
const preparedMessages = await this.stages.messagePreparation.execute({
|
||||
messages: input.messages,
|
||||
systemPrompt: input.options?.systemPrompt,
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('messagePreparation', messagePreparationStartTime);
|
||||
|
||||
// Generate completion using the LLM
|
||||
const llmStartTime = Date.now();
|
||||
const completion = await this.stages.llmCompletion.execute({
|
||||
messages: preparedMessages.messages,
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('llmCompletion', llmStartTime);
|
||||
|
||||
return completion.response;
|
||||
}
|
||||
|
||||
// Prepare messages with context and system prompt
|
||||
// STAGE 1: Start with the user's query
|
||||
const userQuery = input.query || '';
|
||||
log.info(`========== STAGE 1: USER QUERY ==========`);
|
||||
log.info(`Processing query with: question="${userQuery.substring(0, 50)}...", noteId=${input.noteId}, showThinking=${input.showThinking}`);
|
||||
|
||||
// STAGE 2: Perform query decomposition using the LLM
|
||||
log.info(`========== STAGE 2: QUERY DECOMPOSITION ==========`);
|
||||
log.info('Performing query decomposition to generate effective search queries');
|
||||
const llmService = await this.getLLMService();
|
||||
let searchQueries = [userQuery]; // Default to original query
|
||||
|
||||
if (llmService && llmService.generateSearchQueries) {
|
||||
try {
|
||||
const decompositionResult = await llmService.generateSearchQueries(userQuery);
|
||||
if (decompositionResult && decompositionResult.length > 0) {
|
||||
searchQueries = decompositionResult;
|
||||
log.info(`Generated ${searchQueries.length} search queries: ${JSON.stringify(searchQueries)}`);
|
||||
} else {
|
||||
log.info('Query decomposition returned no results, using original query');
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error(`Error in query decomposition: ${error.message || String(error)}`);
|
||||
}
|
||||
} else {
|
||||
log.info('No LLM service available for query decomposition, using original query');
|
||||
}
|
||||
|
||||
// STAGE 3: Execute vector similarity search with decomposed queries
|
||||
const vectorSearchStartTime = Date.now();
|
||||
log.info(`========== STAGE 3: VECTOR SEARCH ==========`);
|
||||
log.info('Using VectorSearchStage pipeline component to find relevant notes');
|
||||
|
||||
const vectorSearchResult = await this.stages.vectorSearch.execute({
|
||||
query: userQuery,
|
||||
noteId: input.noteId || 'global',
|
||||
options: {
|
||||
maxResults: 5, // Can be adjusted
|
||||
useEnhancedQueries: true,
|
||||
threshold: 0.6,
|
||||
llmService: llmService || undefined
|
||||
}
|
||||
});
|
||||
|
||||
this.updateStageMetrics('vectorSearch', vectorSearchStartTime);
|
||||
|
||||
log.info(`Vector search found ${vectorSearchResult.searchResults.length} relevant notes`);
|
||||
|
||||
// Extract context from search results
|
||||
log.info(`========== SEMANTIC CONTEXT EXTRACTION ==========`);
|
||||
const semanticContextStartTime = Date.now();
|
||||
const semanticContext = await this.stages.semanticContextExtraction.execute({
|
||||
noteId: input.noteId || 'global',
|
||||
query: userQuery,
|
||||
messages: input.messages,
|
||||
searchResults: vectorSearchResult.searchResults
|
||||
});
|
||||
|
||||
const context = semanticContext.context;
|
||||
this.updateStageMetrics('semanticContextExtraction', semanticContextStartTime);
|
||||
log.info(`Extracted semantic context (${context.length} chars)`);
|
||||
|
||||
// STAGE 4: Prepare messages with context and tool definitions for the LLM
|
||||
log.info(`========== STAGE 4: MESSAGE PREPARATION ==========`);
|
||||
const messagePreparationStartTime = Date.now();
|
||||
const preparedMessages = await this.stages.messagePreparation.execute({
|
||||
messages: input.messages,
|
||||
@ -204,9 +224,7 @@ export class ChatPipeline {
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('messagePreparation', messagePreparationStartTime);
|
||||
|
||||
// Generate completion using the LLM
|
||||
const llmStartTime = Date.now();
|
||||
log.info(`Prepared ${preparedMessages.messages.length} messages for LLM, tools enabled: ${useTools}`);
|
||||
|
||||
// Setup streaming handler if streaming is enabled and callback provided
|
||||
const enableStreaming = this.config.enableStreaming &&
|
||||
@ -218,11 +236,15 @@ export class ChatPipeline {
|
||||
modelSelection.options.stream = true;
|
||||
}
|
||||
|
||||
// STAGE 5 & 6: Handle LLM completion and tool execution loop
|
||||
log.info(`========== STAGE 5: LLM COMPLETION ==========`);
|
||||
const llmStartTime = Date.now();
|
||||
const completion = await this.stages.llmCompletion.execute({
|
||||
messages: preparedMessages.messages,
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('llmCompletion', llmStartTime);
|
||||
log.info(`Received LLM response from model: ${completion.response.model}, provider: ${completion.response.provider}`);
|
||||
|
||||
// Handle streaming if enabled and available
|
||||
if (enableStreaming && completion.response.stream && streamCallback) {
|
||||
@ -242,123 +264,247 @@ export class ChatPipeline {
|
||||
// Process any tool calls in the response
|
||||
let currentMessages = preparedMessages.messages;
|
||||
let currentResponse = completion.response;
|
||||
let needsFollowUp = false;
|
||||
let toolCallIterations = 0;
|
||||
const maxToolCallIterations = this.config.maxToolCallIterations;
|
||||
|
||||
// Check if tools were enabled in the options
|
||||
const toolsEnabled = modelSelection.options.enableTools !== false;
|
||||
|
||||
log.info(`========== TOOL CALL PROCESSING ==========`);
|
||||
log.info(`Tools enabled: ${toolsEnabled}`);
|
||||
log.info(`Tool calls in response: ${currentResponse.tool_calls ? currentResponse.tool_calls.length : 0}`);
|
||||
log.info(`Current response format: ${typeof currentResponse}`);
|
||||
log.info(`Response keys: ${Object.keys(currentResponse).join(', ')}`);
|
||||
|
||||
// Detailed tool call inspection
|
||||
|
||||
// Log decision points for tool execution
|
||||
log.info(`========== TOOL EXECUTION DECISION ==========`);
|
||||
log.info(`Tools enabled in options: ${toolsEnabled}`);
|
||||
log.info(`Response provider: ${currentResponse.provider || 'unknown'}`);
|
||||
log.info(`Response model: ${currentResponse.model || 'unknown'}`);
|
||||
log.info(`Response has tool_calls: ${currentResponse.tool_calls ? 'true' : 'false'}`);
|
||||
if (currentResponse.tool_calls) {
|
||||
currentResponse.tool_calls.forEach((tool, idx) => {
|
||||
log.info(`Tool call ${idx+1}: ${JSON.stringify(tool)}`);
|
||||
});
|
||||
log.info(`Number of tool calls: ${currentResponse.tool_calls.length}`);
|
||||
log.info(`Tool calls details: ${JSON.stringify(currentResponse.tool_calls)}`);
|
||||
|
||||
// Check if we have a response from Ollama, which might be handled differently
|
||||
if (currentResponse.provider === 'Ollama') {
|
||||
log.info(`ATTENTION: Response is from Ollama - checking if tool execution path is correct`);
|
||||
log.info(`Tool calls type: ${typeof currentResponse.tool_calls}`);
|
||||
log.info(`First tool call name: ${currentResponse.tool_calls[0]?.function?.name || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process tool calls if present and tools are enabled
|
||||
// Tool execution loop
|
||||
if (toolsEnabled && currentResponse.tool_calls && currentResponse.tool_calls.length > 0) {
|
||||
log.info(`========== STAGE 6: TOOL EXECUTION ==========`);
|
||||
log.info(`Response contains ${currentResponse.tool_calls.length} tool calls, processing...`);
|
||||
|
||||
// Start tool calling loop
|
||||
log.info(`Starting tool calling loop with max ${maxToolCallIterations} iterations`);
|
||||
// Format tool calls for logging
|
||||
log.info(`========== TOOL CALL DETAILS ==========`);
|
||||
currentResponse.tool_calls.forEach((toolCall, idx) => {
|
||||
log.info(`Tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
|
||||
log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
|
||||
});
|
||||
|
||||
do {
|
||||
log.info(`Tool calling iteration ${toolCallIterations + 1}`);
|
||||
// Keep track of whether we're in a streaming response
|
||||
const isStreaming = enableStreaming && streamCallback;
|
||||
let streamingPaused = false;
|
||||
|
||||
// Execute tool calling stage
|
||||
const toolCallingStartTime = Date.now();
|
||||
const toolCallingResult = await this.stages.toolCalling.execute({
|
||||
response: currentResponse,
|
||||
messages: currentMessages,
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('toolCalling', toolCallingStartTime);
|
||||
// If streaming was enabled, send an update to the user
|
||||
if (isStreaming && streamCallback) {
|
||||
streamingPaused = true;
|
||||
await streamCallback('', true); // Signal pause in streaming
|
||||
await streamCallback('\n\n[Executing tools...]\n\n', false);
|
||||
}
|
||||
|
||||
// Update state for next iteration
|
||||
currentMessages = toolCallingResult.messages;
|
||||
needsFollowUp = toolCallingResult.needsFollowUp;
|
||||
while (toolCallIterations < maxToolCallIterations) {
|
||||
toolCallIterations++;
|
||||
log.info(`========== TOOL ITERATION ${toolCallIterations}/${maxToolCallIterations} ==========`);
|
||||
|
||||
// Make another call to the LLM if needed
|
||||
if (needsFollowUp) {
|
||||
log.info(`Tool execution completed, making follow-up LLM call (iteration ${toolCallIterations + 1})...`);
|
||||
// Create a copy of messages before tool execution
|
||||
const previousMessages = [...currentMessages];
|
||||
|
||||
// Generate a new LLM response with the updated messages
|
||||
const followUpStartTime = Date.now();
|
||||
log.info(`Sending follow-up request to LLM with ${currentMessages.length} messages (including tool results)`);
|
||||
try {
|
||||
const toolCallingStartTime = Date.now();
|
||||
log.info(`========== PIPELINE TOOL EXECUTION FLOW ==========`);
|
||||
log.info(`About to call toolCalling.execute with ${currentResponse.tool_calls.length} tool calls`);
|
||||
log.info(`Tool calls being passed to stage: ${JSON.stringify(currentResponse.tool_calls)}`);
|
||||
|
||||
const followUpCompletion = await this.stages.llmCompletion.execute({
|
||||
const toolCallingResult = await this.stages.toolCalling.execute({
|
||||
response: currentResponse,
|
||||
messages: currentMessages,
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('llmCompletion', followUpStartTime);
|
||||
this.updateStageMetrics('toolCalling', toolCallingStartTime);
|
||||
|
||||
// Update current response for next iteration
|
||||
currentResponse = followUpCompletion.response;
|
||||
log.info(`ToolCalling stage execution complete, got result with needsFollowUp: ${toolCallingResult.needsFollowUp}`);
|
||||
|
||||
// Check for more tool calls
|
||||
const hasMoreToolCalls = !!(currentResponse.tool_calls && currentResponse.tool_calls.length > 0);
|
||||
// Update messages with tool results
|
||||
currentMessages = toolCallingResult.messages;
|
||||
|
||||
if (hasMoreToolCalls) {
|
||||
log.info(`Follow-up response contains ${currentResponse.tool_calls?.length || 0} more tool calls`);
|
||||
// Log the tool results for debugging
|
||||
const toolResultMessages = currentMessages.filter(
|
||||
msg => msg.role === 'tool' && !previousMessages.includes(msg)
|
||||
);
|
||||
|
||||
log.info(`========== TOOL EXECUTION RESULTS ==========`);
|
||||
toolResultMessages.forEach((msg, idx) => {
|
||||
log.info(`Tool result ${idx + 1}: tool_call_id=${msg.tool_call_id}, content=${msg.content.substring(0, 50)}...`);
|
||||
|
||||
// If streaming, show tool executions to the user
|
||||
if (isStreaming && streamCallback) {
|
||||
// For each tool result, format a readable message for the user
|
||||
const toolName = this.getToolNameFromToolCallId(currentMessages, msg.tool_call_id || '');
|
||||
const formattedToolResult = `[Tool: ${toolName || 'unknown'}]\n${msg.content}\n\n`;
|
||||
streamCallback(formattedToolResult, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if we need another LLM completion for tool results
|
||||
if (toolCallingResult.needsFollowUp) {
|
||||
log.info(`========== TOOL FOLLOW-UP REQUIRED ==========`);
|
||||
log.info('Tool execution complete, sending results back to LLM');
|
||||
|
||||
// Ensure messages are properly formatted
|
||||
this.validateToolMessages(currentMessages);
|
||||
|
||||
// If streaming, show progress to the user
|
||||
if (isStreaming && streamCallback) {
|
||||
await streamCallback('[Generating response with tool results...]\n\n', false);
|
||||
}
|
||||
|
||||
// Generate a new completion with the updated messages
|
||||
const followUpStartTime = Date.now();
|
||||
const followUpCompletion = await this.stages.llmCompletion.execute({
|
||||
messages: currentMessages,
|
||||
options: {
|
||||
...modelSelection.options,
|
||||
// Ensure tool support is still enabled for follow-up requests
|
||||
enableTools: true,
|
||||
// Disable streaming during tool execution follow-ups
|
||||
stream: false
|
||||
}
|
||||
});
|
||||
this.updateStageMetrics('llmCompletion', followUpStartTime);
|
||||
|
||||
// Update current response for the next iteration
|
||||
currentResponse = followUpCompletion.response;
|
||||
|
||||
// Check if we need to continue the tool calling loop
|
||||
if (!currentResponse.tool_calls || currentResponse.tool_calls.length === 0) {
|
||||
log.info(`========== TOOL EXECUTION COMPLETE ==========`);
|
||||
log.info('No more tool calls, breaking tool execution loop');
|
||||
break;
|
||||
} else {
|
||||
log.info(`========== ADDITIONAL TOOL CALLS DETECTED ==========`);
|
||||
log.info(`Next iteration has ${currentResponse.tool_calls.length} more tool calls`);
|
||||
// Log the next set of tool calls
|
||||
currentResponse.tool_calls.forEach((toolCall, idx) => {
|
||||
log.info(`Next tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
|
||||
log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log.info(`Follow-up response contains no more tool calls - completing tool loop`);
|
||||
log.info(`========== TOOL EXECUTION COMPLETE ==========`);
|
||||
log.info('No follow-up needed, breaking tool execution loop');
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.info(`========== TOOL EXECUTION ERROR ==========`);
|
||||
log.error(`Error in tool execution: ${error.message || String(error)}`);
|
||||
|
||||
// Add error message to the conversation if tool execution fails
|
||||
currentMessages.push({
|
||||
role: 'system',
|
||||
content: `Error executing tool: ${error.message || String(error)}. Please try a different approach.`
|
||||
});
|
||||
|
||||
// If streaming, show error to the user
|
||||
if (isStreaming && streamCallback) {
|
||||
await streamCallback(`[Tool execution error: ${error.message || 'unknown error'}]\n\n`, false);
|
||||
}
|
||||
|
||||
// Continue loop if there are more tool calls
|
||||
needsFollowUp = hasMoreToolCalls;
|
||||
// Make a follow-up request to the LLM with the error information
|
||||
const errorFollowUpCompletion = await this.stages.llmCompletion.execute({
|
||||
messages: currentMessages,
|
||||
options: modelSelection.options
|
||||
});
|
||||
|
||||
// Update current response and break the tool loop
|
||||
currentResponse = errorFollowUpCompletion.response;
|
||||
break;
|
||||
}
|
||||
|
||||
// Increment iteration counter
|
||||
toolCallIterations++;
|
||||
|
||||
} while (needsFollowUp && toolCallIterations < maxToolCallIterations);
|
||||
|
||||
// If we hit max iterations but still have tool calls, log a warning
|
||||
if (toolCallIterations >= maxToolCallIterations && needsFollowUp) {
|
||||
log.error(`Reached maximum tool call iterations (${maxToolCallIterations}), stopping`);
|
||||
}
|
||||
|
||||
log.info(`Completed ${toolCallIterations} tool call iterations`);
|
||||
if (toolCallIterations >= maxToolCallIterations) {
|
||||
log.info(`========== MAXIMUM TOOL ITERATIONS REACHED ==========`);
|
||||
log.error(`Reached maximum tool call iterations (${maxToolCallIterations}), terminating loop`);
|
||||
|
||||
// Add a message to inform the LLM that we've reached the limit
|
||||
currentMessages.push({
|
||||
role: 'system',
|
||||
content: `Maximum tool call iterations (${maxToolCallIterations}) reached. Please provide your best response with the information gathered so far.`
|
||||
});
|
||||
|
||||
// If streaming, inform the user about iteration limit
|
||||
if (isStreaming && streamCallback) {
|
||||
await streamCallback(`[Reached maximum of ${maxToolCallIterations} tool calls. Finalizing response...]\n\n`, false);
|
||||
}
|
||||
|
||||
// Make a final request to get a summary response
|
||||
const finalFollowUpCompletion = await this.stages.llmCompletion.execute({
|
||||
messages: currentMessages,
|
||||
options: {
|
||||
...modelSelection.options,
|
||||
enableTools: false // Disable tools for the final response
|
||||
}
|
||||
});
|
||||
|
||||
// Update the current response
|
||||
currentResponse = finalFollowUpCompletion.response;
|
||||
}
|
||||
|
||||
// If streaming was paused for tool execution, resume it now with the final response
|
||||
if (isStreaming && streamCallback && streamingPaused) {
|
||||
// Resume streaming with the final response text
|
||||
await streamCallback(currentResponse.text, true);
|
||||
}
|
||||
} else if (toolsEnabled) {
|
||||
log.info(`========== NO TOOL CALLS DETECTED ==========`);
|
||||
log.info(`LLM response did not contain any tool calls, skipping tool execution`);
|
||||
}
|
||||
|
||||
// For non-streaming responses, process the final response
|
||||
const processStartTime = Date.now();
|
||||
const processed = await this.stages.responseProcessing.execute({
|
||||
// Process the final response
|
||||
log.info(`========== FINAL RESPONSE PROCESSING ==========`);
|
||||
const responseProcessingStartTime = Date.now();
|
||||
const processedResponse = await this.stages.responseProcessing.execute({
|
||||
response: currentResponse,
|
||||
options: input.options
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('responseProcessing', processStartTime);
|
||||
this.updateStageMetrics('responseProcessing', responseProcessingStartTime);
|
||||
log.info(`Final response processed, returning to user (${processedResponse.text.length} chars)`);
|
||||
|
||||
// Combine response with processed text, using accumulated text if streamed
|
||||
const finalResponse: ChatResponse = {
|
||||
...currentResponse,
|
||||
text: accumulatedText || processed.text
|
||||
};
|
||||
// Return the final response to the user
|
||||
// The ResponseProcessingStage returns {text}, not {response}
|
||||
// So we update our currentResponse with the processed text
|
||||
currentResponse.text = processedResponse.text;
|
||||
|
||||
const endTime = Date.now();
|
||||
const executionTime = endTime - startTime;
|
||||
|
||||
// Update overall average execution time
|
||||
this.metrics.averageExecutionTime =
|
||||
(this.metrics.averageExecutionTime * (this.metrics.totalExecutions - 1) + executionTime) /
|
||||
this.metrics.totalExecutions;
|
||||
|
||||
log.info(`Chat pipeline completed in ${executionTime}ms`);
|
||||
|
||||
return finalResponse;
|
||||
log.info(`========== PIPELINE COMPLETE ==========`);
|
||||
return currentResponse;
|
||||
} catch (error: any) {
|
||||
log.error(`Error in chat pipeline: ${error.message}`);
|
||||
log.info(`========== PIPELINE ERROR ==========`);
|
||||
log.error(`Error in chat pipeline: ${error.message || String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get an LLM service for query processing
|
||||
*/
|
||||
private async getLLMService(): Promise<LLMServiceInterface | null> {
|
||||
try {
|
||||
const aiServiceManager = await import('../ai_service_manager.js').then(module => module.default);
|
||||
return aiServiceManager.getService();
|
||||
} catch (error: any) {
|
||||
log.error(`Error getting LLM service: ${error.message || String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a stream chunk through the response processing stage
|
||||
*/
|
||||
@ -428,4 +574,52 @@ export class ChatPipeline {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tool name from tool call ID by looking at previous assistant messages
|
||||
*/
|
||||
private getToolNameFromToolCallId(messages: Message[], toolCallId: string): string {
|
||||
if (!toolCallId) return 'unknown';
|
||||
|
||||
// Look for assistant messages with tool_calls
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message.role === 'assistant' && message.tool_calls) {
|
||||
// Find the tool call with the matching ID
|
||||
const toolCall = message.tool_calls.find(tc => tc.id === toolCallId);
|
||||
if (toolCall && toolCall.function && toolCall.function.name) {
|
||||
return toolCall.function.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tool messages to ensure they're properly formatted
|
||||
*/
|
||||
private validateToolMessages(messages: Message[]): void {
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
|
||||
// Ensure tool messages have required fields
|
||||
if (message.role === 'tool') {
|
||||
if (!message.tool_call_id) {
|
||||
log.info(`Tool message missing tool_call_id, adding placeholder`);
|
||||
message.tool_call_id = `tool_${i}`;
|
||||
}
|
||||
|
||||
// Content should be a string
|
||||
if (typeof message.content !== 'string') {
|
||||
log.info(`Tool message content is not a string, converting`);
|
||||
try {
|
||||
message.content = JSON.stringify(message.content);
|
||||
} catch (e) {
|
||||
message.content = String(message.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -22,25 +22,29 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
|
||||
*/
|
||||
protected async process(input: ToolExecutionInput): Promise<{ response: ChatResponse, needsFollowUp: boolean, messages: Message[] }> {
|
||||
const { response, messages, options } = input;
|
||||
|
||||
|
||||
log.info(`========== TOOL CALLING STAGE ENTRY ==========`);
|
||||
log.info(`Response provider: ${response.provider}, model: ${response.model || 'unknown'}`);
|
||||
|
||||
// Check if the response has tool calls
|
||||
if (!response.tool_calls || response.tool_calls.length === 0) {
|
||||
// No tool calls, return original response and messages
|
||||
log.info(`No tool calls detected in response from provider: ${response.provider}`);
|
||||
log.info(`===== EXITING TOOL CALLING STAGE: No tool_calls =====`);
|
||||
return { response, needsFollowUp: false, messages };
|
||||
}
|
||||
|
||||
log.info(`LLM requested ${response.tool_calls.length} tool calls from provider: ${response.provider}`);
|
||||
|
||||
|
||||
// Log response details for debugging
|
||||
if (response.text) {
|
||||
log.info(`Response text: "${response.text.substring(0, 200)}${response.text.length > 200 ? '...' : ''}"`);
|
||||
}
|
||||
|
||||
|
||||
// Check if the registry has any tools
|
||||
const availableTools = toolRegistry.getAllTools();
|
||||
log.info(`Available tools in registry: ${availableTools.length}`);
|
||||
|
||||
|
||||
if (availableTools.length === 0) {
|
||||
log.error(`No tools available in registry, cannot execute tool calls`);
|
||||
// Try to initialize tools as a recovery step
|
||||
@ -53,10 +57,10 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
|
||||
log.error(`Failed to initialize tools in recovery step: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create a copy of messages to add the assistant message with tool calls
|
||||
const updatedMessages = [...messages];
|
||||
|
||||
|
||||
// Add the assistant message with the tool calls
|
||||
updatedMessages.push({
|
||||
role: 'assistant',
|
||||
@ -65,38 +69,44 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
|
||||
});
|
||||
|
||||
// Execute each tool call and add results to messages
|
||||
const toolResults = await Promise.all(response.tool_calls.map(async (toolCall) => {
|
||||
log.info(`========== STARTING TOOL EXECUTION ==========`);
|
||||
const toolResults = await Promise.all(response.tool_calls.map(async (toolCall, index) => {
|
||||
try {
|
||||
log.info(`Tool call received - Name: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`);
|
||||
|
||||
log.info(`Tool call ${index + 1} received - Name: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`);
|
||||
|
||||
// Log parameters
|
||||
const argsStr = typeof toolCall.function.arguments === 'string'
|
||||
? toolCall.function.arguments
|
||||
const argsStr = typeof toolCall.function.arguments === 'string'
|
||||
? toolCall.function.arguments
|
||||
: JSON.stringify(toolCall.function.arguments);
|
||||
log.info(`Tool parameters: ${argsStr}`);
|
||||
|
||||
|
||||
// Get the tool from registry
|
||||
const tool = toolRegistry.getTool(toolCall.function.name);
|
||||
|
||||
|
||||
if (!tool) {
|
||||
log.error(`Tool not found in registry: ${toolCall.function.name}`);
|
||||
log.info(`Available tools: ${availableTools.map(t => t.definition.function.name).join(', ')}`);
|
||||
throw new Error(`Tool not found: ${toolCall.function.name}`);
|
||||
}
|
||||
|
||||
|
||||
log.info(`Tool found in registry: ${toolCall.function.name}`);
|
||||
|
||||
// Parse arguments (handle both string and object formats)
|
||||
let args;
|
||||
// At this stage, arguments should already be processed by the provider-specific service
|
||||
// But we still need to handle different formats just in case
|
||||
if (typeof toolCall.function.arguments === 'string') {
|
||||
log.info(`Received string arguments in tool calling stage: ${toolCall.function.arguments.substring(0, 50)}...`);
|
||||
|
||||
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
args = JSON.parse(toolCall.function.arguments);
|
||||
log.info(`Parsed JSON arguments: ${Object.keys(args).join(', ')}`);
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
// If it's not valid JSON, try to check if it's a stringified object with quotes
|
||||
log.info(`Failed to parse arguments as JSON, trying alternative parsing: ${e.message}`);
|
||||
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
log.info(`Failed to parse arguments as JSON, trying alternative parsing: ${errorMessage}`);
|
||||
|
||||
// Sometimes LLMs return stringified JSON with escaped quotes or incorrect quotes
|
||||
// Try to clean it up
|
||||
try {
|
||||
@ -105,13 +115,14 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
|
||||
.replace(/\\"/g, '"') // Replace escaped quotes
|
||||
.replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":') // Replace single quotes around property names
|
||||
.replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":'); // Add quotes around unquoted property names
|
||||
|
||||
|
||||
log.info(`Cleaned argument string: ${cleaned}`);
|
||||
args = JSON.parse(cleaned);
|
||||
log.info(`Successfully parsed cleaned arguments: ${Object.keys(args).join(', ')}`);
|
||||
} catch (cleanError) {
|
||||
} catch (cleanError: unknown) {
|
||||
// If all parsing fails, treat it as a text argument
|
||||
log.info(`Failed to parse cleaned arguments: ${cleanError.message}`);
|
||||
const cleanErrorMessage = cleanError instanceof Error ? cleanError.message : String(cleanError);
|
||||
log.info(`Failed to parse cleaned arguments: ${cleanErrorMessage}`);
|
||||
args = { text: toolCall.function.arguments };
|
||||
log.info(`Using text argument: ${args.text.substring(0, 50)}...`);
|
||||
}
|
||||
@ -121,12 +132,12 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
|
||||
args = toolCall.function.arguments;
|
||||
log.info(`Using object arguments with keys: ${Object.keys(args).join(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
// Execute the tool
|
||||
log.info(`================ EXECUTING TOOL: ${toolCall.function.name} ================`);
|
||||
log.info(`Tool parameters: ${Object.keys(args).join(', ')}`);
|
||||
log.info(`Parameters values: ${Object.entries(args).map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ')}`);
|
||||
|
||||
|
||||
const executionStart = Date.now();
|
||||
let result;
|
||||
try {
|
||||
@ -139,13 +150,14 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
|
||||
log.error(`================ TOOL EXECUTION FAILED in ${executionTime}ms: ${execError.message} ================`);
|
||||
throw execError;
|
||||
}
|
||||
|
||||
|
||||
// Log execution result
|
||||
const resultSummary = typeof result === 'string'
|
||||
? `${result.substring(0, 100)}...`
|
||||
const resultSummary = typeof result === 'string'
|
||||
? `${result.substring(0, 100)}...`
|
||||
: `Object with keys: ${Object.keys(result).join(', ')}`;
|
||||
const executionTime = Date.now() - executionStart;
|
||||
log.info(`Tool execution completed in ${executionTime}ms - Result: ${resultSummary}`);
|
||||
|
||||
|
||||
// Return result with tool call ID
|
||||
return {
|
||||
toolCallId: toolCall.id,
|
||||
@ -154,7 +166,7 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error executing tool ${toolCall.function.name}: ${error.message || String(error)}`);
|
||||
|
||||
|
||||
// Return error message as result
|
||||
return {
|
||||
toolCallId: toolCall.id,
|
||||
@ -163,12 +175,12 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
// Add tool results as messages
|
||||
toolResults.forEach(result => {
|
||||
// Format the result content based on type
|
||||
let content: string;
|
||||
|
||||
|
||||
if (typeof result.result === 'string') {
|
||||
content = result.result;
|
||||
log.info(`Tool returned string result (${content.length} chars)`);
|
||||
@ -182,9 +194,9 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
|
||||
log.info(`Failed to stringify object result: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
log.info(`Adding tool result message - Tool: ${result.name}, ID: ${result.toolCallId || 'unknown'}, Length: ${content.length}`);
|
||||
|
||||
|
||||
// Create a properly formatted tool response message
|
||||
updatedMessages.push({
|
||||
role: 'tool',
|
||||
@ -192,21 +204,21 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re
|
||||
name: result.name,
|
||||
tool_call_id: result.toolCallId
|
||||
});
|
||||
|
||||
|
||||
// Log a sample of the content for debugging
|
||||
const contentPreview = content.substring(0, 100) + (content.length > 100 ? '...' : '');
|
||||
log.info(`Tool result preview: ${contentPreview}`);
|
||||
});
|
||||
|
||||
|
||||
log.info(`Added ${toolResults.length} tool results to conversation`);
|
||||
|
||||
|
||||
// If we have tool results, we need a follow-up call to the LLM
|
||||
const needsFollowUp = toolResults.length > 0;
|
||||
|
||||
|
||||
if (needsFollowUp) {
|
||||
log.info(`Tool execution complete, LLM follow-up required with ${updatedMessages.length} messages`);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
response,
|
||||
needsFollowUp,
|
||||
|
@ -107,14 +107,14 @@ export class OllamaService extends BaseAIService {
|
||||
const tools = toolRegistry.getAllToolDefinitions();
|
||||
requestBody.tools = tools;
|
||||
log.info(`Adding ${tools.length} tools to request`);
|
||||
|
||||
|
||||
// If no tools found, reinitialize
|
||||
if (tools.length === 0) {
|
||||
log.info('No tools found in registry, re-initializing...');
|
||||
try {
|
||||
const toolInitializer = await import('../tools/tool_initializer.js');
|
||||
await toolInitializer.default.initializeTools();
|
||||
|
||||
|
||||
// Try again
|
||||
requestBody.tools = toolRegistry.getAllToolDefinitions();
|
||||
log.info(`After re-initialization: ${requestBody.tools.length} tools available`);
|
||||
@ -139,43 +139,43 @@ export class OllamaService extends BaseAIService {
|
||||
log.info(`========== OLLAMA API REQUEST ==========`);
|
||||
log.info(`Model: ${requestBody.model}, Messages: ${requestBody.messages.length}, Tools: ${requestBody.tools ? requestBody.tools.length : 0}`);
|
||||
log.info(`Temperature: ${temperature}, Stream: ${requestBody.stream}, JSON response expected: ${expectsJsonResponse}`);
|
||||
|
||||
|
||||
// Check message structure and log detailed information about each message
|
||||
requestBody.messages.forEach((msg: any, index: number) => {
|
||||
const keys = Object.keys(msg);
|
||||
log.info(`Message ${index}, Role: ${msg.role}, Keys: ${keys.join(', ')}`);
|
||||
|
||||
|
||||
// Log message content preview
|
||||
if (msg.content && typeof msg.content === 'string') {
|
||||
const contentPreview = msg.content.length > 200
|
||||
? `${msg.content.substring(0, 200)}...`
|
||||
const contentPreview = msg.content.length > 200
|
||||
? `${msg.content.substring(0, 200)}...`
|
||||
: msg.content;
|
||||
log.info(`Message ${index} content: ${contentPreview}`);
|
||||
}
|
||||
|
||||
|
||||
// Log tool-related details
|
||||
if (keys.includes('tool_calls')) {
|
||||
log.info(`Message ${index} has ${msg.tool_calls.length} tool calls:`);
|
||||
msg.tool_calls.forEach((call: any, callIdx: number) => {
|
||||
log.info(` Tool call ${callIdx}: ${call.function?.name || 'unknown'}, ID: ${call.id || 'unspecified'}`);
|
||||
if (call.function?.arguments) {
|
||||
const argsPreview = typeof call.function.arguments === 'string'
|
||||
? call.function.arguments.substring(0, 100)
|
||||
const argsPreview = typeof call.function.arguments === 'string'
|
||||
? call.function.arguments.substring(0, 100)
|
||||
: JSON.stringify(call.function.arguments).substring(0, 100);
|
||||
log.info(` Arguments: ${argsPreview}...`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (keys.includes('tool_call_id')) {
|
||||
log.info(`Message ${index} is a tool response for tool call ID: ${msg.tool_call_id}`);
|
||||
}
|
||||
|
||||
|
||||
if (keys.includes('name') && msg.role === 'tool') {
|
||||
log.info(`Message ${index} is from tool: ${msg.name}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Log tool definitions
|
||||
if (requestBody.tools && requestBody.tools.length > 0) {
|
||||
log.info(`Sending ${requestBody.tools.length} tool definitions:`);
|
||||
@ -185,19 +185,19 @@ export class OllamaService extends BaseAIService {
|
||||
log.info(` Description: ${tool.function.description.substring(0, 100)}...`);
|
||||
}
|
||||
if (tool.function?.parameters) {
|
||||
const paramNames = tool.function.parameters.properties
|
||||
? Object.keys(tool.function.parameters.properties)
|
||||
const paramNames = tool.function.parameters.properties
|
||||
? Object.keys(tool.function.parameters.properties)
|
||||
: [];
|
||||
log.info(` Parameters: ${paramNames.join(', ')}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Log full request body (this will create large logs but is helpful for debugging)
|
||||
const requestStr = JSON.stringify(requestBody);
|
||||
log.info(`Full Ollama request (truncated): ${requestStr.substring(0, 1000)}...`);
|
||||
log.info(`========== END OLLAMA REQUEST ==========`);
|
||||
|
||||
|
||||
// Make API request
|
||||
const response = await fetch(`${apiBase}/api/chat`, {
|
||||
method: 'POST',
|
||||
@ -214,20 +214,20 @@ export class OllamaService extends BaseAIService {
|
||||
}
|
||||
|
||||
const data: OllamaResponse = await response.json();
|
||||
|
||||
|
||||
// Log response details
|
||||
log.info(`========== OLLAMA API RESPONSE ==========`);
|
||||
log.info(`Model: ${data.model}, Content length: ${data.message.content.length} chars`);
|
||||
log.info(`Tokens: ${data.prompt_eval_count} prompt, ${data.eval_count} completion, ${data.prompt_eval_count + data.eval_count} total`);
|
||||
log.info(`Duration: ${data.total_duration}ns total, ${data.prompt_eval_duration}ns prompt, ${data.eval_duration}ns completion`);
|
||||
log.info(`Done: ${data.done}, Reason: ${data.done_reason || 'not specified'}`);
|
||||
|
||||
|
||||
// Log content preview
|
||||
const contentPreview = data.message.content.length > 300
|
||||
? `${data.message.content.substring(0, 300)}...`
|
||||
const contentPreview = data.message.content.length > 300
|
||||
? `${data.message.content.substring(0, 300)}...`
|
||||
: data.message.content;
|
||||
log.info(`Response content: ${contentPreview}`);
|
||||
|
||||
|
||||
// Handle the response and extract tool calls if present
|
||||
const chatResponse: ChatResponse = {
|
||||
text: data.message.content,
|
||||
@ -242,45 +242,47 @@ export class OllamaService extends BaseAIService {
|
||||
|
||||
// Add tool calls if present
|
||||
if (data.message.tool_calls && data.message.tool_calls.length > 0) {
|
||||
log.info(`========== OLLAMA TOOL CALLS DETECTED ==========`);
|
||||
log.info(`Ollama response includes ${data.message.tool_calls.length} tool calls`);
|
||||
|
||||
|
||||
// Log detailed information about each tool call
|
||||
const transformedToolCalls: ToolCall[] = [];
|
||||
|
||||
|
||||
// Log detailed information about the tool calls in the response
|
||||
log.info(`========== OLLAMA TOOL CALLS IN RESPONSE ==========`);
|
||||
data.message.tool_calls.forEach((toolCall, index) => {
|
||||
log.info(`Tool call ${index + 1}:`);
|
||||
log.info(` Name: ${toolCall.function?.name || 'unknown'}`);
|
||||
log.info(` ID: ${toolCall.id || `auto-${index + 1}`}`);
|
||||
|
||||
|
||||
// Generate a unique ID if none is provided
|
||||
const id = toolCall.id || `tool-call-${Date.now()}-${index}`;
|
||||
|
||||
|
||||
// Handle arguments based on their type
|
||||
let processedArguments: Record<string, any> | string;
|
||||
|
||||
|
||||
if (typeof toolCall.function.arguments === 'string') {
|
||||
// Log raw string arguments in full for debugging
|
||||
log.info(` Raw string arguments: ${toolCall.function.arguments}`);
|
||||
|
||||
|
||||
// Try to parse JSON string arguments
|
||||
try {
|
||||
processedArguments = JSON.parse(toolCall.function.arguments);
|
||||
log.info(` Successfully parsed arguments to object with keys: ${Object.keys(processedArguments).join(', ')}`);
|
||||
log.info(` Parsed argument values:`);
|
||||
Object.entries(processedArguments).forEach(([key, value]) => {
|
||||
const valuePreview = typeof value === 'string'
|
||||
const valuePreview = typeof value === 'string'
|
||||
? (value.length > 100 ? `${value.substring(0, 100)}...` : value)
|
||||
: JSON.stringify(value);
|
||||
log.info(` ${key}: ${valuePreview}`);
|
||||
});
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
// If parsing fails, keep as string and log the error
|
||||
processedArguments = toolCall.function.arguments;
|
||||
log.info(` Could not parse arguments as JSON: ${e.message}`);
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
log.info(` Could not parse arguments as JSON: ${errorMessage}`);
|
||||
log.info(` Keeping as string: ${processedArguments.substring(0, 200)}${processedArguments.length > 200 ? '...' : ''}`);
|
||||
|
||||
|
||||
// Try to clean and parse again with more aggressive methods
|
||||
try {
|
||||
const cleaned = toolCall.function.arguments
|
||||
@ -288,12 +290,13 @@ export class OllamaService extends BaseAIService {
|
||||
.replace(/\\"/g, '"') // Replace escaped quotes
|
||||
.replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":') // Replace single quotes around property names
|
||||
.replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":'); // Add quotes around unquoted property names
|
||||
|
||||
|
||||
log.info(` Attempting to parse cleaned argument: ${cleaned}`);
|
||||
const reparseArg = JSON.parse(cleaned);
|
||||
log.info(` Successfully parsed cleaned argument with keys: ${Object.keys(reparseArg).join(', ')}`);
|
||||
} catch (cleanErr) {
|
||||
log.info(` Failed to parse cleaned arguments: ${cleanErr.message}`);
|
||||
} catch (cleanErr: unknown) {
|
||||
const cleanErrMessage = cleanErr instanceof Error ? cleanErr.message : String(cleanErr);
|
||||
log.info(` Failed to parse cleaned arguments: ${cleanErrMessage}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -302,13 +305,13 @@ export class OllamaService extends BaseAIService {
|
||||
log.info(` Object arguments with keys: ${Object.keys(processedArguments).join(', ')}`);
|
||||
log.info(` Argument values:`);
|
||||
Object.entries(processedArguments).forEach(([key, value]) => {
|
||||
const valuePreview = typeof value === 'string'
|
||||
const valuePreview = typeof value === 'string'
|
||||
? (value.length > 100 ? `${value.substring(0, 100)}...` : value)
|
||||
: JSON.stringify(value);
|
||||
log.info(` ${key}: ${valuePreview}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Convert to our standard ToolCall format
|
||||
transformedToolCalls.push({
|
||||
id,
|
||||
@ -319,11 +322,39 @@ export class OllamaService extends BaseAIService {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Add transformed tool calls to response
|
||||
chatResponse.tool_calls = transformedToolCalls;
|
||||
log.info(`Transformed ${transformedToolCalls.length} tool calls for execution`);
|
||||
log.info(`Tool calls after transformation: ${JSON.stringify(chatResponse.tool_calls)}`);
|
||||
|
||||
// CRITICAL: Explicitly mark response for tool execution
|
||||
log.info(`CRITICAL: Explicitly marking response for tool execution`);
|
||||
|
||||
// Ensure tool_calls is properly exposed and formatted
|
||||
// This is to make sure the pipeline can detect and execute the tools
|
||||
if (transformedToolCalls.length > 0) {
|
||||
// Make sure the tool_calls are exposed in the exact format expected by pipeline
|
||||
chatResponse.tool_calls = transformedToolCalls.map(tc => ({
|
||||
id: tc.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments
|
||||
}
|
||||
}));
|
||||
|
||||
// If the content is empty, use a placeholder to avoid issues
|
||||
if (!chatResponse.text) {
|
||||
chatResponse.text = "Processing your request...";
|
||||
}
|
||||
|
||||
log.info(`Final tool_calls format for pipeline: ${JSON.stringify(chatResponse.tool_calls)}`);
|
||||
}
|
||||
log.info(`========== END OLLAMA TOOL CALLS ==========`);
|
||||
} else {
|
||||
log.info(`========== NO OLLAMA TOOL CALLS DETECTED ==========`);
|
||||
log.info(`Checking raw message response format: ${JSON.stringify(data.message)}`);
|
||||
}
|
||||
|
||||
log.info(`========== END OLLAMA RESPONSE ==========`);
|
||||
|
@ -4,7 +4,7 @@ import type { Message, ChatCompletionOptions } from "./ai_interface.js";
|
||||
import contextService from "./context_service.js";
|
||||
import { LLM_CONSTANTS } from './constants/provider_constants.js';
|
||||
import { ERROR_PROMPTS } from './constants/llm_prompt_constants.js';
|
||||
import * as aiServiceManagerModule from "./ai_service_manager.js";
|
||||
import aiServiceManagerImport from "./ai_service_manager.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import vectorStore from "./embeddings/index.js";
|
||||
import providerManager from "./providers/providers.js";
|
||||
@ -94,7 +94,7 @@ class RestChatService {
|
||||
|
||||
// Try to access the manager - will create instance only if needed
|
||||
try {
|
||||
const aiManager = aiServiceManagerModule.default;
|
||||
const aiManager = aiServiceManagerImport.getInstance();
|
||||
|
||||
if (!aiManager) {
|
||||
log.info("AI check failed: AI manager module is not available");
|
||||
@ -315,7 +315,7 @@ class RestChatService {
|
||||
log.info("AI services are not available - checking for specific issues");
|
||||
|
||||
try {
|
||||
const aiManager = aiServiceManagerModule.default;
|
||||
const aiManager = aiServiceManagerImport.getInstance();
|
||||
|
||||
if (!aiManager) {
|
||||
log.error("AI service manager is not initialized");
|
||||
@ -341,7 +341,7 @@ class RestChatService {
|
||||
}
|
||||
|
||||
// Get the AI service manager
|
||||
const aiServiceManager = aiServiceManagerModule.default.getInstance();
|
||||
const aiServiceManager = aiServiceManagerImport.getInstance();
|
||||
|
||||
// Get the default service - just use the first available one
|
||||
const availableProviders = aiServiceManager.getAvailableProviders();
|
||||
@ -468,6 +468,9 @@ class RestChatService {
|
||||
// Use the Trilium-specific approach
|
||||
const contextNoteId = session.noteContext || null;
|
||||
|
||||
// Ensure tools are initialized to prevent tool execution issues
|
||||
await this.ensureToolsInitialized();
|
||||
|
||||
// Log that we're calling contextService with the parameters
|
||||
log.info(`Using enhanced context with: noteId=${contextNoteId}, showThinking=${showThinking}`);
|
||||
|
||||
@ -506,23 +509,67 @@ class RestChatService {
|
||||
temperature: session.metadata.temperature || 0.7,
|
||||
maxTokens: session.metadata.maxTokens,
|
||||
model: session.metadata.model,
|
||||
stream: isStreamingRequest ? true : undefined
|
||||
stream: isStreamingRequest ? true : undefined,
|
||||
enableTools: true // Explicitly enable tools
|
||||
};
|
||||
|
||||
// Process based on whether this is a streaming request
|
||||
// Add a note indicating we're explicitly enabling tools
|
||||
log.info(`Advanced context flow: explicitly enabling tools in chat options`);
|
||||
|
||||
// Process streaming responses differently
|
||||
if (isStreamingRequest) {
|
||||
// Handle streaming using the existing method
|
||||
await this.handleStreamingResponse(res, aiMessages, chatOptions, service, session);
|
||||
} else {
|
||||
// Non-streaming approach for POST requests
|
||||
// For non-streaming requests, generate a completion synchronously
|
||||
const response = await service.generateChatCompletion(aiMessages, chatOptions);
|
||||
const aiResponse = response.text; // Extract the text from the response
|
||||
|
||||
// Store the assistant's response in the session
|
||||
session.messages.push({
|
||||
role: 'assistant',
|
||||
content: aiResponse,
|
||||
timestamp: new Date()
|
||||
});
|
||||
// Check if the response contains tool calls
|
||||
if (response.tool_calls && response.tool_calls.length > 0) {
|
||||
log.info(`Advanced context non-streaming: detected ${response.tool_calls.length} tool calls in response`);
|
||||
log.info(`Tool calls details: ${JSON.stringify(response.tool_calls)}`);
|
||||
|
||||
try {
|
||||
// Execute the tools
|
||||
const toolResults = await this.executeToolCalls(response);
|
||||
log.info(`Successfully executed ${toolResults.length} tool calls in advanced context flow`);
|
||||
|
||||
// Build updated messages with tool results
|
||||
const toolMessages = [...aiMessages, {
|
||||
role: 'assistant',
|
||||
content: response.text || '',
|
||||
tool_calls: response.tool_calls
|
||||
}, ...toolResults];
|
||||
|
||||
// Make a follow-up request with the tool results
|
||||
log.info(`Making follow-up request with ${toolResults.length} tool results`);
|
||||
const followUpOptions = {...chatOptions, enableTools: false}; // Disable tools for follow-up
|
||||
const followUpResponse = await service.generateChatCompletion(toolMessages, followUpOptions);
|
||||
|
||||
// Update the session with the final response
|
||||
session.messages.push({
|
||||
role: 'assistant',
|
||||
content: followUpResponse.text || '',
|
||||
timestamp: new Date()
|
||||
});
|
||||
} catch (toolError: any) {
|
||||
log.error(`Error executing tools in advanced context: ${toolError.message}`);
|
||||
|
||||
// Add error response to session
|
||||
session.messages.push({
|
||||
role: 'assistant',
|
||||
content: `Error executing tools: ${toolError.message}`,
|
||||
timestamp: new Date()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No tool calls, just add the response to the session
|
||||
session.messages.push({
|
||||
role: 'assistant',
|
||||
content: response.text || '',
|
||||
timestamp: new Date()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sourceNotes;
|
||||
@ -608,50 +655,57 @@ class RestChatService {
|
||||
try {
|
||||
// Use the correct method name: generateChatCompletion
|
||||
const response = await service.generateChatCompletion(aiMessages, chatOptions);
|
||||
|
||||
|
||||
// Check for tool calls in the response
|
||||
if (response.tool_calls && response.tool_calls.length > 0) {
|
||||
log.info(`========== STREAMING TOOL CALLS DETECTED ==========`);
|
||||
log.info(`Response contains ${response.tool_calls.length} tool calls, executing them...`);
|
||||
|
||||
log.info(`CRITICAL CHECK: Tool execution is supposed to happen in the pipeline, not directly here.`);
|
||||
log.info(`If tools are being executed here instead of in the pipeline, this may be a flow issue.`);
|
||||
log.info(`Response came from provider: ${response.provider || 'unknown'}, model: ${response.model || 'unknown'}`);
|
||||
|
||||
try {
|
||||
log.info(`========== STREAMING TOOL EXECUTION PATH ==========`);
|
||||
log.info(`About to execute tools in streaming path (this is separate from pipeline tool execution)`);
|
||||
|
||||
// Execute the tools
|
||||
const toolResults = await this.executeToolCalls(response);
|
||||
|
||||
log.info(`Successfully executed ${toolResults.length} tool calls in streaming path`);
|
||||
|
||||
// Make a follow-up request with the tool results
|
||||
const toolMessages = [...aiMessages, {
|
||||
role: 'assistant',
|
||||
content: response.text || '',
|
||||
tool_calls: response.tool_calls
|
||||
}, ...toolResults];
|
||||
|
||||
|
||||
log.info(`Making follow-up request with ${toolResults.length} tool results`);
|
||||
|
||||
|
||||
// Send partial response to let the client know tools are being processed
|
||||
if (!res.writableEnded) {
|
||||
res.write(`data: ${JSON.stringify({ content: "Processing tools... " })}\n\n`);
|
||||
}
|
||||
|
||||
|
||||
// Use non-streaming for the follow-up to get a complete response
|
||||
const followUpOptions = {...chatOptions, stream: false, enableTools: false}; // Prevent infinite loops
|
||||
const followUpResponse = await service.generateChatCompletion(toolMessages, followUpOptions);
|
||||
|
||||
|
||||
messageContent = followUpResponse.text || "";
|
||||
|
||||
|
||||
// Send the complete response as a single chunk
|
||||
if (!res.writableEnded) {
|
||||
res.write(`data: ${JSON.stringify({ content: messageContent })}\n\n`);
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
}
|
||||
|
||||
|
||||
// Store the full response for the session
|
||||
session.messages.push({
|
||||
role: 'assistant',
|
||||
content: messageContent,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
|
||||
return; // Skip the rest of the processing
|
||||
} catch (toolError) {
|
||||
log.error(`Error executing tools: ${toolError}`);
|
||||
@ -716,48 +770,55 @@ class RestChatService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Execute tool calls from the LLM response
|
||||
* @param response The LLM response containing tool calls
|
||||
*/
|
||||
private async executeToolCalls(response: any): Promise<Message[]> {
|
||||
log.info(`========== REST SERVICE TOOL EXECUTION FLOW ==========`);
|
||||
log.info(`Entered executeToolCalls method in REST chat service`);
|
||||
|
||||
if (!response.tool_calls || response.tool_calls.length === 0) {
|
||||
log.info(`No tool calls to execute, returning early`);
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
log.info(`Executing ${response.tool_calls.length} tool calls from REST chat service`);
|
||||
|
||||
|
||||
// Import tool registry directly to avoid circular dependencies
|
||||
const toolRegistry = (await import('./tools/tool_registry.js')).default;
|
||||
|
||||
|
||||
// Check if tools are available
|
||||
const availableTools = toolRegistry.getAllTools();
|
||||
log.info(`Available tools in registry: ${availableTools.length}`);
|
||||
|
||||
if (availableTools.length === 0) {
|
||||
log.error('No tools available in registry for execution');
|
||||
|
||||
|
||||
// Try to initialize tools
|
||||
try {
|
||||
const toolInitializer = await import('./tools/tool_initializer.js');
|
||||
await toolInitializer.default.initializeTools();
|
||||
log.info(`Initialized ${toolRegistry.getAllTools().length} tools`);
|
||||
} catch (error) {
|
||||
log.error(`Failed to initialize tools: ${error}`);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Failed to initialize tools: ${errorMessage}`);
|
||||
throw new Error('Tool execution failed: No tools available');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Execute each tool call and collect results
|
||||
const toolResults = await Promise.all(response.tool_calls.map(async (toolCall: any) => {
|
||||
try {
|
||||
log.info(`Executing tool: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`);
|
||||
|
||||
|
||||
// Get the tool from registry
|
||||
const tool = toolRegistry.getTool(toolCall.function.name);
|
||||
if (!tool) {
|
||||
throw new Error(`Tool not found: ${toolCall.function.name}`);
|
||||
}
|
||||
|
||||
|
||||
// Parse arguments
|
||||
let args;
|
||||
if (typeof toolCall.function.arguments === 'string') {
|
||||
@ -765,7 +826,7 @@ class RestChatService {
|
||||
args = JSON.parse(toolCall.function.arguments);
|
||||
} catch (e) {
|
||||
log.error(`Failed to parse tool arguments: ${e.message}`);
|
||||
|
||||
|
||||
// Try cleanup and retry
|
||||
try {
|
||||
const cleaned = toolCall.function.arguments
|
||||
@ -773,7 +834,7 @@ class RestChatService {
|
||||
.replace(/\\"/g, '"') // Replace escaped quotes
|
||||
.replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":') // Replace single quotes around property names
|
||||
.replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":'); // Add quotes around unquoted property names
|
||||
|
||||
|
||||
args = JSON.parse(cleaned);
|
||||
} catch (cleanErr) {
|
||||
// If all parsing fails, use as-is
|
||||
@ -783,23 +844,23 @@ class RestChatService {
|
||||
} else {
|
||||
args = toolCall.function.arguments;
|
||||
}
|
||||
|
||||
|
||||
// Log what we're about to execute
|
||||
log.info(`Executing tool with arguments: ${JSON.stringify(args)}`);
|
||||
|
||||
|
||||
// Execute the tool and get result
|
||||
const startTime = Date.now();
|
||||
const result = await tool.execute(args);
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
|
||||
log.info(`Tool execution completed in ${executionTime}ms`);
|
||||
|
||||
|
||||
// Log the result
|
||||
const resultPreview = typeof result === 'string'
|
||||
const resultPreview = typeof result === 'string'
|
||||
? result.substring(0, 100) + (result.length > 100 ? '...' : '')
|
||||
: JSON.stringify(result).substring(0, 100) + '...';
|
||||
log.info(`Tool result: ${resultPreview}`);
|
||||
|
||||
|
||||
// Format result as a proper message
|
||||
return {
|
||||
role: 'tool',
|
||||
@ -809,7 +870,7 @@ class RestChatService {
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error executing tool ${toolCall.function.name}: ${error.message}`);
|
||||
|
||||
|
||||
// Return error as tool result
|
||||
return {
|
||||
role: 'tool',
|
||||
@ -819,7 +880,7 @@ class RestChatService {
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
log.info(`Completed execution of ${toolResults.length} tools`);
|
||||
return toolResults;
|
||||
}
|
||||
@ -1042,6 +1103,33 @@ class RestChatService {
|
||||
throw new Error(`Failed to delete session: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that LLM tools are properly initialized
|
||||
* This helps prevent issues with tool execution
|
||||
*/
|
||||
private async ensureToolsInitialized(): Promise<void> {
|
||||
try {
|
||||
log.info("Initializing LLM agent tools...");
|
||||
|
||||
// Initialize LLM tools without depending on aiServiceManager
|
||||
const toolInitializer = await import('./tools/tool_initializer.js');
|
||||
await toolInitializer.default.initializeTools();
|
||||
|
||||
// Get the tool registry to check if tools were initialized
|
||||
const toolRegistry = (await import('./tools/tool_registry.js')).default;
|
||||
const tools = toolRegistry.getAllTools();
|
||||
log.info(`LLM tools initialized successfully: ${tools.length} tools available`);
|
||||
|
||||
// Log available tools
|
||||
if (tools.length > 0) {
|
||||
log.info(`Available tools: ${tools.map(t => t.definition.function.name).join(', ')}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error(`Error initializing LLM tools: ${error.message}`);
|
||||
// Don't throw, just log the error to prevent breaking the pipeline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
|
226
src/services/llm/tools/attribute_manager_tool.ts
Normal file
226
src/services/llm/tools/attribute_manager_tool.ts
Normal file
@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Attribute Manager Tool
|
||||
*
|
||||
* This tool allows the LLM to add, remove, or modify note attributes in Trilium.
|
||||
*/
|
||||
|
||||
import type { Tool, ToolHandler } from './tool_interfaces.js';
|
||||
import log from '../../log.js';
|
||||
import becca from '../../../becca/becca.js';
|
||||
import attributes from '../../attributes.js';
|
||||
|
||||
/**
|
||||
* Definition of the attribute manager tool
|
||||
*/
|
||||
export const attributeManagerToolDefinition: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'manage_attributes',
|
||||
description: 'Add, remove, or modify attributes (labels/relations) on a note',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
noteId: {
|
||||
type: 'string',
|
||||
description: 'ID of the note to manage attributes for'
|
||||
},
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'Action to perform on the attribute',
|
||||
enum: ['add', 'remove', 'update', 'list']
|
||||
},
|
||||
attributeName: {
|
||||
type: 'string',
|
||||
description: 'Name of the attribute (e.g., "#tag" for a label, or "relation" for a relation)'
|
||||
},
|
||||
attributeValue: {
|
||||
type: 'string',
|
||||
description: 'Value of the attribute (for add/update actions). Not needed for label-type attributes.'
|
||||
}
|
||||
},
|
||||
required: ['noteId', 'action']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Attribute manager tool implementation
|
||||
*/
|
||||
export class AttributeManagerTool implements ToolHandler {
|
||||
public definition: Tool = attributeManagerToolDefinition;
|
||||
|
||||
/**
|
||||
* Execute the attribute manager tool
|
||||
*/
|
||||
public async execute(args: { noteId: string, action: string, attributeName?: string, attributeValue?: string }): Promise<string | object> {
|
||||
try {
|
||||
const { noteId, action, attributeName, attributeValue } = args;
|
||||
|
||||
log.info(`Executing manage_attributes tool - NoteID: "${noteId}", Action: ${action}, AttributeName: ${attributeName || 'not specified'}`);
|
||||
|
||||
// Get the note from becca
|
||||
const note = becca.notes[noteId];
|
||||
|
||||
if (!note) {
|
||||
log.info(`Note with ID ${noteId} not found - returning error`);
|
||||
return `Error: Note with ID ${noteId} not found`;
|
||||
}
|
||||
|
||||
log.info(`Found note: "${note.title}" (Type: ${note.type})`);
|
||||
|
||||
// List all existing attributes
|
||||
if (action === 'list') {
|
||||
const noteAttributes = note.getOwnedAttributes();
|
||||
log.info(`Listing ${noteAttributes.length} attributes for note "${note.title}"`);
|
||||
|
||||
const formattedAttributes = noteAttributes.map(attr => ({
|
||||
name: attr.name,
|
||||
value: attr.value,
|
||||
type: attr.type
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
attributeCount: noteAttributes.length,
|
||||
attributes: formattedAttributes
|
||||
};
|
||||
}
|
||||
|
||||
// For other actions, attribute name is required
|
||||
if (!attributeName) {
|
||||
return 'Error: attributeName is required for add, remove, and update actions';
|
||||
}
|
||||
|
||||
// Perform the requested action
|
||||
if (action === 'add') {
|
||||
// Add a new attribute
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
// For label-type attributes (starting with #), no value is needed
|
||||
const isLabel = attributeName.startsWith('#');
|
||||
const value = isLabel ? '' : (attributeValue || '');
|
||||
|
||||
// Check if attribute already exists
|
||||
const existingAttrs = note.getOwnedAttributes()
|
||||
.filter(attr => attr.name === attributeName && attr.value === value);
|
||||
|
||||
if (existingAttrs.length > 0) {
|
||||
log.info(`Attribute ${attributeName}=${value} already exists on note "${note.title}"`);
|
||||
return {
|
||||
success: false,
|
||||
message: `Attribute ${attributeName}=${value || ''} already exists on note "${note.title}"`
|
||||
};
|
||||
}
|
||||
|
||||
// Create the attribute
|
||||
await attributes.createAttribute(noteId, attributeName, value);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
log.info(`Added attribute ${attributeName}=${value || ''} in ${duration}ms`);
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
action: 'add',
|
||||
attributeName: attributeName,
|
||||
attributeValue: value,
|
||||
message: `Added attribute ${attributeName}=${value || ''} to note "${note.title}"`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error adding attribute: ${error.message || String(error)}`);
|
||||
return `Error: ${error.message || String(error)}`;
|
||||
}
|
||||
} else if (action === 'remove') {
|
||||
// Remove an attribute
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Find the attribute to remove
|
||||
const attributesToRemove = note.getOwnedAttributes()
|
||||
.filter(attr => attr.name === attributeName &&
|
||||
(attributeValue === undefined || attr.value === attributeValue));
|
||||
|
||||
if (attributesToRemove.length === 0) {
|
||||
log.info(`Attribute ${attributeName} not found on note "${note.title}"`);
|
||||
return {
|
||||
success: false,
|
||||
message: `Attribute ${attributeName} not found on note "${note.title}"`
|
||||
};
|
||||
}
|
||||
|
||||
// Remove all matching attributes
|
||||
for (const attr of attributesToRemove) {
|
||||
await attributes.deleteAttribute(attr.attributeId);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
log.info(`Removed ${attributesToRemove.length} attribute(s) in ${duration}ms`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
action: 'remove',
|
||||
attributeName: attributeName,
|
||||
attributesRemoved: attributesToRemove.length,
|
||||
message: `Removed ${attributesToRemove.length} attribute(s) from note "${note.title}"`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error removing attribute: ${error.message || String(error)}`);
|
||||
return `Error: ${error.message || String(error)}`;
|
||||
}
|
||||
} else if (action === 'update') {
|
||||
// Update an attribute
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (attributeValue === undefined) {
|
||||
return 'Error: attributeValue is required for update action';
|
||||
}
|
||||
|
||||
// Find the attribute to update
|
||||
const attributesToUpdate = note.getOwnedAttributes()
|
||||
.filter(attr => attr.name === attributeName);
|
||||
|
||||
if (attributesToUpdate.length === 0) {
|
||||
log.info(`Attribute ${attributeName} not found on note "${note.title}"`);
|
||||
return {
|
||||
success: false,
|
||||
message: `Attribute ${attributeName} not found on note "${note.title}"`
|
||||
};
|
||||
}
|
||||
|
||||
// Update all matching attributes
|
||||
for (const attr of attributesToUpdate) {
|
||||
await attributes.updateAttributeValue(attr.attributeId, attributeValue);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
log.info(`Updated ${attributesToUpdate.length} attribute(s) in ${duration}ms`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
action: 'update',
|
||||
attributeName: attributeName,
|
||||
attributeValue: attributeValue,
|
||||
attributesUpdated: attributesToUpdate.length,
|
||||
message: `Updated ${attributesToUpdate.length} attribute(s) on note "${note.title}"`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error updating attribute: ${error.message || String(error)}`);
|
||||
return `Error: ${error.message || String(error)}`;
|
||||
}
|
||||
} else {
|
||||
return `Error: Unsupported action "${action}". Supported actions are: add, remove, update, list`;
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error(`Error executing manage_attributes tool: ${error.message || String(error)}`);
|
||||
return `Error: ${error.message || String(error)}`;
|
||||
}
|
||||
}
|
||||
}
|
481
src/services/llm/tools/calendar_integration_tool.ts
Normal file
481
src/services/llm/tools/calendar_integration_tool.ts
Normal file
@ -0,0 +1,481 @@
|
||||
/**
|
||||
* Calendar Integration Tool
|
||||
*
|
||||
* This tool allows the LLM to find date-related notes or create date-based entries.
|
||||
*/
|
||||
|
||||
import type { Tool, ToolHandler } from './tool_interfaces.js';
|
||||
import log from '../../log.js';
|
||||
import becca from '../../../becca/becca.js';
|
||||
import notes from '../../notes.js';
|
||||
import attributes from '../../attributes.js';
|
||||
import dateNotes from '../../date_notes.js';
|
||||
|
||||
/**
|
||||
* Definition of the calendar integration tool
|
||||
*/
|
||||
export const calendarIntegrationToolDefinition: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'calendar_integration',
|
||||
description: 'Find date-related notes or create date-based entries',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'Action to perform',
|
||||
enum: ['find_date_notes', 'create_date_note', 'find_notes_with_date_range', 'get_daily_note']
|
||||
},
|
||||
date: {
|
||||
type: 'string',
|
||||
description: 'Date in ISO format (YYYY-MM-DD) for the note'
|
||||
},
|
||||
dateStart: {
|
||||
type: 'string',
|
||||
description: 'Start date in ISO format (YYYY-MM-DD) for date range queries'
|
||||
},
|
||||
dateEnd: {
|
||||
type: 'string',
|
||||
description: 'End date in ISO format (YYYY-MM-DD) for date range queries'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Title for creating a new date-related note'
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'Content for creating a new date-related note'
|
||||
},
|
||||
parentNoteId: {
|
||||
type: 'string',
|
||||
description: 'Optional parent note ID for the new date note. If not specified, will use default calendar container.'
|
||||
}
|
||||
},
|
||||
required: ['action']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Calendar integration tool implementation
|
||||
*/
|
||||
export class CalendarIntegrationTool implements ToolHandler {
|
||||
public definition: Tool = calendarIntegrationToolDefinition;
|
||||
|
||||
/**
|
||||
* Execute the calendar integration tool
|
||||
*/
|
||||
public async execute(args: {
|
||||
action: string,
|
||||
date?: string,
|
||||
dateStart?: string,
|
||||
dateEnd?: string,
|
||||
title?: string,
|
||||
content?: string,
|
||||
parentNoteId?: string
|
||||
}): Promise<string | object> {
|
||||
try {
|
||||
const { action, date, dateStart, dateEnd, title, content, parentNoteId } = args;
|
||||
|
||||
log.info(`Executing calendar_integration tool - Action: ${action}, Date: ${date || 'not specified'}`);
|
||||
|
||||
// Handle different actions
|
||||
if (action === 'find_date_notes') {
|
||||
return await this.findDateNotes(date);
|
||||
} else if (action === 'create_date_note') {
|
||||
return await this.createDateNote(date, title, content, parentNoteId);
|
||||
} else if (action === 'find_notes_with_date_range') {
|
||||
return await this.findNotesWithDateRange(dateStart, dateEnd);
|
||||
} else if (action === 'get_daily_note') {
|
||||
return await this.getDailyNote(date);
|
||||
} else {
|
||||
return `Error: Unsupported action "${action}". Supported actions are: find_date_notes, create_date_note, find_notes_with_date_range, get_daily_note`;
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error(`Error executing calendar_integration tool: ${error.message || String(error)}`);
|
||||
return `Error: ${error.message || String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find notes related to a specific date
|
||||
*/
|
||||
private async findDateNotes(date?: string): Promise<object> {
|
||||
if (!date) {
|
||||
// If no date is provided, use today's date
|
||||
const today = new Date();
|
||||
date = today.toISOString().split('T')[0];
|
||||
log.info(`No date specified, using today's date: ${date}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate date format
|
||||
if (!this.isValidDate(date)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid date format. Please use YYYY-MM-DD format.`
|
||||
};
|
||||
}
|
||||
|
||||
log.info(`Finding notes related to date: ${date}`);
|
||||
|
||||
// Get notes with dateNote attribute matching this date
|
||||
const notesWithDateAttribute = this.getNotesWithDateAttribute(date);
|
||||
log.info(`Found ${notesWithDateAttribute.length} notes with date attribute for ${date}`);
|
||||
|
||||
// Get year, month, day notes if they exist
|
||||
const yearMonthDayNotes = await this.getYearMonthDayNotes(date);
|
||||
|
||||
// Format results
|
||||
return {
|
||||
success: true,
|
||||
date: date,
|
||||
yearNote: yearMonthDayNotes.yearNote ? {
|
||||
noteId: yearMonthDayNotes.yearNote.noteId,
|
||||
title: yearMonthDayNotes.yearNote.title
|
||||
} : null,
|
||||
monthNote: yearMonthDayNotes.monthNote ? {
|
||||
noteId: yearMonthDayNotes.monthNote.noteId,
|
||||
title: yearMonthDayNotes.monthNote.title
|
||||
} : null,
|
||||
dayNote: yearMonthDayNotes.dayNote ? {
|
||||
noteId: yearMonthDayNotes.dayNote.noteId,
|
||||
title: yearMonthDayNotes.dayNote.title
|
||||
} : null,
|
||||
relatedNotes: notesWithDateAttribute.map(note => ({
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
type: note.type
|
||||
})),
|
||||
message: `Found ${notesWithDateAttribute.length} notes related to date ${date}`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error finding date notes: ${error.message || String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new note associated with a date
|
||||
*/
|
||||
private async createDateNote(date?: string, title?: string, content?: string, parentNoteId?: string): Promise<object> {
|
||||
if (!date) {
|
||||
// If no date is provided, use today's date
|
||||
const today = new Date();
|
||||
date = today.toISOString().split('T')[0];
|
||||
log.info(`No date specified, using today's date: ${date}`);
|
||||
}
|
||||
|
||||
// Validate date format
|
||||
if (!this.isValidDate(date)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid date format. Please use YYYY-MM-DD format.`
|
||||
};
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
title = `Note for ${date}`;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
content = `<p>Date note created for ${date}</p>`;
|
||||
}
|
||||
|
||||
try {
|
||||
log.info(`Creating new date note for ${date} with title "${title}"`);
|
||||
|
||||
// If no parent is specified, try to find appropriate date container
|
||||
if (!parentNoteId) {
|
||||
// Get or create day note to use as parent
|
||||
const dateComponents = this.parseDateString(date);
|
||||
if (!dateComponents) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid date format. Please use YYYY-MM-DD format.`
|
||||
};
|
||||
}
|
||||
|
||||
// Use the date string directly with getDayNote
|
||||
const dayNote = await dateNotes.getDayNote(date);
|
||||
|
||||
if (dayNote) {
|
||||
parentNoteId = dayNote.noteId;
|
||||
log.info(`Using day note ${dayNote.title} (${parentNoteId}) as parent`);
|
||||
} else {
|
||||
// Use root if day note couldn't be found/created
|
||||
parentNoteId = 'root';
|
||||
log.info(`Could not find/create day note, using root as parent`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate parent note exists
|
||||
const parent = becca.notes[parentNoteId];
|
||||
if (!parent) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Parent note with ID ${parentNoteId} not found. Please specify a valid parent note ID.`
|
||||
};
|
||||
}
|
||||
|
||||
// Create the new note
|
||||
const createStartTime = Date.now();
|
||||
const noteId = await notes.createNewNote({
|
||||
parentNoteId: parent.noteId,
|
||||
title: title,
|
||||
content: content,
|
||||
type: 'text',
|
||||
mime: 'text/html'
|
||||
});
|
||||
const createDuration = Date.now() - createStartTime;
|
||||
|
||||
if (!noteId) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to create date note. An unknown error occurred.`
|
||||
};
|
||||
}
|
||||
|
||||
log.info(`Created new note with ID ${noteId} in ${createDuration}ms`);
|
||||
|
||||
// Add dateNote attribute with the specified date
|
||||
const attrStartTime = Date.now();
|
||||
await attributes.createLabel(noteId, 'dateNote', date);
|
||||
const attrDuration = Date.now() - attrStartTime;
|
||||
|
||||
log.info(`Added dateNote=${date} attribute in ${attrDuration}ms`);
|
||||
|
||||
// Return the new note information
|
||||
return {
|
||||
success: true,
|
||||
noteId: noteId,
|
||||
date: date,
|
||||
title: title,
|
||||
message: `Created new date note "${title}" for ${date}`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error creating date note: ${error.message || String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find notes with date attributes in a specified range
|
||||
*/
|
||||
private async findNotesWithDateRange(dateStart?: string, dateEnd?: string): Promise<object> {
|
||||
if (!dateStart || !dateEnd) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Both dateStart and dateEnd are required for find_notes_with_date_range action.`
|
||||
};
|
||||
}
|
||||
|
||||
// Validate date formats
|
||||
if (!this.isValidDate(dateStart) || !this.isValidDate(dateEnd)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid date format. Please use YYYY-MM-DD format.`
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
log.info(`Finding notes with date attributes in range ${dateStart} to ${dateEnd}`);
|
||||
|
||||
// Get all notes with dateNote attribute
|
||||
const allNotes = this.getAllNotesWithDateAttribute();
|
||||
|
||||
// Filter by date range
|
||||
const startDate = new Date(dateStart);
|
||||
const endDate = new Date(dateEnd);
|
||||
|
||||
const filteredNotes = allNotes.filter(note => {
|
||||
const dateAttr = note.getOwnedAttributes()
|
||||
.find((attr: any) => attr.name === 'dateNote');
|
||||
|
||||
if (dateAttr && dateAttr.value) {
|
||||
const noteDate = new Date(dateAttr.value);
|
||||
return noteDate >= startDate && noteDate <= endDate;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
log.info(`Found ${filteredNotes.length} notes in date range`);
|
||||
|
||||
// Sort notes by date
|
||||
filteredNotes.sort((a, b) => {
|
||||
const aDateAttr = a.getOwnedAttributes().find((attr: any) => attr.name === 'dateNote');
|
||||
const bDateAttr = b.getOwnedAttributes().find((attr: any) => attr.name === 'dateNote');
|
||||
|
||||
if (aDateAttr && bDateAttr) {
|
||||
const aDate = new Date(aDateAttr.value);
|
||||
const bDate = new Date(bDateAttr.value);
|
||||
return aDate.getTime() - bDate.getTime();
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Format results
|
||||
return {
|
||||
success: true,
|
||||
dateStart: dateStart,
|
||||
dateEnd: dateEnd,
|
||||
noteCount: filteredNotes.length,
|
||||
notes: filteredNotes.map(note => {
|
||||
const dateAttr = note.getOwnedAttributes().find((attr: any) => attr.name === 'dateNote');
|
||||
return {
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
type: note.type,
|
||||
date: dateAttr ? dateAttr.value : null
|
||||
};
|
||||
}),
|
||||
message: `Found ${filteredNotes.length} notes in date range ${dateStart} to ${dateEnd}`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error finding notes in date range: ${error.message || String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a daily note for a specific date
|
||||
*/
|
||||
private async getDailyNote(date?: string): Promise<object> {
|
||||
if (!date) {
|
||||
// If no date is provided, use today's date
|
||||
const today = new Date();
|
||||
date = today.toISOString().split('T')[0];
|
||||
log.info(`No date specified, using today's date: ${date}`);
|
||||
}
|
||||
|
||||
// Validate date format
|
||||
if (!this.isValidDate(date)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid date format. Please use YYYY-MM-DD format.`
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
log.info(`Getting daily note for ${date}`);
|
||||
|
||||
// Get or create day note - directly pass the date string
|
||||
const startTime = Date.now();
|
||||
const dayNote = await dateNotes.getDayNote(date);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (!dayNote) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Could not find or create daily note for ${date}`
|
||||
};
|
||||
}
|
||||
|
||||
log.info(`Retrieved/created daily note for ${date} in ${duration}ms`);
|
||||
|
||||
// Get parent month and year notes
|
||||
const yearStr = date.substring(0, 4);
|
||||
const monthStr = date.substring(0, 7);
|
||||
|
||||
const monthNote = await dateNotes.getMonthNote(monthStr);
|
||||
const yearNote = await dateNotes.getYearNote(yearStr);
|
||||
|
||||
// Return the note information
|
||||
return {
|
||||
success: true,
|
||||
date: date,
|
||||
dayNote: {
|
||||
noteId: dayNote.noteId,
|
||||
title: dayNote.title,
|
||||
content: await dayNote.getContent()
|
||||
},
|
||||
monthNote: monthNote ? {
|
||||
noteId: monthNote.noteId,
|
||||
title: monthNote.title
|
||||
} : null,
|
||||
yearNote: yearNote ? {
|
||||
noteId: yearNote.noteId,
|
||||
title: yearNote.title
|
||||
} : null,
|
||||
message: `Retrieved daily note for ${date}`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error getting daily note: ${error.message || String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get notes with a specific date attribute
|
||||
*/
|
||||
private getNotesWithDateAttribute(date: string): any[] {
|
||||
// Find notes with matching dateNote attribute
|
||||
return attributes.getNotesWithLabel('dateNote', date) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get all notes with any date attribute
|
||||
*/
|
||||
private getAllNotesWithDateAttribute(): any[] {
|
||||
// Find all notes with dateNote attribute
|
||||
return attributes.getNotesWithLabel('dateNote') || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get year, month, and day notes for a date
|
||||
*/
|
||||
private async getYearMonthDayNotes(date: string): Promise<{
|
||||
yearNote: any | null;
|
||||
monthNote: any | null;
|
||||
dayNote: any | null;
|
||||
}> {
|
||||
if (!this.isValidDate(date)) {
|
||||
return { yearNote: null, monthNote: null, dayNote: null };
|
||||
}
|
||||
|
||||
// Extract the year and month from the date string
|
||||
const yearStr = date.substring(0, 4);
|
||||
const monthStr = date.substring(0, 7);
|
||||
|
||||
// Use the dateNotes service to get the notes
|
||||
const yearNote = await dateNotes.getYearNote(yearStr);
|
||||
const monthNote = await dateNotes.getMonthNote(monthStr);
|
||||
const dayNote = await dateNotes.getDayNote(date);
|
||||
|
||||
return { yearNote, monthNote, dayNote };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to validate date string format
|
||||
*/
|
||||
private isValidDate(dateString: string): boolean {
|
||||
const regex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
if (!regex.test(dateString)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toString() !== 'Invalid Date';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to parse date string into components
|
||||
*/
|
||||
private parseDateString(dateString: string): { year: number; month: number; day: number } | null {
|
||||
if (!this.isValidDate(dateString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [yearStr, monthStr, dayStr] = dateString.split('-');
|
||||
|
||||
return {
|
||||
year: parseInt(yearStr, 10),
|
||||
month: parseInt(monthStr, 10),
|
||||
day: parseInt(dayStr, 10)
|
||||
};
|
||||
}
|
||||
}
|
544
src/services/llm/tools/content_extraction_tool.ts
Normal file
544
src/services/llm/tools/content_extraction_tool.ts
Normal file
@ -0,0 +1,544 @@
|
||||
/**
|
||||
* Content Extraction Tool
|
||||
*
|
||||
* This tool allows the LLM to extract structured information from notes.
|
||||
*/
|
||||
|
||||
import type { Tool, ToolHandler } from './tool_interfaces.js';
|
||||
import log from '../../log.js';
|
||||
import becca from '../../../becca/becca.js';
|
||||
|
||||
/**
|
||||
* Definition of the content extraction tool
|
||||
*/
|
||||
export const contentExtractionToolDefinition: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'extract_content',
|
||||
description: 'Extract structured information from a note\'s content, such as lists, tables, or specific sections',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
noteId: {
|
||||
type: 'string',
|
||||
description: 'ID of the note to extract content from'
|
||||
},
|
||||
extractionType: {
|
||||
type: 'string',
|
||||
description: 'Type of content to extract',
|
||||
enum: ['lists', 'tables', 'headings', 'codeBlocks', 'all']
|
||||
},
|
||||
format: {
|
||||
type: 'string',
|
||||
description: 'Format to return the extracted content in',
|
||||
enum: ['json', 'markdown', 'text']
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Optional search query to filter extracted content (e.g., "tasks related to finance")'
|
||||
}
|
||||
},
|
||||
required: ['noteId', 'extractionType']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Content extraction tool implementation
|
||||
*/
|
||||
export class ContentExtractionTool implements ToolHandler {
|
||||
public definition: Tool = contentExtractionToolDefinition;
|
||||
|
||||
/**
|
||||
* Execute the content extraction tool
|
||||
*/
|
||||
public async execute(args: {
|
||||
noteId: string,
|
||||
extractionType: 'lists' | 'tables' | 'headings' | 'codeBlocks' | 'all',
|
||||
format?: 'json' | 'markdown' | 'text',
|
||||
query?: string
|
||||
}): Promise<string | object> {
|
||||
try {
|
||||
const { noteId, extractionType, format = 'json', query } = args;
|
||||
|
||||
log.info(`Executing extract_content tool - NoteID: "${noteId}", Type: ${extractionType}, Format: ${format}`);
|
||||
|
||||
// Get the note from becca
|
||||
const note = becca.notes[noteId];
|
||||
|
||||
if (!note) {
|
||||
log.info(`Note with ID ${noteId} not found - returning error`);
|
||||
return `Error: Note with ID ${noteId} not found`;
|
||||
}
|
||||
|
||||
log.info(`Found note: "${note.title}" (Type: ${note.type})`);
|
||||
|
||||
// Get the note content
|
||||
const content = await note.getContent();
|
||||
if (!content) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Note content is empty'
|
||||
};
|
||||
}
|
||||
|
||||
log.info(`Retrieved note content, length: ${content.length} chars`);
|
||||
|
||||
// Extract the requested content
|
||||
const extractedContent: any = {};
|
||||
|
||||
if (extractionType === 'lists' || extractionType === 'all') {
|
||||
extractedContent.lists = this.extractLists(content);
|
||||
log.info(`Extracted ${extractedContent.lists.length} lists`);
|
||||
}
|
||||
|
||||
if (extractionType === 'tables' || extractionType === 'all') {
|
||||
extractedContent.tables = this.extractTables(content);
|
||||
log.info(`Extracted ${extractedContent.tables.length} tables`);
|
||||
}
|
||||
|
||||
if (extractionType === 'headings' || extractionType === 'all') {
|
||||
extractedContent.headings = this.extractHeadings(content);
|
||||
log.info(`Extracted ${extractedContent.headings.length} headings`);
|
||||
}
|
||||
|
||||
if (extractionType === 'codeBlocks' || extractionType === 'all') {
|
||||
extractedContent.codeBlocks = this.extractCodeBlocks(content);
|
||||
log.info(`Extracted ${extractedContent.codeBlocks.length} code blocks`);
|
||||
}
|
||||
|
||||
// Filter by query if provided
|
||||
if (query) {
|
||||
log.info(`Filtering extracted content with query: "${query}"`);
|
||||
this.filterContentByQuery(extractedContent, query);
|
||||
}
|
||||
|
||||
// Format the response based on requested format
|
||||
if (format === 'markdown') {
|
||||
return this.formatAsMarkdown(extractedContent, extractionType);
|
||||
} else if (format === 'text') {
|
||||
return this.formatAsText(extractedContent, extractionType);
|
||||
} else {
|
||||
// Default to JSON format
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
extractionType,
|
||||
content: extractedContent
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error(`Error executing extract_content tool: ${error.message || String(error)}`);
|
||||
return `Error: ${error.message || String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract lists from HTML content
|
||||
*/
|
||||
private extractLists(content: string): Array<{ type: string, items: string[] }> {
|
||||
const lists = [];
|
||||
|
||||
// Extract unordered lists
|
||||
const ulRegex = /<ul[^>]*>([\s\S]*?)<\/ul>/gi;
|
||||
let ulMatch;
|
||||
|
||||
while ((ulMatch = ulRegex.exec(content)) !== null) {
|
||||
const listContent = ulMatch[1];
|
||||
const items = this.extractListItems(listContent);
|
||||
|
||||
if (items.length > 0) {
|
||||
lists.push({
|
||||
type: 'unordered',
|
||||
items
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract ordered lists
|
||||
const olRegex = /<ol[^>]*>([\s\S]*?)<\/ol>/gi;
|
||||
let olMatch;
|
||||
|
||||
while ((olMatch = olRegex.exec(content)) !== null) {
|
||||
const listContent = olMatch[1];
|
||||
const items = this.extractListItems(listContent);
|
||||
|
||||
if (items.length > 0) {
|
||||
lists.push({
|
||||
type: 'ordered',
|
||||
items
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return lists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract list items from list content
|
||||
*/
|
||||
private extractListItems(listContent: string): string[] {
|
||||
const items = [];
|
||||
const itemRegex = /<li[^>]*>([\s\S]*?)<\/li>/gi;
|
||||
let itemMatch;
|
||||
|
||||
while ((itemMatch = itemRegex.exec(listContent)) !== null) {
|
||||
const itemText = this.stripHtml(itemMatch[1]).trim();
|
||||
if (itemText) {
|
||||
items.push(itemText);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tables from HTML content
|
||||
*/
|
||||
private extractTables(content: string): Array<{ headers: string[], rows: string[][] }> {
|
||||
const tables = [];
|
||||
const tableRegex = /<table[^>]*>([\s\S]*?)<\/table>/gi;
|
||||
let tableMatch;
|
||||
|
||||
while ((tableMatch = tableRegex.exec(content)) !== null) {
|
||||
const tableContent = tableMatch[1];
|
||||
const headers = [];
|
||||
const rows = [];
|
||||
|
||||
// Extract table headers
|
||||
const headerRegex = /<th[^>]*>([\s\S]*?)<\/th>/gi;
|
||||
let headerMatch;
|
||||
while ((headerMatch = headerRegex.exec(tableContent)) !== null) {
|
||||
headers.push(this.stripHtml(headerMatch[1]).trim());
|
||||
}
|
||||
|
||||
// Extract table rows
|
||||
const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
||||
let rowMatch;
|
||||
while ((rowMatch = rowRegex.exec(tableContent)) !== null) {
|
||||
const rowContent = rowMatch[1];
|
||||
const cells = [];
|
||||
|
||||
const cellRegex = /<td[^>]*>([\s\S]*?)<\/td>/gi;
|
||||
let cellMatch;
|
||||
while ((cellMatch = cellRegex.exec(rowContent)) !== null) {
|
||||
cells.push(this.stripHtml(cellMatch[1]).trim());
|
||||
}
|
||||
|
||||
if (cells.length > 0) {
|
||||
rows.push(cells);
|
||||
}
|
||||
}
|
||||
|
||||
if (headers.length > 0 || rows.length > 0) {
|
||||
tables.push({
|
||||
headers,
|
||||
rows
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract headings from HTML content
|
||||
*/
|
||||
private extractHeadings(content: string): Array<{ level: number, text: string }> {
|
||||
const headings = [];
|
||||
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const headingRegex = new RegExp(`<h${i}[^>]*>([\s\S]*?)<\/h${i}>`, 'gi');
|
||||
let headingMatch;
|
||||
|
||||
while ((headingMatch = headingRegex.exec(content)) !== null) {
|
||||
const headingText = this.stripHtml(headingMatch[1]).trim();
|
||||
if (headingText) {
|
||||
headings.push({
|
||||
level: i,
|
||||
text: headingText
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract code blocks from HTML content
|
||||
*/
|
||||
private extractCodeBlocks(content: string): Array<{ language?: string, code: string }> {
|
||||
const codeBlocks = [];
|
||||
|
||||
// Look for <pre> and <code> blocks
|
||||
const preRegex = /<pre[^>]*>([\s\S]*?)<\/pre>/gi;
|
||||
let preMatch;
|
||||
|
||||
while ((preMatch = preRegex.exec(content)) !== null) {
|
||||
const preContent = preMatch[1];
|
||||
// Check if there's a nested <code> tag
|
||||
const codeMatch = /<code[^>]*>([\s\S]*?)<\/code>/i.exec(preContent);
|
||||
|
||||
if (codeMatch) {
|
||||
// Extract language if it's in the class attribute
|
||||
const classMatch = /class="[^"]*language-([^"\s]+)[^"]*"/i.exec(preMatch[0]);
|
||||
codeBlocks.push({
|
||||
language: classMatch ? classMatch[1] : undefined,
|
||||
code: this.decodeHtmlEntities(codeMatch[1]).trim()
|
||||
});
|
||||
} else {
|
||||
// Just a <pre> without <code>
|
||||
codeBlocks.push({
|
||||
code: this.decodeHtmlEntities(preContent).trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also look for standalone <code> blocks not inside <pre>
|
||||
const standaloneCodeRegex = /(?<!<pre[^>]*>[\s\S]*?)<code[^>]*>([\s\S]*?)<\/code>/gi;
|
||||
let standaloneCodeMatch;
|
||||
|
||||
while ((standaloneCodeMatch = standaloneCodeRegex.exec(content)) !== null) {
|
||||
codeBlocks.push({
|
||||
code: this.decodeHtmlEntities(standaloneCodeMatch[1]).trim()
|
||||
});
|
||||
}
|
||||
|
||||
return codeBlocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter content by query
|
||||
*/
|
||||
private filterContentByQuery(content: any, query: string): void {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
// Filter lists
|
||||
if (content.lists) {
|
||||
content.lists = content.lists.filter(list => {
|
||||
// Keep the list if any item matches the query
|
||||
return list.items.some(item => item.toLowerCase().includes(lowerQuery));
|
||||
});
|
||||
|
||||
// Also filter individual items in each list
|
||||
content.lists.forEach(list => {
|
||||
list.items = list.items.filter(item => item.toLowerCase().includes(lowerQuery));
|
||||
});
|
||||
}
|
||||
|
||||
// Filter headings
|
||||
if (content.headings) {
|
||||
content.headings = content.headings.filter(heading =>
|
||||
heading.text.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter tables
|
||||
if (content.tables) {
|
||||
content.tables = content.tables.filter(table => {
|
||||
// Check headers
|
||||
const headerMatch = table.headers.some(header =>
|
||||
header.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
|
||||
// Check cells
|
||||
const cellMatch = table.rows.some(row =>
|
||||
row.some(cell => cell.toLowerCase().includes(lowerQuery))
|
||||
);
|
||||
|
||||
return headerMatch || cellMatch;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter code blocks
|
||||
if (content.codeBlocks) {
|
||||
content.codeBlocks = content.codeBlocks.filter(block =>
|
||||
block.code.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format extracted content as Markdown
|
||||
*/
|
||||
private formatAsMarkdown(content: any, extractionType: string): string {
|
||||
let markdown = '';
|
||||
|
||||
if (extractionType === 'lists' || extractionType === 'all') {
|
||||
if (content.lists && content.lists.length > 0) {
|
||||
markdown += '## Lists\n\n';
|
||||
|
||||
content.lists.forEach((list: any, index: number) => {
|
||||
markdown += `### List ${index + 1} (${list.type})\n\n`;
|
||||
|
||||
list.items.forEach((item: string) => {
|
||||
if (list.type === 'unordered') {
|
||||
markdown += `- ${item}\n`;
|
||||
} else {
|
||||
markdown += `1. ${item}\n`;
|
||||
}
|
||||
});
|
||||
|
||||
markdown += '\n';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (extractionType === 'headings' || extractionType === 'all') {
|
||||
if (content.headings && content.headings.length > 0) {
|
||||
markdown += '## Headings\n\n';
|
||||
|
||||
content.headings.forEach((heading: any) => {
|
||||
markdown += `${'#'.repeat(heading.level)} ${heading.text}\n\n`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (extractionType === 'tables' || extractionType === 'all') {
|
||||
if (content.tables && content.tables.length > 0) {
|
||||
markdown += '## Tables\n\n';
|
||||
|
||||
content.tables.forEach((table: any, index: number) => {
|
||||
markdown += `### Table ${index + 1}\n\n`;
|
||||
|
||||
// Add headers
|
||||
if (table.headers.length > 0) {
|
||||
markdown += '| ' + table.headers.join(' | ') + ' |\n';
|
||||
markdown += '| ' + table.headers.map(() => '---').join(' | ') + ' |\n';
|
||||
}
|
||||
|
||||
// Add rows
|
||||
table.rows.forEach((row: string[]) => {
|
||||
markdown += '| ' + row.join(' | ') + ' |\n';
|
||||
});
|
||||
|
||||
markdown += '\n';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (extractionType === 'codeBlocks' || extractionType === 'all') {
|
||||
if (content.codeBlocks && content.codeBlocks.length > 0) {
|
||||
markdown += '## Code Blocks\n\n';
|
||||
|
||||
content.codeBlocks.forEach((block: any, index: number) => {
|
||||
markdown += `### Code Block ${index + 1}\n\n`;
|
||||
|
||||
if (block.language) {
|
||||
markdown += '```' + block.language + '\n';
|
||||
} else {
|
||||
markdown += '```\n';
|
||||
}
|
||||
|
||||
markdown += block.code + '\n';
|
||||
markdown += '```\n\n';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return markdown.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format extracted content as plain text
|
||||
*/
|
||||
private formatAsText(content: any, extractionType: string): string {
|
||||
let text = '';
|
||||
|
||||
if (extractionType === 'lists' || extractionType === 'all') {
|
||||
if (content.lists && content.lists.length > 0) {
|
||||
text += 'LISTS:\n\n';
|
||||
|
||||
content.lists.forEach((list: any, index: number) => {
|
||||
text += `List ${index + 1} (${list.type}):\n\n`;
|
||||
|
||||
list.items.forEach((item: string, itemIndex: number) => {
|
||||
if (list.type === 'unordered') {
|
||||
text += `• ${item}\n`;
|
||||
} else {
|
||||
text += `${itemIndex + 1}. ${item}\n`;
|
||||
}
|
||||
});
|
||||
|
||||
text += '\n';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (extractionType === 'headings' || extractionType === 'all') {
|
||||
if (content.headings && content.headings.length > 0) {
|
||||
text += 'HEADINGS:\n\n';
|
||||
|
||||
content.headings.forEach((heading: any) => {
|
||||
text += `${heading.text} (Level ${heading.level})\n`;
|
||||
});
|
||||
|
||||
text += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (extractionType === 'tables' || extractionType === 'all') {
|
||||
if (content.tables && content.tables.length > 0) {
|
||||
text += 'TABLES:\n\n';
|
||||
|
||||
content.tables.forEach((table: any, index: number) => {
|
||||
text += `Table ${index + 1}:\n\n`;
|
||||
|
||||
// Add headers
|
||||
if (table.headers.length > 0) {
|
||||
text += table.headers.join(' | ') + '\n';
|
||||
text += table.headers.map(() => '-----').join(' | ') + '\n';
|
||||
}
|
||||
|
||||
// Add rows
|
||||
table.rows.forEach((row: string[]) => {
|
||||
text += row.join(' | ') + '\n';
|
||||
});
|
||||
|
||||
text += '\n';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (extractionType === 'codeBlocks' || extractionType === 'all') {
|
||||
if (content.codeBlocks && content.codeBlocks.length > 0) {
|
||||
text += 'CODE BLOCKS:\n\n';
|
||||
|
||||
content.codeBlocks.forEach((block: any, index: number) => {
|
||||
text += `Code Block ${index + 1}`;
|
||||
|
||||
if (block.language) {
|
||||
text += ` (${block.language})`;
|
||||
}
|
||||
|
||||
text += ':\n\n';
|
||||
text += block.code + '\n\n';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from content
|
||||
*/
|
||||
private stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode HTML entities
|
||||
*/
|
||||
private decodeHtmlEntities(text: string): string {
|
||||
return text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ');
|
||||
}
|
||||
}
|
170
src/services/llm/tools/note_creation_tool.ts
Normal file
170
src/services/llm/tools/note_creation_tool.ts
Normal file
@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Note Creation Tool
|
||||
*
|
||||
* This tool allows the LLM to create new notes in Trilium.
|
||||
*/
|
||||
|
||||
import type { Tool, ToolHandler } from './tool_interfaces.js';
|
||||
import log from '../../log.js';
|
||||
import becca from '../../../becca/becca.js';
|
||||
import notes from '../../notes.js';
|
||||
|
||||
/**
|
||||
* Definition of the note creation tool
|
||||
*/
|
||||
export const noteCreationToolDefinition: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_note',
|
||||
description: 'Create a new note in Trilium with the specified content and attributes',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
parentNoteId: {
|
||||
type: 'string',
|
||||
description: 'ID of the parent note under which to create the new note. If not specified, creates under root.'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Title of the new note'
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'Content of the new note'
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'Type of the note (text, code, etc.)',
|
||||
enum: ['text', 'code', 'file', 'image', 'search', 'relation-map', 'book', 'mermaid', 'canvas']
|
||||
},
|
||||
mime: {
|
||||
type: 'string',
|
||||
description: 'MIME type of the note (e.g., text/html, application/json). Only required for certain note types.'
|
||||
},
|
||||
attributes: {
|
||||
type: 'array',
|
||||
description: 'Array of attributes to set on the note (e.g., [{"name":"#tag"}, {"name":"priority", "value":"high"}])',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Name of the attribute'
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
description: 'Value of the attribute (if applicable)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['title', 'content']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Note creation tool implementation
|
||||
*/
|
||||
export class NoteCreationTool implements ToolHandler {
|
||||
public definition: Tool = noteCreationToolDefinition;
|
||||
|
||||
/**
|
||||
* Execute the note creation tool
|
||||
*/
|
||||
public async execute(args: {
|
||||
parentNoteId?: string,
|
||||
title: string,
|
||||
content: string,
|
||||
type?: string,
|
||||
mime?: string,
|
||||
attributes?: Array<{ name: string, value?: string }>
|
||||
}): Promise<string | object> {
|
||||
try {
|
||||
const { parentNoteId, title, content, type = 'text', mime } = args;
|
||||
|
||||
log.info(`Executing create_note tool - Title: "${title}", Type: ${type}, ParentNoteId: ${parentNoteId || 'root'}`);
|
||||
|
||||
// Validate parent note exists if specified
|
||||
let parent;
|
||||
if (parentNoteId) {
|
||||
parent = becca.notes[parentNoteId];
|
||||
if (!parent) {
|
||||
return `Error: Parent note with ID ${parentNoteId} not found. Please specify a valid parent note ID.`;
|
||||
}
|
||||
} else {
|
||||
// Use root note if no parent specified
|
||||
parent = becca.getNote('root');
|
||||
}
|
||||
|
||||
// Determine the appropriate mime type
|
||||
let noteMime = mime;
|
||||
if (!noteMime) {
|
||||
// Set default mime types based on note type
|
||||
switch (type) {
|
||||
case 'text':
|
||||
noteMime = 'text/html';
|
||||
break;
|
||||
case 'code':
|
||||
noteMime = 'text/plain';
|
||||
break;
|
||||
case 'file':
|
||||
noteMime = 'application/octet-stream';
|
||||
break;
|
||||
case 'image':
|
||||
noteMime = 'image/png';
|
||||
break;
|
||||
default:
|
||||
noteMime = 'text/html';
|
||||
}
|
||||
}
|
||||
|
||||
// Create the note
|
||||
const createStartTime = Date.now();
|
||||
const noteId = await notes.createNewNote({
|
||||
parentNoteId: parent.noteId,
|
||||
title: title,
|
||||
content: content,
|
||||
type: type,
|
||||
mime: noteMime
|
||||
});
|
||||
const createDuration = Date.now() - createStartTime;
|
||||
|
||||
if (!noteId) {
|
||||
return 'Error: Failed to create note. An unknown error occurred.';
|
||||
}
|
||||
|
||||
log.info(`Note created successfully in ${createDuration}ms, ID: ${noteId}`);
|
||||
|
||||
// Add attributes if specified
|
||||
if (args.attributes && args.attributes.length > 0) {
|
||||
log.info(`Adding ${args.attributes.length} attributes to the note`);
|
||||
|
||||
for (const attr of args.attributes) {
|
||||
if (!attr.name) continue;
|
||||
|
||||
const attrStartTime = Date.now();
|
||||
await notes.createAttribute(noteId, attr.name, attr.value || '');
|
||||
const attrDuration = Date.now() - attrStartTime;
|
||||
|
||||
log.info(`Added attribute ${attr.name}=${attr.value || ''} in ${attrDuration}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the new note's information
|
||||
const newNote = becca.notes[noteId];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: noteId,
|
||||
title: newNote.title,
|
||||
type: newNote.type,
|
||||
message: `Note "${title}" created successfully`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error executing create_note tool: ${error.message || String(error)}`);
|
||||
return `Error: ${error.message || String(error)}`;
|
||||
}
|
||||
}
|
||||
}
|
185
src/services/llm/tools/note_summarization_tool.ts
Normal file
185
src/services/llm/tools/note_summarization_tool.ts
Normal file
@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Note Summarization Tool
|
||||
*
|
||||
* This tool allows the LLM to generate concise summaries of longer notes.
|
||||
*/
|
||||
|
||||
import type { Tool, ToolHandler } from './tool_interfaces.js';
|
||||
import log from '../../log.js';
|
||||
import becca from '../../../becca/becca.js';
|
||||
import aiServiceManager from '../ai_service_manager.js';
|
||||
|
||||
/**
|
||||
* Definition of the note summarization tool
|
||||
*/
|
||||
export const noteSummarizationToolDefinition: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'summarize_note',
|
||||
description: 'Generate a concise summary of a note\'s content',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
noteId: {
|
||||
type: 'string',
|
||||
description: 'ID of the note to summarize'
|
||||
},
|
||||
maxLength: {
|
||||
type: 'number',
|
||||
description: 'Maximum length of the summary in characters (default: 500)'
|
||||
},
|
||||
format: {
|
||||
type: 'string',
|
||||
description: 'Format of the summary',
|
||||
enum: ['paragraph', 'bullets', 'executive']
|
||||
},
|
||||
focus: {
|
||||
type: 'string',
|
||||
description: 'Optional focus for the summary (e.g., "technical details", "key findings")'
|
||||
}
|
||||
},
|
||||
required: ['noteId']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Note summarization tool implementation
|
||||
*/
|
||||
export class NoteSummarizationTool implements ToolHandler {
|
||||
public definition: Tool = noteSummarizationToolDefinition;
|
||||
|
||||
/**
|
||||
* Execute the note summarization tool
|
||||
*/
|
||||
public async execute(args: {
|
||||
noteId: string,
|
||||
maxLength?: number,
|
||||
format?: 'paragraph' | 'bullets' | 'executive',
|
||||
focus?: string
|
||||
}): Promise<string | object> {
|
||||
try {
|
||||
const { noteId, maxLength = 500, format = 'paragraph', focus } = args;
|
||||
|
||||
log.info(`Executing summarize_note tool - NoteID: "${noteId}", MaxLength: ${maxLength}, Format: ${format}`);
|
||||
|
||||
// Get the note from becca
|
||||
const note = becca.notes[noteId];
|
||||
|
||||
if (!note) {
|
||||
log.info(`Note with ID ${noteId} not found - returning error`);
|
||||
return `Error: Note with ID ${noteId} not found`;
|
||||
}
|
||||
|
||||
log.info(`Found note: "${note.title}" (Type: ${note.type})`);
|
||||
|
||||
// Get the note content
|
||||
const content = await note.getContent();
|
||||
|
||||
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Note content is empty or invalid'
|
||||
};
|
||||
}
|
||||
|
||||
log.info(`Retrieved note content, length: ${content.length} chars`);
|
||||
|
||||
// Check if content needs summarization (if it's short enough, just return it)
|
||||
if (content.length <= maxLength && !focus) {
|
||||
log.info(`Note content is already shorter than maxLength, returning as is`);
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
summary: this.cleanHtml(content),
|
||||
wasAlreadyShort: true
|
||||
};
|
||||
}
|
||||
|
||||
// Remove HTML tags for summarization
|
||||
const cleanContent = this.cleanHtml(content);
|
||||
|
||||
// Generate the summary using the AI service
|
||||
const aiService = aiServiceManager.getService();
|
||||
|
||||
if (!aiService) {
|
||||
log.error('No AI service available for summarization');
|
||||
return `Error: No AI service is available for summarization`;
|
||||
}
|
||||
|
||||
log.info(`Using ${aiService.getName()} to generate summary`);
|
||||
|
||||
// Create a prompt based on format and focus
|
||||
let prompt = `Summarize the following text`;
|
||||
|
||||
if (focus) {
|
||||
prompt += ` with a focus on ${focus}`;
|
||||
}
|
||||
|
||||
if (format === 'bullets') {
|
||||
prompt += ` in a bullet point format`;
|
||||
} else if (format === 'executive') {
|
||||
prompt += ` as a brief executive summary`;
|
||||
} else {
|
||||
prompt += ` in a concise paragraph`;
|
||||
}
|
||||
|
||||
prompt += `. Keep the summary under ${maxLength} characters:\n\n${cleanContent}`;
|
||||
|
||||
// Generate the summary
|
||||
const summaryStartTime = Date.now();
|
||||
|
||||
const completion = await aiService.generateChatCompletion([
|
||||
{ role: 'system', content: 'You are a skilled summarizer. Create concise, accurate summaries while preserving the key information.' },
|
||||
{ role: 'user', content: prompt }
|
||||
], {
|
||||
temperature: 0.3, // Lower temperature for more focused summaries
|
||||
maxTokens: 1000 // Enough tokens for the summary
|
||||
});
|
||||
|
||||
const summaryDuration = Date.now() - summaryStartTime;
|
||||
|
||||
log.info(`Generated summary in ${summaryDuration}ms, length: ${completion.text.length} chars`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
originalLength: content.length,
|
||||
summary: completion.text,
|
||||
format: format,
|
||||
focus: focus || 'general content'
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error executing summarize_note tool: ${error.message || String(error)}`);
|
||||
return `Error: ${error.message || String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean HTML content for summarization
|
||||
*/
|
||||
private cleanHtml(html: string): string {
|
||||
if (typeof html !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove HTML tags
|
||||
let text = html.replace(/<[^>]*>/g, '');
|
||||
|
||||
// Decode common HTML entities
|
||||
text = text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ');
|
||||
|
||||
// Normalize whitespace
|
||||
text = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
136
src/services/llm/tools/note_update_tool.ts
Normal file
136
src/services/llm/tools/note_update_tool.ts
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Note Update Tool
|
||||
*
|
||||
* This tool allows the LLM to update existing notes in Trilium.
|
||||
*/
|
||||
|
||||
import type { Tool, ToolHandler } from './tool_interfaces.js';
|
||||
import log from '../../log.js';
|
||||
import becca from '../../../becca/becca.js';
|
||||
|
||||
/**
|
||||
* Definition of the note update tool
|
||||
*/
|
||||
export const noteUpdateToolDefinition: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'update_note',
|
||||
description: 'Update the content or title of an existing note',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
noteId: {
|
||||
type: 'string',
|
||||
description: 'ID of the note to update'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'New title for the note (if you want to change it)'
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'New content for the note (if you want to change it)'
|
||||
},
|
||||
mode: {
|
||||
type: 'string',
|
||||
description: 'How to update content: replace (default), append, or prepend',
|
||||
enum: ['replace', 'append', 'prepend']
|
||||
}
|
||||
},
|
||||
required: ['noteId']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Note update tool implementation
|
||||
*/
|
||||
export class NoteUpdateTool implements ToolHandler {
|
||||
public definition: Tool = noteUpdateToolDefinition;
|
||||
|
||||
/**
|
||||
* Execute the note update tool
|
||||
*/
|
||||
public async execute(args: { noteId: string, title?: string, content?: string, mode?: 'replace' | 'append' | 'prepend' }): Promise<string | object> {
|
||||
try {
|
||||
const { noteId, title, content, mode = 'replace' } = args;
|
||||
|
||||
if (!title && !content) {
|
||||
return 'Error: At least one of title or content must be provided to update a note.';
|
||||
}
|
||||
|
||||
log.info(`Executing update_note tool - NoteID: "${noteId}", Mode: ${mode}`);
|
||||
|
||||
// Get the note from becca
|
||||
const note = becca.notes[noteId];
|
||||
|
||||
if (!note) {
|
||||
log.info(`Note with ID ${noteId} not found - returning error`);
|
||||
return `Error: Note with ID ${noteId} not found`;
|
||||
}
|
||||
|
||||
log.info(`Found note: "${note.title}" (Type: ${note.type})`);
|
||||
|
||||
let titleUpdateResult;
|
||||
let contentUpdateResult;
|
||||
|
||||
// Update title if provided
|
||||
if (title && title !== note.title) {
|
||||
const titleStartTime = Date.now();
|
||||
|
||||
try {
|
||||
await note.setTitle(title);
|
||||
const titleDuration = Date.now() - titleStartTime;
|
||||
log.info(`Updated note title to "${title}" in ${titleDuration}ms`);
|
||||
titleUpdateResult = `Title updated from "${note.title}" to "${title}"`;
|
||||
} catch (error: any) {
|
||||
log.error(`Error updating note title: ${error.message || String(error)}`);
|
||||
titleUpdateResult = `Failed to update title: ${error.message || 'Unknown error'}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update content if provided
|
||||
if (content) {
|
||||
const contentStartTime = Date.now();
|
||||
|
||||
try {
|
||||
let newContent = content;
|
||||
|
||||
// For append or prepend modes, get the current content first
|
||||
if (mode === 'append' || mode === 'prepend') {
|
||||
const currentContent = await note.getContent();
|
||||
|
||||
if (mode === 'append') {
|
||||
newContent = currentContent + '\n\n' + content;
|
||||
log.info(`Appending content to existing note content`);
|
||||
} else if (mode === 'prepend') {
|
||||
newContent = content + '\n\n' + currentContent;
|
||||
log.info(`Prepending content to existing note content`);
|
||||
}
|
||||
}
|
||||
|
||||
await note.setContent(newContent);
|
||||
const contentDuration = Date.now() - contentStartTime;
|
||||
log.info(`Updated note content in ${contentDuration}ms, new content length: ${newContent.length}`);
|
||||
contentUpdateResult = `Content updated successfully (${mode} mode)`;
|
||||
} catch (error: any) {
|
||||
log.error(`Error updating note content: ${error.message || String(error)}`);
|
||||
contentUpdateResult = `Failed to update content: ${error.message || 'Unknown error'}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the results
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
titleUpdate: titleUpdateResult || 'No title update requested',
|
||||
contentUpdate: contentUpdateResult || 'No content update requested',
|
||||
message: `Note "${note.title}" updated successfully`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error executing update_note tool: ${error.message || String(error)}`);
|
||||
return `Error: ${error.message || String(error)}`;
|
||||
}
|
||||
}
|
||||
}
|
382
src/services/llm/tools/relationship_tool.ts
Normal file
382
src/services/llm/tools/relationship_tool.ts
Normal file
@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Relationship Tool
|
||||
*
|
||||
* This tool allows the LLM to create, identify, or modify relationships between notes.
|
||||
*/
|
||||
|
||||
import type { Tool, ToolHandler } from './tool_interfaces.js';
|
||||
import log from '../../log.js';
|
||||
import becca from '../../../becca/becca.js';
|
||||
import attributes from '../../attributes.js';
|
||||
import aiServiceManager from '../ai_service_manager.js';
|
||||
|
||||
/**
|
||||
* Definition of the relationship tool
|
||||
*/
|
||||
export const relationshipToolDefinition: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'manage_relationships',
|
||||
description: 'Create, list, or modify relationships between notes',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'Action to perform on relationships',
|
||||
enum: ['create', 'list', 'find_related', 'suggest']
|
||||
},
|
||||
sourceNoteId: {
|
||||
type: 'string',
|
||||
description: 'ID of the source note for the relationship'
|
||||
},
|
||||
targetNoteId: {
|
||||
type: 'string',
|
||||
description: 'ID of the target note for the relationship (for create action)'
|
||||
},
|
||||
relationName: {
|
||||
type: 'string',
|
||||
description: 'Name of the relation (for create action, e.g., "references", "belongs to", "depends on")'
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of relationships to return (for list action)'
|
||||
}
|
||||
},
|
||||
required: ['action', 'sourceNoteId']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Relationship tool implementation
|
||||
*/
|
||||
export class RelationshipTool implements ToolHandler {
|
||||
public definition: Tool = relationshipToolDefinition;
|
||||
|
||||
/**
|
||||
* Execute the relationship tool
|
||||
*/
|
||||
public async execute(args: {
|
||||
action: 'create' | 'list' | 'find_related' | 'suggest',
|
||||
sourceNoteId: string,
|
||||
targetNoteId?: string,
|
||||
relationName?: string,
|
||||
limit?: number
|
||||
}): Promise<string | object> {
|
||||
try {
|
||||
const { action, sourceNoteId, targetNoteId, relationName, limit = 10 } = args;
|
||||
|
||||
log.info(`Executing manage_relationships tool - Action: ${action}, SourceNoteId: ${sourceNoteId}`);
|
||||
|
||||
// Get the source note from becca
|
||||
const sourceNote = becca.notes[sourceNoteId];
|
||||
|
||||
if (!sourceNote) {
|
||||
log.info(`Source note with ID ${sourceNoteId} not found - returning error`);
|
||||
return `Error: Source note with ID ${sourceNoteId} not found`;
|
||||
}
|
||||
|
||||
log.info(`Found source note: "${sourceNote.title}" (Type: ${sourceNote.type})`);
|
||||
|
||||
// Handle different actions
|
||||
if (action === 'create') {
|
||||
return await this.createRelationship(sourceNote, targetNoteId, relationName);
|
||||
} else if (action === 'list') {
|
||||
return await this.listRelationships(sourceNote, limit);
|
||||
} else if (action === 'find_related') {
|
||||
return await this.findRelatedNotes(sourceNote, limit);
|
||||
} else if (action === 'suggest') {
|
||||
return await this.suggestRelationships(sourceNote, limit);
|
||||
} else {
|
||||
return `Error: Unsupported action "${action}". Supported actions are: create, list, find_related, suggest`;
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error(`Error executing manage_relationships tool: ${error.message || String(error)}`);
|
||||
return `Error: ${error.message || String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a relationship between notes
|
||||
*/
|
||||
private async createRelationship(sourceNote: any, targetNoteId?: string, relationName?: string): Promise<object> {
|
||||
if (!targetNoteId) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Target note ID is required for create action'
|
||||
};
|
||||
}
|
||||
|
||||
if (!relationName) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Relation name is required for create action'
|
||||
};
|
||||
}
|
||||
|
||||
// Get the target note from becca
|
||||
const targetNote = becca.notes[targetNoteId];
|
||||
|
||||
if (!targetNote) {
|
||||
log.info(`Target note with ID ${targetNoteId} not found - returning error`);
|
||||
return {
|
||||
success: false,
|
||||
message: `Target note with ID ${targetNoteId} not found`
|
||||
};
|
||||
}
|
||||
|
||||
log.info(`Found target note: "${targetNote.title}" (Type: ${targetNote.type})`);
|
||||
|
||||
try {
|
||||
// Check if relationship already exists
|
||||
const existingRelations = sourceNote.getRelationTargets(relationName);
|
||||
|
||||
for (const existingNote of existingRelations) {
|
||||
if (existingNote.noteId === targetNoteId) {
|
||||
log.info(`Relationship ${relationName} already exists from "${sourceNote.title}" to "${targetNote.title}"`);
|
||||
return {
|
||||
success: false,
|
||||
sourceNoteId: sourceNote.noteId,
|
||||
sourceTitle: sourceNote.title,
|
||||
targetNoteId: targetNote.noteId,
|
||||
targetTitle: targetNote.title,
|
||||
relationName: relationName,
|
||||
message: `Relationship ${relationName} already exists from "${sourceNote.title}" to "${targetNote.title}"`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create the relationship attribute
|
||||
const startTime = Date.now();
|
||||
await attributes.createRelation(sourceNote.noteId, relationName, targetNote.noteId);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
log.info(`Created relationship ${relationName} from "${sourceNote.title}" to "${targetNote.title}" in ${duration}ms`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sourceNoteId: sourceNote.noteId,
|
||||
sourceTitle: sourceNote.title,
|
||||
targetNoteId: targetNote.noteId,
|
||||
targetTitle: targetNote.title,
|
||||
relationName: relationName,
|
||||
message: `Created relationship ${relationName} from "${sourceNote.title}" to "${targetNote.title}"`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error creating relationship: ${error.message || String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List relationships for a note
|
||||
*/
|
||||
private async listRelationships(sourceNote: any, limit: number): Promise<object> {
|
||||
try {
|
||||
// Get outgoing relationships (where this note is the source)
|
||||
const outgoingAttributes = sourceNote.getAttributes()
|
||||
.filter((attr: any) => attr.type === 'relation')
|
||||
.slice(0, limit);
|
||||
|
||||
const outgoingRelations = [];
|
||||
|
||||
for (const attr of outgoingAttributes) {
|
||||
const targetNote = becca.notes[attr.value];
|
||||
|
||||
if (targetNote) {
|
||||
outgoingRelations.push({
|
||||
relationName: attr.name,
|
||||
targetNoteId: targetNote.noteId,
|
||||
targetTitle: targetNote.title
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get incoming relationships (where this note is the target)
|
||||
const incomingNotes = becca.findNotesWithRelation(sourceNote.noteId);
|
||||
const incomingRelations = [];
|
||||
|
||||
for (const sourceOfRelation of incomingNotes) {
|
||||
const incomingAttributes = sourceOfRelation.getOwnedAttributes()
|
||||
.filter((attr: any) => attr.type === 'relation' && attr.value === sourceNote.noteId);
|
||||
|
||||
for (const attr of incomingAttributes) {
|
||||
incomingRelations.push({
|
||||
relationName: attr.name,
|
||||
sourceNoteId: sourceOfRelation.noteId,
|
||||
sourceTitle: sourceOfRelation.title
|
||||
});
|
||||
}
|
||||
|
||||
if (incomingRelations.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Found ${outgoingRelations.length} outgoing and ${incomingRelations.length} incoming relationships`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: sourceNote.noteId,
|
||||
title: sourceNote.title,
|
||||
outgoingRelations: outgoingRelations,
|
||||
incomingRelations: incomingRelations.slice(0, limit),
|
||||
message: `Found ${outgoingRelations.length} outgoing and ${incomingRelations.length} incoming relationships for "${sourceNote.title}"`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error listing relationships: ${error.message || String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find related notes using vector similarity
|
||||
*/
|
||||
private async findRelatedNotes(sourceNote: any, limit: number): Promise<object> {
|
||||
try {
|
||||
// Get the vector search tool from the AI service manager
|
||||
const vectorSearchTool = aiServiceManager.getVectorSearchTool();
|
||||
|
||||
if (!vectorSearchTool) {
|
||||
log.error('Vector search tool not available');
|
||||
return {
|
||||
success: false,
|
||||
message: 'Vector search capability not available'
|
||||
};
|
||||
}
|
||||
|
||||
log.info(`Using vector search to find notes related to "${sourceNote.title}"`);
|
||||
|
||||
// Get note content for semantic search
|
||||
const content = await sourceNote.getContent();
|
||||
const title = sourceNote.title;
|
||||
|
||||
// Use both title and content for search
|
||||
const searchQuery = title + (content && typeof content === 'string' ? ': ' + content.substring(0, 500) : '');
|
||||
|
||||
// Execute the search
|
||||
const searchStartTime = Date.now();
|
||||
const results = await vectorSearchTool.searchNotes(searchQuery, {
|
||||
maxResults: limit + 1 // Add 1 to account for the source note itself
|
||||
});
|
||||
const searchDuration = Date.now() - searchStartTime;
|
||||
|
||||
// Filter out the source note from results
|
||||
const filteredResults = results.filter(note => note.noteId !== sourceNote.noteId);
|
||||
log.info(`Found ${filteredResults.length} related notes in ${searchDuration}ms`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: sourceNote.noteId,
|
||||
title: sourceNote.title,
|
||||
relatedNotes: filteredResults.slice(0, limit).map(note => ({
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
similarity: Math.round(note.similarity * 100) / 100
|
||||
})),
|
||||
message: `Found ${filteredResults.length} notes semantically related to "${sourceNote.title}"`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error finding related notes: ${error.message || String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest possible relationships based on content analysis
|
||||
*/
|
||||
private async suggestRelationships(sourceNote: any, limit: number): Promise<object> {
|
||||
try {
|
||||
// First, find related notes using vector search
|
||||
const relatedResult = await this.findRelatedNotes(sourceNote, limit) as any;
|
||||
|
||||
if (!relatedResult.success || !relatedResult.relatedNotes || relatedResult.relatedNotes.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Could not find any related notes to suggest relationships'
|
||||
};
|
||||
}
|
||||
|
||||
// Get the AI service for relationship suggestion
|
||||
const aiService = aiServiceManager.getService();
|
||||
|
||||
if (!aiService) {
|
||||
log.error('No AI service available for relationship suggestions');
|
||||
return {
|
||||
success: false,
|
||||
message: 'AI service not available for relationship suggestions',
|
||||
relatedNotes: relatedResult.relatedNotes
|
||||
};
|
||||
}
|
||||
|
||||
log.info(`Using ${aiService.getName()} to suggest relationships for ${relatedResult.relatedNotes.length} related notes`);
|
||||
|
||||
// Get the source note content
|
||||
const sourceContent = await sourceNote.getContent();
|
||||
|
||||
// Prepare suggestions
|
||||
const suggestions = [];
|
||||
|
||||
for (const relatedNote of relatedResult.relatedNotes) {
|
||||
try {
|
||||
// Get the target note content
|
||||
const targetNote = becca.notes[relatedNote.noteId];
|
||||
const targetContent = await targetNote.getContent();
|
||||
|
||||
// Prepare a prompt for the AI service
|
||||
const prompt = `Analyze the relationship between these two notes and suggest a descriptive relation name (like "references", "implements", "depends on", etc.)
|
||||
|
||||
SOURCE NOTE: "${sourceNote.title}"
|
||||
${typeof sourceContent === 'string' ? sourceContent.substring(0, 300) : ''}
|
||||
|
||||
TARGET NOTE: "${targetNote.title}"
|
||||
${typeof targetContent === 'string' ? targetContent.substring(0, 300) : ''}
|
||||
|
||||
Suggest the most appropriate relationship type that would connect the source note to the target note. Reply with ONLY the relationship name, nothing else.`;
|
||||
|
||||
// Get the suggestion
|
||||
const completion = await aiService.generateChatCompletion([
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You analyze the relationship between notes and suggest a concise, descriptive relation name.'
|
||||
},
|
||||
{ role: 'user', content: prompt }
|
||||
], {
|
||||
temperature: 0.4,
|
||||
maxTokens: 50
|
||||
});
|
||||
|
||||
// Extract just the relation name (remove any formatting or explanation)
|
||||
const relationName = completion.text
|
||||
.replace(/^["']|["']$/g, '') // Remove quotes
|
||||
.replace(/^relationship:|\./gi, '') // Remove prefixes/suffixes
|
||||
.trim();
|
||||
|
||||
suggestions.push({
|
||||
targetNoteId: relatedNote.noteId,
|
||||
targetTitle: relatedNote.title,
|
||||
similarity: relatedNote.similarity,
|
||||
suggestedRelation: relationName
|
||||
});
|
||||
|
||||
log.info(`Suggested relationship "${relationName}" from "${sourceNote.title}" to "${targetNote.title}"`);
|
||||
} catch (error: any) {
|
||||
log.error(`Error generating suggestion: ${error.message || String(error)}`);
|
||||
// Continue with other suggestions
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: sourceNote.noteId,
|
||||
title: sourceNote.title,
|
||||
suggestions: suggestions,
|
||||
message: `Generated ${suggestions.length} relationship suggestions for "${sourceNote.title}"`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error suggesting relationships: ${error.message || String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user