diff options
-rw-r--r-- | src/client/views/nodes/chatbot/tools/FilterDocTool.ts | 3 | ||||
-rw-r--r-- | src/client/views/nodes/chatbot/tools/FilterDocsTool.ts | 345 |
2 files changed, 347 insertions, 1 deletions
diff --git a/src/client/views/nodes/chatbot/tools/FilterDocTool.ts b/src/client/views/nodes/chatbot/tools/FilterDocTool.ts index d6d01ee82..30c1dcc44 100644 --- a/src/client/views/nodes/chatbot/tools/FilterDocTool.ts +++ b/src/client/views/nodes/chatbot/tools/FilterDocTool.ts @@ -1,4 +1,4 @@ -// FilterDocsTool.ts +/* not in use// FilterDocsTool.ts import { BaseTool } from './BaseTool'; import { Observation } from '../types/types'; import { ParametersType, ToolInfo } from '../types/tool_types'; @@ -173,3 +173,4 @@ Filtering failed: ${err instanceof Error ? err.message : String(err)} } } } +*/
\ No newline at end of file diff --git a/src/client/views/nodes/chatbot/tools/FilterDocsTool.ts b/src/client/views/nodes/chatbot/tools/FilterDocsTool.ts new file mode 100644 index 000000000..2eebaf8d0 --- /dev/null +++ b/src/client/views/nodes/chatbot/tools/FilterDocsTool.ts @@ -0,0 +1,345 @@ +// Enhanced FilterDocsTool.ts - Replaces GPTPopup filtering functionality +import { BaseTool } from './BaseTool'; +import { Observation } from '../types/types'; +import { ParametersType, ToolInfo } from '../types/tool_types'; +import { AgentDocumentManager } from '../utils/AgentDocumentManager'; +import { + gptAPICall, + GPTCallType, + GPTDocCommand, + DescriptionSeperator, + DataSeperator, + DocSeperator, +} from '../../../../apis/gpt/GPT'; +import { v4 as uuidv4 } from 'uuid'; +import { TagItem } from '../../../TagsView'; +import { DocumentView } from '../../DocumentView'; +import { Doc } from '../../../../../fields/Doc'; + +const parameterRules = [ + { + name: 'filterCriteria', + type: 'string', + description: 'Natural-language criteria for choosing a subset of documents (e.g., "show me only research papers about AI", "filter for documents created last week")', + required: true, + }, +] as const; + +const toolInfo: ToolInfo<typeof parameterRules> = { + name: 'filterDocs', + description: 'Filters documents in a collection based on user-specified natural-language criteria. Replaces GPTPopup filtering functionality with enhanced error handling and management features.', + parameterRules, + citationRules: 'No citation needed for filtering operations.', +}; + +export class FilterDocsTool extends BaseTool<typeof parameterRules> { + private _docManager: AgentDocumentManager; + static ChatTag = '#chat'; // tag used to mark filtered documents + private _collectionView: DocumentView; + private _documentDescriptions: Promise<string> | undefined; + private _textToDocMap = new Map<string, Doc>(); + private _lastFilterCriteria: string | undefined; + + constructor(docManager: AgentDocumentManager, collectionView: DocumentView) { + super(toolInfo); + this._docManager = docManager; + this._docManager.initializeFindDocsFreeform(); + this._collectionView = collectionView; + this._initializeDocumentContext(); + } + + /** + * Initialize document context similar to GPTPopup's componentDidMount behavior + */ + private _initializeDocumentContext() { + const selectedDoc = this._collectionView?.Document; + // Use any type to avoid complex type checking while maintaining runtime safety + const componentView = selectedDoc?.ComponentView as any; + const hasChildDocs = componentView?.hasChildDocs; + + if (hasChildDocs && typeof hasChildDocs === 'function') { + this._textToDocMap.clear(); + try { + const childDocs = hasChildDocs() as Doc[]; + this._documentDescriptions = Promise.all( + childDocs.map((doc: Doc) => + Doc.getDescription(doc).then(text => { + const cleanText = text.replace(/\n/g, ' ').trim(); + this._textToDocMap.set(cleanText, doc); + return `${DescriptionSeperator}${cleanText}${DescriptionSeperator}`; + }) + ) + ).then(docDescriptions => docDescriptions.join('')); + } catch (error) { + console.warn('[FilterDocsTool] Error initializing document context:', error); + } + } + } + + /** + * Check if a document filter is currently active + */ + static hasActiveFilter(parentDoc: Doc | undefined): boolean { + if (!parentDoc) return false; + const result = Doc.hasDocFilter(parentDoc, 'tags', FilterDocsTool.ChatTag); + return Boolean(result); + } + + /** + * Clear all chat filters from a collection (equivalent to GPTPopup's clear filter toggle) + */ + static clearChatFilter(parentDoc: Doc | undefined, childDocs?: Doc[]): boolean { + if (!parentDoc) return false; + + try { + // Remove filter from parent document + Doc.setDocFilter(parentDoc, 'tags', FilterDocsTool.ChatTag, 'remove'); + + // Remove tags from child documents + let docsToProcess: Doc[] = []; + if (childDocs) { + docsToProcess = childDocs; + } else { + // Try to get child docs from ComponentView + const componentView = parentDoc.ComponentView as any; + const hasChildDocs = componentView?.hasChildDocs; + if (hasChildDocs && typeof hasChildDocs === 'function') { + docsToProcess = hasChildDocs() as Doc[]; + } + } + + docsToProcess.forEach((doc: Doc) => TagItem.removeTagFromDoc(doc, FilterDocsTool.ChatTag)); + + return true; + } catch (error) { + console.error('[FilterDocsTool] Error clearing chat filter:', error); + return false; + } + } + + /** + * Determine if user input is a filter command using GPT + */ + async isFilterCommand(userPrompt: string): Promise<boolean> { + try { + const commandType = await gptAPICall(userPrompt, GPTCallType.COMMANDTYPE, undefined, true); + const commandNumber = parseInt(commandType.split(' ')[0][0]); + return commandNumber === GPTDocCommand.Filter; + } catch (error) { + console.error('[FilterDocsTool] Error determining command type:', error); + return false; + } + } + + async execute(args: ParametersType<typeof parameterRules>): Promise<Observation[]> { + const chunkId = uuidv4(); + this._lastFilterCriteria = args.filterCriteria; + console.log('[FilterDocsTool] Executing filter with criteria:', args.filterCriteria); + + // Validate parent view and document + const parentView = this._collectionView; + const parentDoc = parentView?.Document; + if (!parentDoc) { + console.error('[FilterDocsTool] Missing parent DocumentView/Document!'); + return [ + { + type: 'text', + text: `<chunk chunk_id="${chunkId}" chunk_type="error"> +FilterDocsTool: No parent collection document found. Please ensure you're working within a document collection. +</chunk>`, + }, + ]; + } + + try { + // Method 1: Use pre-computed document descriptions if available (from componentDidMount-like behavior) + let prompt: string; + let textToDocMap: Map<string, Doc>; + + if (this._documentDescriptions && this._textToDocMap.size > 0) { + console.log('[FilterDocsTool] Using pre-computed document descriptions'); + prompt = await this._documentDescriptions; + textToDocMap = this._textToDocMap; + } else { + // Method 2: Build descriptions from scratch using docManager + console.log('[FilterDocsTool] Building document descriptions from docManager'); + textToDocMap = new Map<string, Doc>(); + const blocks: string[] = []; + + for (const id of this._docManager.docIds) { + const descRaw = await this._docManager.getDocDescription(id); + const desc = descRaw.replace(/\n/g, ' ').trim(); + + if (!desc) { + console.warn(`[FilterDocsTool] Skipping document ${id} with empty description`); + continue; + } + + const doc = this._docManager.getDocument(id); + if (doc) { + textToDocMap.set(desc, doc); + blocks.push(`${DescriptionSeperator}${desc}${DescriptionSeperator}`); + } + } + + prompt = blocks.join(''); + } + + if (!prompt || textToDocMap.size === 0) { + return [ + { + type: 'text', + text: `<chunk chunk_id="${chunkId}" chunk_type="error"> +No documents found to filter. The collection appears to be empty or documents lack descriptions. +</chunk>`, + }, + ]; + } + + console.log(`[FilterDocsTool] Processing ${textToDocMap.size} documents with filter criteria`); + + // Call GPT for document subset selection + const gptResponse = await gptAPICall(args.filterCriteria, GPTCallType.SUBSETDOCS, prompt); + console.log('[FilterDocsTool] GPT response received:', gptResponse.substring(0, 200) + '...'); + + // Clear existing filters using the enhanced clear method + const childDocs = Array.from(textToDocMap.values()); + FilterDocsTool.clearChatFilter(parentDoc, childDocs); + + // Parse GPT response and apply new filters + const filteredResults = this._parseGptResponseAndApplyFilters( + gptResponse, + textToDocMap, + parentDoc, + chunkId + ); + + return filteredResults; + + } catch (error) { + console.error('[FilterDocsTool] Execution error:', error); + return [ + { + type: 'text', + text: `<chunk chunk_id="${chunkId}" chunk_type="error"> +Filtering failed: ${error instanceof Error ? error.message : String(error)} +Please try rephrasing your filter criteria or check if the collection contains valid documents. +</chunk>`, + }, + ]; + } + } + + /** + * Parse GPT response and apply filters with enhanced error handling + */ + private _parseGptResponseAndApplyFilters( + gptResponse: string, + textToDocMap: Map<string, Doc>, + parentDoc: Doc, + chunkId: string + ): Observation[] { + const filteredDocIds: string[] = []; + const errors: string[] = []; + let explanation = ''; + + try { + // Extract explanation if present (surrounded by DocSeperator) + const explanationMatch = gptResponse.match(new RegExp(`${DocSeperator}\\s*([\\s\\S]*?)\\s*(?:${DocSeperator}|$)`)); + explanation = explanationMatch?.[1]?.trim() || ''; + + // Parse document descriptions from GPT response + const docBlocks = gptResponse + .split(DescriptionSeperator) + .filter(block => block.trim() !== '') + .map(block => block.replace(/\n/g, ' ').trim()); + + console.log(`[FilterDocsTool] Found ${docBlocks.length} document blocks in GPT response`); + + docBlocks.forEach((block, index) => { + // Split by DataSeperator if present (for future extensibility) + const [descText = ''] = block.split(DataSeperator).map(s => s.trim()); + + if (!descText) { + console.warn(`[FilterDocsTool] Skipping empty description block ${index}`); + return; + } + + const doc = textToDocMap.get(descText); + if (!doc) { + console.warn(`[FilterDocsTool] No matching document found for: "${descText.substring(0, 50)}..."`); + errors.push(`No match found for: "${descText.substring(0, 30)}..."`); + return; + } + + // Apply the chat tag to mark as filtered + try { + TagItem.addTagToDoc(doc, FilterDocsTool.ChatTag); + const docId = String(doc.id || `doc_${index}`); + filteredDocIds.push(docId); + console.log(`[FilterDocsTool] Tagged document: ${docId}`); + } catch (tagError) { + console.error(`[FilterDocsTool] Error tagging document:`, tagError); + errors.push(`Failed to tag document: ${doc.id || 'unknown'}`); + } + }); + + // Apply the document filter to show only tagged documents + if (filteredDocIds.length > 0) { + try { + Doc.setDocFilter(parentDoc, 'tags', FilterDocsTool.ChatTag, 'check'); + const parentDocId = String(parentDoc.id || 'unknown'); + console.log(`[FilterDocsTool] Applied filter to parent document: ${parentDocId}`); + } catch (filterError) { + console.error('[FilterDocsTool] Error setting document filter:', filterError); + errors.push('Failed to apply collection filter'); + } + } + + } catch (parseError) { + console.error('[FilterDocsTool] Error parsing GPT response:', parseError); + errors.push(`Response parsing failed: ${parseError instanceof Error ? parseError.message : String(parseError)}`); + } + + // Build result message + const filterCriteria = this._lastFilterCriteria || 'the specified criteria'; + const successMessage = filteredDocIds.length > 0 + ? `Successfully filtered ${filteredDocIds.length} document(s) matching "${filterCriteria}".` + : 'No documents matched the specified filter criteria.'; + + const explanationMessage = explanation + ? `\n\nGPT Explanation: ${explanation}` + : ''; + + const errorMessage = errors.length > 0 + ? `\n\nWarnings: ${errors.join('; ')}` + : ''; + + return [ + { + type: 'text', + text: `<chunk chunk_id="${chunkId}" chunk_type="filter_result"> +${successMessage}${explanationMessage}${errorMessage} + +Filtered document IDs: ${filteredDocIds.length > 0 ? filteredDocIds.join(', ') : '(none)'} +Filter tag applied: ${FilterDocsTool.ChatTag} +</chunk>`, + }, + ]; + } + + /** + * Get current filter status for UI integration + */ + getFilterStatus(): { isActive: boolean; documentCount: number; filterTag: string } { + const parentDoc = this._collectionView?.Document; + const isActive = FilterDocsTool.hasActiveFilter(parentDoc); + const documentCount = this._textToDocMap.size; + + return { + isActive, + documentCount, + filterTag: FilterDocsTool.ChatTag, + }; + } +}
\ No newline at end of file |