mirror of
https://github.com/zadam/trilium.git
synced 2025-12-04 22:44:25 +01:00
feat(llm): add smart search tool for unified search interface
* Add SmartSearchTool that automatically selects best search method based on query analysis * Intelligent detection of semantic, keyword, attribute, and temporal searches * Automatic fallback to alternative methods when primary search yields poor results * Support for exact phrase matching, boolean operators, and date/time patterns * Comprehensive error handling with helpful suggestions and examples * Standardized response format with execution metadata * Add parameter validation helpers for consistent error messaging * Remove unified_search_tool.ts to eliminate duplicate search interfaces This provides LLMs with a single, intelligent search interface while maintaining backward compatibility with individual search tools for specialized cases. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ac415c1007
commit
b37d9b4b3d
@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Parameter Validation Helpers
|
||||
*
|
||||
* This file provides utilities for validating tool parameters with LLM-friendly error messages
|
||||
* and suggestions for common parameter patterns.
|
||||
*/
|
||||
|
||||
import { ToolResponseFormatter, ToolErrorResponse } from './tool_interfaces.js';
|
||||
|
||||
export class ParameterValidationHelpers {
|
||||
/**
|
||||
* Validate noteId parameter with helpful error messages
|
||||
*/
|
||||
static validateNoteId(noteId: string | undefined, parameterName: string = 'noteId'): ToolErrorResponse | null {
|
||||
if (!noteId) {
|
||||
return ToolResponseFormatter.invalidParameterError(
|
||||
parameterName,
|
||||
'noteId from search results',
|
||||
'missing'
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof noteId !== 'string') {
|
||||
return ToolResponseFormatter.invalidParameterError(
|
||||
parameterName,
|
||||
'string value like "abc123def456"',
|
||||
typeof noteId
|
||||
);
|
||||
}
|
||||
|
||||
// Check basic noteId format (should be alphanumeric and at least 10 chars)
|
||||
if (noteId.length < 10 || !/^[a-zA-Z0-9_-]+$/.test(noteId)) {
|
||||
return ToolResponseFormatter.error(
|
||||
`Invalid noteId format: "${noteId}"`,
|
||||
{
|
||||
possibleCauses: [
|
||||
'Using note title instead of noteId',
|
||||
'Malformed noteId string',
|
||||
'Copy-paste error in noteId'
|
||||
],
|
||||
suggestions: [
|
||||
'Use search_notes to get the correct noteId',
|
||||
'noteIds look like "abc123def456" (letters and numbers)',
|
||||
'Make sure to use the noteId field from search results, not the title'
|
||||
],
|
||||
examples: [
|
||||
'search_notes("note title") to find the noteId',
|
||||
'read_note("abc123def456") using the noteId',
|
||||
'Valid noteId: "x5k2j8m9p4q1" (random letters and numbers)'
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate action parameter for tools that use action-based operations
|
||||
*/
|
||||
static validateAction(action: string | undefined, validActions: string[], examples: Record<string, string> = {}): ToolErrorResponse | null {
|
||||
if (!action) {
|
||||
return ToolResponseFormatter.invalidParameterError(
|
||||
'action',
|
||||
`one of: ${validActions.join(', ')}`,
|
||||
'missing'
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof action !== 'string') {
|
||||
return ToolResponseFormatter.invalidParameterError(
|
||||
'action',
|
||||
`string - one of: ${validActions.join(', ')}`,
|
||||
typeof action
|
||||
);
|
||||
}
|
||||
|
||||
if (!validActions.includes(action)) {
|
||||
const exampleList = validActions.map(a => examples[a] || `"${a}"`);
|
||||
return ToolResponseFormatter.error(
|
||||
`Invalid action: "${action}"`,
|
||||
{
|
||||
possibleCauses: [
|
||||
'Typo in action name',
|
||||
'Unsupported action for this tool',
|
||||
'Case sensitivity issue'
|
||||
],
|
||||
suggestions: [
|
||||
`Use one of these valid actions: ${validActions.join(', ')}`,
|
||||
'Check spelling and capitalization',
|
||||
'Refer to tool documentation for supported actions'
|
||||
],
|
||||
examples: exampleList
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate query parameter for search operations
|
||||
*/
|
||||
static validateSearchQuery(query: string | undefined): ToolErrorResponse | null {
|
||||
if (!query) {
|
||||
return ToolResponseFormatter.invalidParameterError(
|
||||
'query',
|
||||
'search terms or phrases',
|
||||
'missing'
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof query !== 'string') {
|
||||
return ToolResponseFormatter.invalidParameterError(
|
||||
'query',
|
||||
'string with search terms',
|
||||
typeof query
|
||||
);
|
||||
}
|
||||
|
||||
if (query.trim().length === 0) {
|
||||
return ToolResponseFormatter.error(
|
||||
'Query cannot be empty',
|
||||
{
|
||||
possibleCauses: [
|
||||
'Empty query string provided',
|
||||
'Query contains only whitespace'
|
||||
],
|
||||
suggestions: [
|
||||
'Provide meaningful search terms',
|
||||
'Use descriptive words or phrases',
|
||||
'Try searching for note titles or content keywords'
|
||||
],
|
||||
examples: [
|
||||
'search_notes("meeting notes")',
|
||||
'search_notes("project planning")',
|
||||
'search_notes("#important")'
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate numeric parameters with range checking
|
||||
*/
|
||||
static validateNumericRange(
|
||||
value: number | undefined,
|
||||
parameterName: string,
|
||||
min: number,
|
||||
max: number,
|
||||
defaultValue?: number
|
||||
): { value: number; error: ToolErrorResponse | null } {
|
||||
|
||||
if (value === undefined) {
|
||||
return { value: defaultValue || min, error: null };
|
||||
}
|
||||
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return {
|
||||
value: defaultValue || min,
|
||||
error: ToolResponseFormatter.invalidParameterError(
|
||||
parameterName,
|
||||
`number between ${min} and ${max}`,
|
||||
String(value)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
if (value < min || value > max) {
|
||||
return {
|
||||
value: Math.max(min, Math.min(max, value)), // Clamp to valid range
|
||||
error: ToolResponseFormatter.error(
|
||||
`${parameterName} must be between ${min} and ${max}, got ${value}`,
|
||||
{
|
||||
possibleCauses: [
|
||||
'Value outside allowed range',
|
||||
'Typo in numeric value'
|
||||
],
|
||||
suggestions: [
|
||||
`Use a value between ${min} and ${max}`,
|
||||
`Try ${min} for minimum, ${max} for maximum`,
|
||||
defaultValue ? `Omit parameter to use default (${defaultValue})` : ''
|
||||
].filter(Boolean),
|
||||
examples: [
|
||||
`${parameterName}: ${min} (minimum)`,
|
||||
`${parameterName}: ${Math.floor((min + max) / 2)} (middle)`,
|
||||
`${parameterName}: ${max} (maximum)`
|
||||
]
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
return { value, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate content parameter for note operations
|
||||
*/
|
||||
static validateContent(content: string | undefined, parameterName: string = 'content', allowEmpty: boolean = false): ToolErrorResponse | null {
|
||||
if (!content) {
|
||||
if (allowEmpty) return null;
|
||||
|
||||
return ToolResponseFormatter.invalidParameterError(
|
||||
parameterName,
|
||||
'text content for the note',
|
||||
'missing'
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof content !== 'string') {
|
||||
return ToolResponseFormatter.invalidParameterError(
|
||||
parameterName,
|
||||
'string with note content',
|
||||
typeof content
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowEmpty && content.trim().length === 0) {
|
||||
return ToolResponseFormatter.error(
|
||||
'Content cannot be empty',
|
||||
{
|
||||
possibleCauses: [
|
||||
'Empty content string provided',
|
||||
'Content contains only whitespace'
|
||||
],
|
||||
suggestions: [
|
||||
'Provide meaningful content for the note',
|
||||
'Use plain text, markdown, or HTML',
|
||||
'Content can be as simple as a single sentence'
|
||||
],
|
||||
examples: [
|
||||
'content: "This is my note content"',
|
||||
'content: "# Heading\\n\\nSome text here"',
|
||||
'content: "<p>HTML content</p>"'
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate title parameter for note operations
|
||||
*/
|
||||
static validateTitle(title: string | undefined, required: boolean = true): ToolErrorResponse | null {
|
||||
if (!title) {
|
||||
if (required) {
|
||||
return ToolResponseFormatter.invalidParameterError(
|
||||
'title',
|
||||
'name for the note',
|
||||
'missing'
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof title !== 'string') {
|
||||
return ToolResponseFormatter.invalidParameterError(
|
||||
'title',
|
||||
'string with note title',
|
||||
typeof title
|
||||
);
|
||||
}
|
||||
|
||||
if (title.trim().length === 0) {
|
||||
return ToolResponseFormatter.error(
|
||||
'Title cannot be empty',
|
||||
{
|
||||
possibleCauses: [
|
||||
'Empty title string provided',
|
||||
'Title contains only whitespace'
|
||||
],
|
||||
suggestions: [
|
||||
'Provide a descriptive title',
|
||||
'Use clear, concise names',
|
||||
'Avoid special characters that might cause issues'
|
||||
],
|
||||
examples: [
|
||||
'title: "Meeting Notes"',
|
||||
'title: "Project Plan - Phase 1"',
|
||||
'title: "Daily Tasks"'
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide helpful suggestions for common parameter mistakes
|
||||
*/
|
||||
static createParameterSuggestions(toolName: string, parameterName: string): string[] {
|
||||
const suggestions: Record<string, Record<string, string[]>> = {
|
||||
'search_notes': {
|
||||
'query': [
|
||||
'Use descriptive terms like "meeting notes" or "project planning"',
|
||||
'Try searching for concepts rather than exact phrases',
|
||||
'Use tags like "#important" to find tagged notes'
|
||||
],
|
||||
'parentNoteId': [
|
||||
'Use noteId from previous search results',
|
||||
'Leave empty to search all notes',
|
||||
'Make sure to use the noteId, not the note title'
|
||||
]
|
||||
},
|
||||
'create_note': {
|
||||
'title': [
|
||||
'Choose a clear, descriptive name',
|
||||
'Keep titles concise but informative',
|
||||
'Avoid special characters that might cause issues'
|
||||
],
|
||||
'content': [
|
||||
'Can be plain text, markdown, or HTML',
|
||||
'Start with a simple description',
|
||||
'Content can be updated later with note_update'
|
||||
],
|
||||
'parentNoteId': [
|
||||
'Use noteId from search results to place in specific folder',
|
||||
'Leave empty to create in root folder',
|
||||
'Search for the parent note first to get its noteId'
|
||||
]
|
||||
},
|
||||
'read_note': {
|
||||
'noteId': [
|
||||
'Use the noteId from search_notes results',
|
||||
'noteIds look like "abc123def456"',
|
||||
'Don\'t use the note title - use the actual noteId'
|
||||
]
|
||||
},
|
||||
'manage_attributes': {
|
||||
'noteId': [
|
||||
'Use noteId from search results',
|
||||
'Make sure the note exists before managing attributes',
|
||||
'Use search_notes to find the correct noteId first'
|
||||
],
|
||||
'attributeName': [
|
||||
'Use "#tagname" for tags (like #important)',
|
||||
'Use plain names for properties (like priority, status)',
|
||||
'Use "~relationname" for relations'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return suggestions[toolName]?.[parameterName] || [
|
||||
'Check the parameter format and requirements',
|
||||
'Refer to tool documentation for examples',
|
||||
'Try using simpler values first'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create examples for common parameter usage patterns
|
||||
*/
|
||||
static getParameterExamples(toolName: string, parameterName: string): string[] {
|
||||
const examples: Record<string, Record<string, string[]>> = {
|
||||
'search_notes': {
|
||||
'query': [
|
||||
'search_notes("meeting notes")',
|
||||
'search_notes("project planning documents")',
|
||||
'search_notes("#important")'
|
||||
]
|
||||
},
|
||||
'create_note': {
|
||||
'title': [
|
||||
'title: "Weekly Meeting Notes"',
|
||||
'title: "Project Tasks"',
|
||||
'title: "Research Ideas"'
|
||||
],
|
||||
'content': [
|
||||
'content: "This is my note content"',
|
||||
'content: "# Heading\\n\\nContent here"',
|
||||
'content: "- Item 1\\n- Item 2"'
|
||||
]
|
||||
},
|
||||
'manage_attributes': {
|
||||
'attributeName': [
|
||||
'attributeName: "#important"',
|
||||
'attributeName: "priority"',
|
||||
'attributeName: "~related-to"'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return examples[toolName]?.[parameterName] || [
|
||||
`${parameterName}: "example_value"`
|
||||
];
|
||||
}
|
||||
}
|
||||
721
apps/server/src/services/llm/tools/smart_search_tool.ts
Normal file
721
apps/server/src/services/llm/tools/smart_search_tool.ts
Normal file
@ -0,0 +1,721 @@
|
||||
/**
|
||||
* Smart Search Tool - Phase 1.3 of LLM Tool Effectiveness Improvement
|
||||
*
|
||||
* This unified search tool automatically chooses the best search method based on query analysis,
|
||||
* combines results from multiple approaches when beneficial, and provides intelligent fallback options.
|
||||
*/
|
||||
|
||||
import type { Tool, ToolHandler, StandardizedToolResponse } from './tool_interfaces.js';
|
||||
import { ToolResponseFormatter } from './tool_interfaces.js';
|
||||
import log from '../../log.js';
|
||||
import { SearchNotesTool } from './search_notes_tool.js';
|
||||
import { KeywordSearchTool } from './keyword_search_tool.js';
|
||||
import { AttributeSearchTool } from './attribute_search_tool.js';
|
||||
import { ContextExtractor } from '../context/index.js';
|
||||
import becca from '../../../becca/becca.js';
|
||||
|
||||
/**
|
||||
* Query analysis result structure
|
||||
*/
|
||||
interface QueryAnalysis {
|
||||
/** Detected search type */
|
||||
primaryMethod: 'semantic' | 'keyword' | 'attribute' | 'exact_phrase' | 'temporal';
|
||||
/** Secondary methods to try for better results */
|
||||
fallbackMethods: ('semantic' | 'keyword' | 'attribute')[];
|
||||
/** Confidence level in the detected method (0-1) */
|
||||
confidence: number;
|
||||
/** Processed query optimized for the detected method */
|
||||
processedQuery: string;
|
||||
/** Original query terms extracted */
|
||||
terms: string[];
|
||||
/** Detected attributes if any */
|
||||
attributes?: { type: 'label' | 'relation', name: string, value?: string }[];
|
||||
/** Detected date/time patterns */
|
||||
temporalPatterns?: string[];
|
||||
/** Exact phrases detected in quotes */
|
||||
exactPhrases?: string[];
|
||||
/** Suggested alternative queries */
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search result with method information
|
||||
*/
|
||||
interface SmartSearchResult {
|
||||
noteId: string;
|
||||
title: string;
|
||||
preview: string;
|
||||
score: number;
|
||||
similarity?: number;
|
||||
dateCreated: string;
|
||||
dateModified: string;
|
||||
parentId?: string;
|
||||
searchMethod: string;
|
||||
relevanceFactors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Definition of the smart search tool
|
||||
*/
|
||||
export const smartSearchToolDefinition: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'smart_search',
|
||||
description: 'Intelligent search that automatically chooses the best search approach for your query. Handles concepts ("project planning"), exact phrases ("weekly meeting"), tags (#important), dates ("last week"), and provides smart fallbacks. This is the recommended search tool for most queries.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Your search query in natural language. Examples: "find my project notes", "#urgent tasks", "meeting notes from last week", "machine learning concepts", "exact phrase search"'
|
||||
},
|
||||
parentNoteId: {
|
||||
type: 'string',
|
||||
description: 'Optional: Search only within this note folder. Use noteId from previous search results to narrow scope. Leave empty to search everywhere.'
|
||||
},
|
||||
maxResults: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results to return. Use 5-10 for quick overview, 15-25 for thorough search. Default is 10, maximum is 50.'
|
||||
},
|
||||
forceMethod: {
|
||||
type: 'string',
|
||||
description: 'Optional: Override smart detection and force a specific search method. Use "auto" (default) for intelligent selection.',
|
||||
enum: ['auto', 'semantic', 'keyword', 'attribute', 'multi_method']
|
||||
},
|
||||
includeArchived: {
|
||||
type: 'boolean',
|
||||
description: 'Include archived notes in search results. Default is false for faster, more relevant results.'
|
||||
},
|
||||
enableFallback: {
|
||||
type: 'boolean',
|
||||
description: 'Enable automatic fallback to alternative search methods when initial search yields poor results. Default is true.'
|
||||
},
|
||||
summarize: {
|
||||
type: 'boolean',
|
||||
description: 'Get AI-generated summaries of each result instead of content previews. Useful for quick overviews. Default is false.'
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Smart search tool implementation
|
||||
*/
|
||||
export class SmartSearchTool implements ToolHandler {
|
||||
public definition: Tool = smartSearchToolDefinition;
|
||||
private semanticSearchTool: SearchNotesTool;
|
||||
private keywordSearchTool: KeywordSearchTool;
|
||||
private attributeSearchTool: AttributeSearchTool;
|
||||
private contextExtractor: ContextExtractor;
|
||||
|
||||
constructor() {
|
||||
this.semanticSearchTool = new SearchNotesTool();
|
||||
this.keywordSearchTool = new KeywordSearchTool();
|
||||
this.attributeSearchTool = new AttributeSearchTool();
|
||||
this.contextExtractor = new ContextExtractor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze query to determine optimal search strategy
|
||||
*/
|
||||
private analyzeQuery(query: string): QueryAnalysis {
|
||||
const analysis: QueryAnalysis = {
|
||||
primaryMethod: 'semantic',
|
||||
fallbackMethods: [],
|
||||
confidence: 0.5,
|
||||
processedQuery: query.trim(),
|
||||
terms: [],
|
||||
suggestions: []
|
||||
};
|
||||
|
||||
const lowerQuery = query.toLowerCase().trim();
|
||||
|
||||
// Extract exact phrases in quotes
|
||||
const phraseMatches = query.match(/"([^"]+)"/g);
|
||||
if (phraseMatches) {
|
||||
analysis.exactPhrases = phraseMatches.map(match => match.slice(1, -1));
|
||||
analysis.primaryMethod = 'exact_phrase';
|
||||
analysis.confidence = 0.9;
|
||||
analysis.processedQuery = query;
|
||||
analysis.fallbackMethods = ['keyword', 'semantic'];
|
||||
analysis.suggestions!.push('Remove quotes for broader semantic search');
|
||||
}
|
||||
|
||||
// Detect attribute searches
|
||||
const attributePatterns = [
|
||||
{ regex: /#(\w+)(?:=([^"\s]+|"[^"]*"))?/g, type: 'label' as const },
|
||||
{ regex: /~(\w+)(?:=([^"\s]+|"[^"]*"))?/g, type: 'relation' as const },
|
||||
{ regex: /(label|relation):(\w+)(?:=([^"\s]+|"[^"]*"))?/gi, type: 'dynamic' as const }
|
||||
];
|
||||
|
||||
const attributes: { type: 'label' | 'relation', name: string, value?: string }[] = [];
|
||||
let hasAttributes = false;
|
||||
|
||||
attributePatterns.forEach(pattern => {
|
||||
let match;
|
||||
while ((match = pattern.regex.exec(query)) !== null) {
|
||||
hasAttributes = true;
|
||||
const type = pattern.type === 'dynamic'
|
||||
? match[1].toLowerCase() as 'label' | 'relation'
|
||||
: pattern.type;
|
||||
|
||||
const name = pattern.type === 'dynamic' ? match[2] : match[1];
|
||||
const value = pattern.type === 'dynamic'
|
||||
? match[3]?.replace(/"/g, '')
|
||||
: match[2]?.replace(/"/g, '');
|
||||
|
||||
attributes.push({ type, name, value });
|
||||
}
|
||||
});
|
||||
|
||||
if (hasAttributes) {
|
||||
analysis.attributes = attributes;
|
||||
analysis.primaryMethod = 'attribute';
|
||||
analysis.confidence = 0.95;
|
||||
analysis.fallbackMethods = ['semantic', 'keyword'];
|
||||
analysis.suggestions!.push('Try without attribute prefixes for content search');
|
||||
}
|
||||
|
||||
// Detect temporal patterns
|
||||
const temporalPatterns = [
|
||||
/\b(?:last|past|previous)\s+(?:week|month|year|day)\b/gi,
|
||||
/\b(?:this|current)\s+(?:week|month|year|day)\b/gi,
|
||||
/\b(?:yesterday|today|tomorrow)\b/gi,
|
||||
/\b(?:recent|recently|latest)\b/gi,
|
||||
/\b\d{4}[-/]\d{1,2}[-/]\d{1,2}\b/g,
|
||||
/\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\s+\d{1,2},?\s+\d{4}\b/gi
|
||||
];
|
||||
|
||||
const temporalMatches: string[] = [];
|
||||
temporalPatterns.forEach(pattern => {
|
||||
const matches = query.match(pattern);
|
||||
if (matches) temporalMatches.push(...matches);
|
||||
});
|
||||
|
||||
if (temporalMatches.length > 0) {
|
||||
analysis.temporalPatterns = temporalMatches;
|
||||
if (!hasAttributes && !phraseMatches) {
|
||||
analysis.primaryMethod = 'temporal';
|
||||
analysis.confidence = 0.8;
|
||||
analysis.fallbackMethods = ['semantic', 'keyword'];
|
||||
}
|
||||
}
|
||||
|
||||
// Detect boolean operators suggesting keyword search
|
||||
const booleanOperators = /\b(AND|OR|NOT)\b/gi;
|
||||
if (booleanOperators.test(query)) {
|
||||
analysis.primaryMethod = 'keyword';
|
||||
analysis.confidence = 0.85;
|
||||
analysis.fallbackMethods = ['semantic'];
|
||||
analysis.suggestions!.push('Remove operators for natural language search');
|
||||
}
|
||||
|
||||
// Detect specific search operators
|
||||
const operatorPatterns = [
|
||||
/note\.(title|content|type)/i,
|
||||
/\*=/,
|
||||
/\^=/,
|
||||
/\$=/
|
||||
];
|
||||
|
||||
if (operatorPatterns.some(pattern => pattern.test(query))) {
|
||||
analysis.primaryMethod = 'keyword';
|
||||
analysis.confidence = 0.9;
|
||||
analysis.fallbackMethods = ['semantic'];
|
||||
analysis.suggestions!.push('Use natural language for semantic search');
|
||||
}
|
||||
|
||||
// Extract meaningful terms for fallback
|
||||
analysis.terms = query
|
||||
.replace(/["#~]/g, '')
|
||||
.replace(/\b(and|or|not|the|a|an|is|are|was|were|in|on|at|to|for|of|with)\b/gi, '')
|
||||
.split(/\s+/)
|
||||
.filter(term => term.length > 2)
|
||||
.slice(0, 5);
|
||||
|
||||
// Default semantic search for natural language queries
|
||||
if (!hasAttributes && !phraseMatches && !booleanOperators.test(query) &&
|
||||
!operatorPatterns.some(p => p.test(query))) {
|
||||
analysis.primaryMethod = 'semantic';
|
||||
analysis.confidence = 0.7;
|
||||
analysis.fallbackMethods = ['keyword'];
|
||||
analysis.suggestions!.push(
|
||||
'Use quotes for exact phrases',
|
||||
'Add #tag or ~relation for attribute search'
|
||||
);
|
||||
}
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess query to optimize it for the detected search method
|
||||
*/
|
||||
private preprocessQuery(query: string, method: string): string {
|
||||
let processed = query.trim();
|
||||
|
||||
switch (method) {
|
||||
case 'semantic':
|
||||
// Remove quotes and operators for better semantic understanding
|
||||
processed = processed
|
||||
.replace(/"/g, '')
|
||||
.replace(/\b(AND|OR|NOT)\b/gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
break;
|
||||
|
||||
case 'keyword':
|
||||
// Keep operators and structure for precise matching
|
||||
break;
|
||||
|
||||
case 'attribute':
|
||||
// Keep attribute syntax intact
|
||||
break;
|
||||
|
||||
case 'exact_phrase':
|
||||
// Ensure phrases are properly quoted
|
||||
if (!processed.includes('"')) {
|
||||
processed = `"${processed}"`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'temporal':
|
||||
// Add date-related search context
|
||||
processed = `${processed} note.dateModified note.dateCreated`;
|
||||
break;
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute search using the specified method
|
||||
*/
|
||||
private async executeSearchMethod(
|
||||
method: string,
|
||||
query: string,
|
||||
options: any
|
||||
): Promise<{ results: SmartSearchResult[], method: string, success: boolean, error?: string }> {
|
||||
try {
|
||||
let results: any[] = [];
|
||||
let success = true;
|
||||
let error: string | undefined;
|
||||
|
||||
switch (method) {
|
||||
case 'semantic': {
|
||||
const response = await this.semanticSearchTool.executeStandardized({
|
||||
query,
|
||||
parentNoteId: options.parentNoteId,
|
||||
maxResults: options.maxResults,
|
||||
summarize: options.summarize
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
results = (response.result as any).results || [];
|
||||
} else {
|
||||
success = false;
|
||||
error = response.error;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'keyword': {
|
||||
const response = await this.keywordSearchTool.execute({
|
||||
query,
|
||||
maxResults: options.maxResults,
|
||||
includeArchived: options.includeArchived
|
||||
});
|
||||
|
||||
if (typeof response === 'object' && 'results' in response) {
|
||||
results = (response as any).results || [];
|
||||
} else if (typeof response === 'string') {
|
||||
success = false;
|
||||
error = response;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'attribute': {
|
||||
const analysis = this.analyzeQuery(query);
|
||||
if (analysis.attributes && analysis.attributes.length > 0) {
|
||||
const attr = analysis.attributes[0];
|
||||
const response = await this.attributeSearchTool.execute({
|
||||
attributeType: attr.type,
|
||||
attributeName: attr.name,
|
||||
attributeValue: attr.value,
|
||||
maxResults: options.maxResults
|
||||
});
|
||||
|
||||
if (typeof response === 'object' && 'results' in response) {
|
||||
results = (response as any).results || [];
|
||||
} else if (typeof response === 'string') {
|
||||
success = false;
|
||||
error = response;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize results to SmartSearchResult format
|
||||
const smartResults: SmartSearchResult[] = results.map((result: any) => ({
|
||||
noteId: result.noteId,
|
||||
title: result.title || '[Unknown title]',
|
||||
preview: result.preview || result.contentPreview || '[No preview]',
|
||||
score: result.score || result.similarity || 1.0,
|
||||
similarity: result.similarity,
|
||||
dateCreated: result.dateCreated,
|
||||
dateModified: result.dateModified,
|
||||
parentId: result.parentId,
|
||||
searchMethod: method,
|
||||
relevanceFactors: this.calculateRelevanceFactors(result, query, method)
|
||||
}));
|
||||
|
||||
return { results: smartResults, method, success, error };
|
||||
|
||||
} catch (error: any) {
|
||||
return {
|
||||
results: [],
|
||||
method,
|
||||
success: false,
|
||||
error: error.message || String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate relevance factors for a search result
|
||||
*/
|
||||
private calculateRelevanceFactors(result: any, query: string, method: string): string[] {
|
||||
const factors: string[] = [];
|
||||
|
||||
factors.push(`Found via ${method} search`);
|
||||
|
||||
if (result.score > 0.8) factors.push('High relevance score');
|
||||
if (result.similarity && result.similarity > 0.8) factors.push('High similarity');
|
||||
|
||||
const queryWords = query.toLowerCase().split(/\s+/);
|
||||
const titleWords = (result.title || '').toLowerCase().split(/\s+/);
|
||||
const titleMatches = queryWords.filter(word => titleWords.some(tw => tw.includes(word)));
|
||||
|
||||
if (titleMatches.length > 0) {
|
||||
factors.push(`Title matches: ${titleMatches.join(', ')}`);
|
||||
}
|
||||
|
||||
const recentThreshold = Date.now() - (7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
const modifiedDate = new Date(result.dateModified || 0).getTime();
|
||||
if (modifiedDate > recentThreshold) {
|
||||
factors.push('Recently modified');
|
||||
}
|
||||
|
||||
return factors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge and deduplicate results from multiple search methods
|
||||
*/
|
||||
private mergeResults(searchResults: SmartSearchResult[][]): SmartSearchResult[] {
|
||||
const seenNoteIds = new Set<string>();
|
||||
const mergedResults: SmartSearchResult[] = [];
|
||||
const noteIdToResults = new Map<string, SmartSearchResult[]>();
|
||||
|
||||
// Group results by noteId
|
||||
searchResults.forEach(results => {
|
||||
results.forEach(result => {
|
||||
if (!noteIdToResults.has(result.noteId)) {
|
||||
noteIdToResults.set(result.noteId, []);
|
||||
}
|
||||
noteIdToResults.get(result.noteId)!.push(result);
|
||||
});
|
||||
});
|
||||
|
||||
// Merge duplicates and combine relevance factors
|
||||
noteIdToResults.forEach((duplicates, noteId) => {
|
||||
if (duplicates.length === 1) {
|
||||
mergedResults.push(duplicates[0]);
|
||||
} else {
|
||||
// Merge multiple results for same note
|
||||
const best = duplicates.reduce((prev, current) =>
|
||||
current.score > prev.score ? current : prev
|
||||
);
|
||||
|
||||
const allMethods = [...new Set(duplicates.map(d => d.searchMethod))];
|
||||
const allFactors = [...new Set(duplicates.flatMap(d => d.relevanceFactors))];
|
||||
|
||||
mergedResults.push({
|
||||
...best,
|
||||
searchMethod: allMethods.join(' + '),
|
||||
relevanceFactors: allFactors,
|
||||
score: Math.max(...duplicates.map(d => d.score))
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by score descending
|
||||
return mergedResults.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate fallback suggestions when search fails
|
||||
*/
|
||||
private generateFallbackSuggestions(query: string, analysis: QueryAnalysis): string[] {
|
||||
const suggestions: string[] = [];
|
||||
|
||||
// Broader term suggestions
|
||||
const keywords = analysis.terms.slice(0, 3);
|
||||
if (keywords.length > 1) {
|
||||
suggestions.push(`Try individual keywords: ${keywords.join(' OR ')}`);
|
||||
suggestions.push(`Try broader search: ${keywords[0]} concepts`);
|
||||
}
|
||||
|
||||
// Method-specific suggestions
|
||||
if (analysis.primaryMethod === 'attribute' && analysis.attributes) {
|
||||
suggestions.push(`Search content instead: ${analysis.attributes[0].name}`);
|
||||
}
|
||||
|
||||
if (analysis.exactPhrases) {
|
||||
suggestions.push(`Try without quotes: ${analysis.exactPhrases[0]}`);
|
||||
}
|
||||
|
||||
// Generic suggestions
|
||||
suggestions.push('Check spelling of search terms');
|
||||
suggestions.push('Try simpler or more general terms');
|
||||
suggestions.push('Use different keywords for the same concept');
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the smart search tool with standardized response format
|
||||
*/
|
||||
public async executeStandardized(args: {
|
||||
query: string,
|
||||
parentNoteId?: string,
|
||||
maxResults?: number,
|
||||
forceMethod?: string,
|
||||
includeArchived?: boolean,
|
||||
enableFallback?: boolean,
|
||||
summarize?: boolean
|
||||
}): Promise<StandardizedToolResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const {
|
||||
query,
|
||||
parentNoteId,
|
||||
maxResults = 10,
|
||||
forceMethod = 'auto',
|
||||
includeArchived = false,
|
||||
enableFallback = true,
|
||||
summarize = false
|
||||
} = args;
|
||||
|
||||
log.info(`Executing smart_search tool - Query: "${query}", Method: ${forceMethod}, MaxResults: ${maxResults}`);
|
||||
|
||||
// Validate input
|
||||
if (!query || query.trim().length === 0) {
|
||||
return ToolResponseFormatter.invalidParameterError(
|
||||
'query',
|
||||
'non-empty string',
|
||||
query
|
||||
);
|
||||
}
|
||||
|
||||
if (maxResults < 1 || maxResults > 50) {
|
||||
return ToolResponseFormatter.invalidParameterError(
|
||||
'maxResults',
|
||||
'number between 1 and 50',
|
||||
String(maxResults)
|
||||
);
|
||||
}
|
||||
|
||||
// Analyze query to determine search strategy
|
||||
const analysis = this.analyzeQuery(query);
|
||||
const primaryMethod = forceMethod === 'auto' ? analysis.primaryMethod : forceMethod;
|
||||
|
||||
log.info(`Query analysis: method=${primaryMethod}, confidence=${analysis.confidence}, fallbacks=${analysis.fallbackMethods.join(', ')}`);
|
||||
|
||||
const searchOptions = {
|
||||
parentNoteId,
|
||||
maxResults,
|
||||
includeArchived,
|
||||
summarize
|
||||
};
|
||||
|
||||
let allResults: SmartSearchResult[] = [];
|
||||
let usedMethods: string[] = [];
|
||||
let errors: string[] = [];
|
||||
|
||||
// Execute primary search method
|
||||
const primaryQuery = this.preprocessQuery(query, primaryMethod);
|
||||
const primaryResult = await this.executeSearchMethod(primaryMethod, primaryQuery, searchOptions);
|
||||
|
||||
if (primaryResult.success) {
|
||||
allResults.push(...primaryResult.results);
|
||||
usedMethods.push(primaryMethod);
|
||||
log.info(`Primary search (${primaryMethod}) found ${primaryResult.results.length} results`);
|
||||
} else {
|
||||
errors.push(`${primaryMethod}: ${primaryResult.error}`);
|
||||
log.info(`Primary search (${primaryMethod}) failed: ${primaryResult.error}`);
|
||||
}
|
||||
|
||||
// Execute fallback methods if enabled and needed
|
||||
if (enableFallback && (allResults.length < maxResults * 0.3 || !primaryResult.success)) {
|
||||
log.info(`Executing fallback searches: ${analysis.fallbackMethods.join(', ')}`);
|
||||
|
||||
for (const fallbackMethod of analysis.fallbackMethods) {
|
||||
if (usedMethods.includes(fallbackMethod)) continue;
|
||||
|
||||
const fallbackQuery = this.preprocessQuery(query, fallbackMethod);
|
||||
const fallbackResult = await this.executeSearchMethod(
|
||||
fallbackMethod,
|
||||
fallbackQuery,
|
||||
{ ...searchOptions, maxResults: Math.max(5, maxResults - allResults.length) }
|
||||
);
|
||||
|
||||
if (fallbackResult.success) {
|
||||
allResults.push(...fallbackResult.results);
|
||||
usedMethods.push(fallbackMethod);
|
||||
log.info(`Fallback search (${fallbackMethod}) found ${fallbackResult.results.length} additional results`);
|
||||
} else {
|
||||
errors.push(`${fallbackMethod}: ${fallbackResult.error}`);
|
||||
log.info(`Fallback search (${fallbackMethod}) failed: ${fallbackResult.error}`);
|
||||
}
|
||||
|
||||
if (allResults.length >= maxResults) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge and deduplicate results
|
||||
const finalResults = this.mergeResults([allResults]).slice(0, maxResults);
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
log.info(`Smart search completed in ${executionTime}ms: ${finalResults.length} unique results from ${usedMethods.join(' + ')} methods`);
|
||||
|
||||
// Handle no results case
|
||||
if (finalResults.length === 0) {
|
||||
const suggestions = this.generateFallbackSuggestions(query, analysis);
|
||||
|
||||
return ToolResponseFormatter.error(
|
||||
`No results found for query: "${query}"`,
|
||||
{
|
||||
possibleCauses: [
|
||||
`Primary method (${primaryMethod}) found no matches`,
|
||||
'Search terms may be too specific',
|
||||
'Content may not exist in the knowledge base',
|
||||
...errors.map(e => `Search error: ${e}`)
|
||||
],
|
||||
suggestions: [
|
||||
...suggestions,
|
||||
'Try the suggested alternative queries below'
|
||||
],
|
||||
examples: [
|
||||
...(analysis.suggestions || []),
|
||||
`smart_search("${analysis.terms.slice(0, 2).join(' ')}")`,
|
||||
'smart_search("general topic") for broader results'
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Success response with comprehensive metadata
|
||||
const nextSteps = {
|
||||
suggested: `Use read_note with noteId to get full content: read_note("${finalResults[0].noteId}")`,
|
||||
alternatives: [
|
||||
'Use note_update to modify any of these notes',
|
||||
'Use attribute_manager to add tags or relations to results',
|
||||
'Refine search with different keywords or methods'
|
||||
],
|
||||
examples: [
|
||||
`read_note("${finalResults[0].noteId}")`,
|
||||
`smart_search("${query} related concepts")`,
|
||||
`smart_search("${analysis.terms.join(' ')}", {"forceMethod": "keyword"})`
|
||||
]
|
||||
};
|
||||
|
||||
return ToolResponseFormatter.success(
|
||||
{
|
||||
count: finalResults.length,
|
||||
results: finalResults,
|
||||
query: query,
|
||||
analysis: {
|
||||
detectedMethod: analysis.primaryMethod,
|
||||
confidence: analysis.confidence,
|
||||
usedMethods: usedMethods,
|
||||
attributes: analysis.attributes,
|
||||
temporalPatterns: analysis.temporalPatterns,
|
||||
exactPhrases: analysis.exactPhrases
|
||||
}
|
||||
},
|
||||
nextSteps,
|
||||
{
|
||||
executionTime,
|
||||
resourcesUsed: ['search', 'content', 'analysis'],
|
||||
searchMethods: usedMethods,
|
||||
primaryMethod: primaryMethod,
|
||||
fallbackEnabled: enableFallback,
|
||||
maxResultsRequested: maxResults,
|
||||
queryAnalysisConfidence: analysis.confidence,
|
||||
errors: errors.length > 0 ? errors : undefined
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Error executing smart_search tool: ${errorMessage}`);
|
||||
|
||||
return ToolResponseFormatter.error(
|
||||
`Smart search execution failed: ${errorMessage}`,
|
||||
{
|
||||
possibleCauses: [
|
||||
'Search service connectivity issue',
|
||||
'Query analysis failed',
|
||||
'Multiple search methods failed',
|
||||
'Invalid search parameters'
|
||||
],
|
||||
suggestions: [
|
||||
'Try a simpler search query',
|
||||
'Check if Trilium service is running properly',
|
||||
'Use forceMethod="semantic" to bypass query analysis',
|
||||
'Verify search parameters are valid'
|
||||
],
|
||||
examples: [
|
||||
'smart_search("simple keywords")',
|
||||
'smart_search("test", {"forceMethod": "keyword"})'
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the smart search tool (legacy method for backward compatibility)
|
||||
*/
|
||||
public async execute(args: {
|
||||
query: string,
|
||||
parentNoteId?: string,
|
||||
maxResults?: number,
|
||||
forceMethod?: string,
|
||||
includeArchived?: boolean,
|
||||
enableFallback?: boolean,
|
||||
summarize?: boolean
|
||||
}): Promise<string | object> {
|
||||
const standardizedResponse = await this.executeStandardized(args);
|
||||
|
||||
// For backward compatibility, return the legacy format
|
||||
if (standardizedResponse.success) {
|
||||
const result = standardizedResponse.result as any;
|
||||
return {
|
||||
count: result.count,
|
||||
results: result.results,
|
||||
query: result.query,
|
||||
analysis: result.analysis,
|
||||
message: `Smart search found ${result.count} results using ${result.analysis.usedMethods.join(' + ')} method(s). Use read_note with noteId for full content.`
|
||||
};
|
||||
} else {
|
||||
return `Error: ${standardizedResponse.error}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user