From d66300e78ebbdad5a9933e7bc3e5016be3078b0e Mon Sep 17 00:00:00 2001 From: Willie Zutz Date: Sat, 28 Jun 2025 17:59:12 -0600 Subject: [PATCH] feat(agent): Refactor search agents and implement SpeedSearchAgent - Updated FileSearchAgent to improve code readability and formatting. - Refactored SynthesizerAgent for better prompt handling and document processing. - Enhanced TaskManagerAgent with clearer file context handling. - Modified AgentSearch to maintain consistent parameter formatting. - Introduced SpeedSearchAgent for optimized search functionality. - Updated metaSearchAgent to support new SpeedSearchAgent. - Improved file processing utilities for better document handling. - Added test attachments for sporting events queries. --- .github/copilot-instructions.md | 11 +- docs/API/SEARCH.md | 1 - docs/architecture/README.md | 4 +- src/app/api/chat/route.ts | 2 +- src/app/api/search/route.ts | 4 +- src/components/MessageInput.tsx | 1 + src/components/MessageInputActions/Attach.tsx | 276 ++++--- .../MessageInputActions/AttachSmall.tsx | 161 ---- .../MessageInputActions/Optimization.tsx | 2 +- src/lib/agents/contentRouterAgent.ts | 48 +- src/lib/agents/fileSearchAgent.ts | 22 +- src/lib/agents/synthesizerAgent.ts | 19 +- src/lib/agents/taskManagerAgent.ts | 11 +- src/lib/search/agentSearch.ts | 6 +- src/lib/search/index.ts | 3 + src/lib/search/metaSearchAgent.ts | 688 +----------------- src/lib/search/speedSearch.ts | 560 ++++++++++++++ src/lib/utils/fileProcessing.ts | 6 +- testAttachments/sporting-events.txt | 3 + 19 files changed, 832 insertions(+), 996 deletions(-) delete mode 100644 src/components/MessageInputActions/AttachSmall.tsx create mode 100644 src/lib/search/speedSearch.ts create mode 100644 testAttachments/sporting-events.txt diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3875861..1d601a5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,6 +15,7 @@ The system works through these main steps: ## Architecture Details ### Technology Stack + - **Frontend**: React, Next.js, Tailwind CSS - **Backend**: Node.js - **Database**: SQLite with Drizzle ORM @@ -23,29 +24,31 @@ The system works through these main steps: - **Content Processing**: Mozilla Readability, Cheerio, Playwright ### Database (SQLite + Drizzle ORM) + - Schema: `src/lib/db/schema.ts` - Tables: `messages`, `chats`, `systemPrompts` - Configuration: `drizzle.config.ts` - Local file: `data/db.sqlite` ### AI/ML Stack + - **LLM Providers**: OpenAI, Anthropic, Groq, Ollama, Gemini, DeepSeek, LM Studio - **Embeddings**: Xenova Transformers, similarity search (cosine/dot product) - **Agents**: `webSearchAgent`, `analyzerAgent`, `synthesizerAgent`, `taskManagerAgent` ### External Services + - **Search Engine**: SearXNG integration (`src/lib/searxng.ts`) - **Configuration**: TOML-based config file ### Data Flow + 1. User query → Task Manager Agent 2. Web Search Agent → SearXNG → Content extraction 3. Analyzer Agent → Content processing + embedding 4. Synthesizer Agent → LLM response generation 5. Response with cited sources - - ## Project Structure - `/src/app`: Next.js app directory with page components and API routes @@ -117,22 +120,26 @@ When working on this codebase, you might need to: ## Code Style & Standards ### TypeScript Configuration + - Strict mode enabled - ES2017 target - Path aliases: `@/*` → `src/*` - No test files (testing not implemented) ### Formatting & Linting + - ESLint: Next.js core web vitals rules - Prettier: Use `npm run format:write` before commits - Import style: Use `@/` prefix for internal imports ### File Organization + - Components: React functional components with TypeScript - API routes: Next.js App Router (`src/app/api/`) - Utilities: Grouped by domain (`src/lib/`) - Naming: camelCase for functions/variables, PascalCase for components ### Error Handling + - Use try/catch blocks for async operations - Return structured error responses from API routes diff --git a/docs/API/SEARCH.md b/docs/API/SEARCH.md index 69cb374..97ed674 100644 --- a/docs/API/SEARCH.md +++ b/docs/API/SEARCH.md @@ -60,7 +60,6 @@ The API accepts a JSON object in the request body, where you define the focus mo - **`optimizationMode`** (string, optional): Specifies the optimization mode to control the balance between performance and quality. Available modes: - `speed`: Prioritize speed and get the quickest possible answer. Minimum effort retrieving web content. - Only uses SearXNG result previews. - - `balanced`: Find the right balance between speed and accuracy. Medium effort retrieving web content. - Uses web scraping technologies to retrieve partial content from full web pages. - `agent`: Use an agentic workflow to answer complex multi-part questions. This mode requires a model that is trained for tool use. - **`query`** (string, required): The search query or question. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 59df06e..179b02f 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -11,10 +11,8 @@ Perplexica's architecture consists of the following key components: - In Agent mode, the application uses an agentic workflow to answer complex multi-part questions - The agent can use reasoning steps to provide comprehensive answers to complex questions - Agent mode is experimental and may consume lots of tokens and take a long time to produce responses - - In Balanced mode, the application retrieves web content using Playwright and Mozilla Readability to extract relevant segments of web content - - Because it only uses segments of web content, it can be less accurate than Agent mode - In Speed mode, the application only uses the preview content returned by SearXNG - This content is provided by the search engines and contains minimal context from the actual web page - - This mode is the least accurate and is often prone to hallucination + - This mode prioritizes quick responses over accuracy For a more detailed explanation of how these components work together, see [WORKING.md](https://github.com/ItzCrazyKns/Perplexica/tree/master/docs/architecture/WORKING.md). diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 1c1125c..604438e 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -43,7 +43,7 @@ type EmbeddingModel = { type Body = { message: Message; - optimizationMode: 'speed' | 'balanced' | 'agent'; + optimizationMode: 'speed' | 'agent'; focusMode: string; history: Array<[string, string]>; files: Array; diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index a5a6c1b..7e0e811 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -30,7 +30,7 @@ interface embeddingModel { } interface ChatRequestBody { - optimizationMode: 'speed' | 'balanced' | 'agent'; + optimizationMode: 'speed' | 'agent'; focusMode: string; chatModel?: chatModel; embeddingModel?: embeddingModel; @@ -52,7 +52,7 @@ export const POST = async (req: Request) => { } body.history = body.history || []; - body.optimizationMode = body.optimizationMode || 'balanced'; + body.optimizationMode = body.optimizationMode || 'speed'; body.stream = body.stream || false; const history: BaseMessage[] = body.history.map((msg) => { diff --git a/src/components/MessageInput.tsx b/src/components/MessageInput.tsx index a851b93..d02d19a 100644 --- a/src/components/MessageInput.tsx +++ b/src/components/MessageInput.tsx @@ -158,6 +158,7 @@ const MessageInput = ({ setFileIds={setFileIds} files={files} setFiles={setFiles} + optimizationMode={optimizationMode} />
diff --git a/src/components/MessageInputActions/Attach.tsx b/src/components/MessageInputActions/Attach.tsx index 4fb9b4d..a269fdc 100644 --- a/src/components/MessageInputActions/Attach.tsx +++ b/src/components/MessageInputActions/Attach.tsx @@ -14,16 +14,23 @@ const Attach = ({ setFileIds, files, setFiles, + optimizationMode, }: { fileIds: string[]; setFileIds: (fileIds: string[]) => void; files: FileType[]; setFiles: (files: FileType[]) => void; + optimizationMode: string; }) => { const [loading, setLoading] = useState(false); const fileInputRef = useRef(); + const isSpeedMode = optimizationMode === 'speed'; + const isDisabled = isSpeedMode; + const handleChange = async (e: React.ChangeEvent) => { + if (isDisabled) return; + setLoading(true); const data = new FormData(); @@ -37,7 +44,8 @@ const Attach = ({ const embeddingModel = localStorage.getItem('embeddingModel'); const chatModelProvider = localStorage.getItem('chatModelProvider'); const chatModel = localStorage.getItem('chatModel'); - const ollamaContextWindow = localStorage.getItem('ollamaContextWindow') || '2048'; + const ollamaContextWindow = + localStorage.getItem('ollamaContextWindow') || '2048'; data.append('embedding_model_provider', embeddingModelProvider!); data.append('embedding_model', embeddingModel!); @@ -67,122 +75,166 @@ const Attach = ({

) : files.length > 0 ? ( - - 0 ? '-ml-2 lg:-ml-3' : '', - )} - > - {files.length > 1 && ( - <> - -

- {files.length} files -

- - )} +
+ + 0 ? '-ml-2 lg:-ml-3' : '', + isDisabled + ? 'text-black/20 dark:text-white/20 cursor-not-allowed' + : 'text-black/50 dark:text-white/50 hover:bg-light-secondary dark:hover:bg-dark-secondary hover:text-black dark:hover:text-white', + )} + > + {files.length > 1 && ( + <> + +

+ {files.length} files +

+ + )} - {files.length === 1 && ( - <> - -

- {files[0].fileName.length > 10 - ? files[0].fileName.replace(/\.\w+$/, '').substring(0, 3) + - '...' + - files[0].fileExtension - : files[0].fileName} -

- - )} -
- - -
-
-

- Attached files -

-
- - + {files.length === 1 && ( + <> + +

+ {files[0].fileName.length > 10 + ? files[0].fileName.replace(/\.\w+$/, '').substring(0, 3) + + '...' + + files[0].fileExtension + : files[0].fileName} +

+ + )} + + + +
+
+

+ Attached files +

+
+ + +
+
+
+
+ {files.map((file, i) => ( +
+
+ +
+

+ {file.fileName.length > 25 + ? file.fileName.replace(/\.\w+$/, '').substring(0, 25) + + '...' + + file.fileExtension + : file.fileName} +

+
+ ))}
-
-
- {files.map((file, i) => ( -
-
- -
-

- {file.fileName.length > 25 - ? file.fileName.replace(/\.\w+$/, '').substring(0, 25) + - '...' + - file.fileExtension - : file.fileName} -

-
- ))} -
+ + + + {isSpeedMode && ( +
+
+ File attachments are disabled in Speed mode +
- - - - ) : ( -
)} - > - - - +
+ ) : ( +
+ + {isSpeedMode && ( +
+
+ File attachments are disabled in Speed mode +
+
+
+ )} +
); }; diff --git a/src/components/MessageInputActions/AttachSmall.tsx b/src/components/MessageInputActions/AttachSmall.tsx deleted file mode 100644 index 0f7f2b9..0000000 --- a/src/components/MessageInputActions/AttachSmall.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { cn } from '@/lib/utils'; -import { - Popover, - PopoverButton, - PopoverPanel, - Transition, -} from '@headlessui/react'; -import { CopyPlus, File, LoaderCircle, Plus, Trash } from 'lucide-react'; -import { Fragment, useRef, useState } from 'react'; -import { File as FileType } from '../ChatWindow'; - -const AttachSmall = ({ - fileIds, - setFileIds, - files, - setFiles, -}: { - fileIds: string[]; - setFileIds: (fileIds: string[]) => void; - files: FileType[]; - setFiles: (files: FileType[]) => void; -}) => { - const [loading, setLoading] = useState(false); - const fileInputRef = useRef(); - - const handleChange = async (e: React.ChangeEvent) => { - setLoading(true); - const data = new FormData(); - - for (let i = 0; i < e.target.files!.length; i++) { - data.append('files', e.target.files![i]); - } - - const embeddingModelProvider = localStorage.getItem( - 'embeddingModelProvider', - ); - const embeddingModel = localStorage.getItem('embeddingModel'); - const chatModelProvider = localStorage.getItem('chatModelProvider'); - const chatModel = localStorage.getItem('chatModel'); - const ollamaContextWindow = localStorage.getItem('ollamaContextWindow') || '2048'; - - data.append('embedding_model_provider', embeddingModelProvider!); - data.append('embedding_model', embeddingModel!); - data.append('chat_model_provider', chatModelProvider!); - data.append('chat_model', chatModel!); - if (chatModelProvider === 'ollama') { - data.append('ollama_context_window', ollamaContextWindow); - } - - const res = await fetch(`/api/uploads`, { - method: 'POST', - body: data, - }); - - const resData = await res.json(); - - setFiles([...files, ...resData.files]); - setFileIds([...fileIds, ...resData.files.map((file: any) => file.fileId)]); - setLoading(false); - }; - - return loading ? ( -
- -
- ) : files.length > 0 ? ( - - - - - - -
-
-

- Attached files -

-
- - -
-
-
-
- {files.map((file, i) => ( -
-
- -
-

- {file.fileName.length > 25 - ? file.fileName.replace(/\.\w+$/, '').substring(0, 25) + - '...' + - file.fileExtension - : file.fileName} -

-
- ))} -
-
- - - - ) : ( - - ); -}; - -export default AttachSmall; diff --git a/src/components/MessageInputActions/Optimization.tsx b/src/components/MessageInputActions/Optimization.tsx index 23869da..fbe6ad5 100644 --- a/src/components/MessageInputActions/Optimization.tsx +++ b/src/components/MessageInputActions/Optimization.tsx @@ -12,7 +12,7 @@ const OptimizationModes = [ key: 'speed', title: 'Speed', description: - 'Prioritize speed and get the quickest possible answer. Minimum effort retrieving web content.', + 'Prioritize speed and get the quickest possible answer. Uses only web search results - attached files will not be processed.', icon: , }, // { diff --git a/src/lib/agents/contentRouterAgent.ts b/src/lib/agents/contentRouterAgent.ts index 420a9f0..0a1b7a7 100644 --- a/src/lib/agents/contentRouterAgent.ts +++ b/src/lib/agents/contentRouterAgent.ts @@ -15,9 +15,7 @@ const RouterDecisionSchema = z.object({ decision: z .enum(['file_search', 'web_search', 'analyzer']) .describe('The next step to take in the workflow'), - reasoning: z - .string() - .describe('Explanation of why this decision was made'), + reasoning: z.string().describe('Explanation of why this decision was made'), }); type RouterDecision = z.infer; @@ -57,13 +55,15 @@ export class ContentRouterAgent { // Extract focus mode from state - this should now come from the API const focusMode = state.focusMode || 'webSearch'; - + const hasFiles = state.fileIds && state.fileIds.length > 0; const documentCount = state.relevantDocuments.length; const searchHistory = state.searchInstructionHistory.join(', ') || 'None'; - + // Extract file topics if files are available - const fileTopics = hasFiles ? await this.extractFileTopics(state.fileIds!) : 'None'; + const fileTopics = hasFiles + ? await this.extractFileTopics(state.fileIds!) + : 'None'; // Emit routing decision event this.emitter.emit('agent_action', { @@ -97,9 +97,12 @@ export class ContentRouterAgent { }); // Use structured output for routing decision - const structuredLlm = this.llm.withStructuredOutput(RouterDecisionSchema, { - name: 'route_content', - }); + const structuredLlm = this.llm.withStructuredOutput( + RouterDecisionSchema, + { + name: 'route_content', + }, + ); const routerDecision = await structuredLlm.invoke( [...removeThinkingBlocksFromMessages(state.messages), prompt], @@ -112,7 +115,11 @@ export class ContentRouterAgent { console.log(`Focus mode: ${focusMode}`); // Validate decision based on focus mode restrictions - const validatedDecision = this.validateDecision(routerDecision, focusMode, hasFiles); + const validatedDecision = this.validateDecision( + routerDecision, + focusMode, + hasFiles, + ); // Emit routing result event this.emitter.emit('agent_action', { @@ -163,15 +170,15 @@ export class ContentRouterAgent { */ private async extractFileTopics(fileIds: string[]): Promise { try { - const topics = fileIds.map(fileId => { + const topics = fileIds.map((fileId) => { try { const filePath = path.join(process.cwd(), 'uploads', fileId); const contentPath = filePath + '-extracted.json'; - + if (fs.existsSync(contentPath)) { const content = JSON.parse(fs.readFileSync(contentPath, 'utf8')); const filename = content.title || 'Document'; - + // Use LLM-generated semantic topics if available, otherwise fall back to filename const semanticTopics = content.topics; return semanticTopics || filename; @@ -182,7 +189,7 @@ export class ContentRouterAgent { return 'Unknown Document'; } }); - + return topics.join('; '); } catch (error) { console.warn('Error extracting file topics:', error); @@ -199,16 +206,17 @@ export class ContentRouterAgent { hasFiles: boolean, ): RouterDecision { // Enforce focus mode restrictions for chat and localResearch modes - if ((focusMode === 'chat' || focusMode === 'localResearch') && - decision.decision === 'web_search') { - + if ( + (focusMode === 'chat' || focusMode === 'localResearch') && + decision.decision === 'web_search' + ) { // Override to file_search if files are available, otherwise analyzer const fallbackDecision = hasFiles ? 'file_search' : 'analyzer'; - + console.log( - `Overriding web_search decision to ${fallbackDecision} due to focus mode restriction: ${focusMode}` + `Overriding web_search decision to ${fallbackDecision} due to focus mode restriction: ${focusMode}`, ); - + return { decision: fallbackDecision as 'file_search' | 'analyzer', reasoning: `Overridden to ${fallbackDecision} - web search not allowed in ${focusMode} mode. ${decision.reasoning}`, diff --git a/src/lib/agents/fileSearchAgent.ts b/src/lib/agents/fileSearchAgent.ts index d928259..14efe9b 100644 --- a/src/lib/agents/fileSearchAgent.ts +++ b/src/lib/agents/fileSearchAgent.ts @@ -5,7 +5,10 @@ import { EventEmitter } from 'events'; import { Document } from 'langchain/document'; import { AgentState } from './agentState'; import { Embeddings } from '@langchain/core/embeddings'; -import { processFilesToDocuments, getRankedDocs } from '../utils/fileProcessing'; +import { + processFilesToDocuments, + getRankedDocs, +} from '../utils/fileProcessing'; export class FileSearchAgent { private llm: BaseChatModel; @@ -79,12 +82,16 @@ export class FileSearchAgent { return new Command({ goto: 'analyzer', update: { - messages: [new AIMessage('No searchable content found in attached files.')], + messages: [ + new AIMessage('No searchable content found in attached files.'), + ], }, }); } - console.log(`Processed ${fileDocuments.length} file documents for search`); + console.log( + `Processed ${fileDocuments.length} file documents for search`, + ); // Emit searching file content event this.emitter.emit('agent_action', { @@ -139,7 +146,11 @@ export class FileSearchAgent { return new Command({ goto: 'analyzer', update: { - messages: [new AIMessage('No relevant content found in attached files for the current task.')], + messages: [ + new AIMessage( + 'No relevant content found in attached files for the current task.', + ), + ], }, }); } @@ -157,7 +168,8 @@ export class FileSearchAgent { totalTasks: state.tasks?.length || 1, relevantSections: rankedDocuments.length, searchedDocuments: fileDocuments.length, - documentCount: state.relevantDocuments.length + rankedDocuments.length, + documentCount: + state.relevantDocuments.length + rankedDocuments.length, }, }, }); diff --git a/src/lib/agents/synthesizerAgent.ts b/src/lib/agents/synthesizerAgent.ts index 44af1b4..c850222 100644 --- a/src/lib/agents/synthesizerAgent.ts +++ b/src/lib/agents/synthesizerAgent.ts @@ -33,23 +33,22 @@ export class SynthesizerAgent { try { // Format the prompt using the external template const template = PromptTemplate.fromTemplate(synthesizerPrompt); - - const conversationHistory = removeThinkingBlocksFromMessages(state.messages) - .map((msg) => `<${msg.getType()}>${msg.content}`) - .join('\n') || 'No previous conversation context'; + + const conversationHistory = + removeThinkingBlocksFromMessages(state.messages) + .map((msg) => `<${msg.getType()}>${msg.content}`) + .join('\n') || 'No previous conversation context'; const relevantDocuments = state.relevantDocuments - .map( - (doc, index) => { - const isFile = doc.metadata?.url?.toLowerCase().includes('file'); - return `<${index + 1}>\n + .map((doc, index) => { + const isFile = doc.metadata?.url?.toLowerCase().includes('file'); + return `<${index + 1}>\n ${doc.metadata.title}\n ${isFile ? 'file' : 'web'}\n ${isFile ? '' : '\n' + doc.metadata.url + '\n'} \n${doc.pageContent}\n\n `; - } - ) + }) .join('\n'); const formattedPrompt = await template.format({ diff --git a/src/lib/agents/taskManagerAgent.ts b/src/lib/agents/taskManagerAgent.ts index bdc869f..55ef606 100644 --- a/src/lib/agents/taskManagerAgent.ts +++ b/src/lib/agents/taskManagerAgent.ts @@ -127,12 +127,13 @@ export class TaskManagerAgent { }); const template = PromptTemplate.fromTemplate(taskBreakdownPrompt); - + // Create file context information - const fileContext = state.fileIds && state.fileIds.length > 0 - ? `Files attached: ${state.fileIds.length} file(s) are available for analysis. Consider creating tasks that can leverage these attached files when appropriate.` - : 'No files attached: Focus on tasks that can be answered through web research or general knowledge.'; - + const fileContext = + state.fileIds && state.fileIds.length > 0 + ? `Files attached: ${state.fileIds.length} file(s) are available for analysis. Consider creating tasks that can leverage these attached files when appropriate.` + : 'No files attached: Focus on tasks that can be answered through web research or general knowledge.'; + const prompt = await template.format({ systemInstructions: this.systemInstructions, fileContext: fileContext, diff --git a/src/lib/search/agentSearch.ts b/src/lib/search/agentSearch.ts index 75cc134..0d5b727 100644 --- a/src/lib/search/agentSearch.ts +++ b/src/lib/search/agentSearch.ts @@ -153,9 +153,9 @@ export class AgentSearch { * Execute the agent search workflow */ async searchAndAnswer( - query: string, - history: BaseMessage[] = [], - fileIds: string[] = [] + query: string, + history: BaseMessage[] = [], + fileIds: string[] = [], ) { const workflow = this.createWorkflow(); diff --git a/src/lib/search/index.ts b/src/lib/search/index.ts index e52dd99..2f2c420 100644 --- a/src/lib/search/index.ts +++ b/src/lib/search/index.ts @@ -1,6 +1,9 @@ import MetaSearchAgent from '@/lib/search/metaSearchAgent'; +import SpeedSearchAgent from '@/lib/search/speedSearch'; import prompts from '../prompts'; +export { default as SpeedSearchAgent } from './speedSearch'; + export const searchHandlers: Record = { webSearch: new MetaSearchAgent({ activeEngines: [], diff --git a/src/lib/search/metaSearchAgent.ts b/src/lib/search/metaSearchAgent.ts index 546517d..c70d047 100644 --- a/src/lib/search/metaSearchAgent.ts +++ b/src/lib/search/metaSearchAgent.ts @@ -1,32 +1,9 @@ import type { Embeddings } from '@langchain/core/embeddings'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { BaseMessage } from '@langchain/core/messages'; -import { StringOutputParser } from '@langchain/core/output_parsers'; -import { - ChatPromptTemplate, - MessagesPlaceholder, - PromptTemplate, -} from '@langchain/core/prompts'; -import { - RunnableLambda, - RunnableMap, - RunnableSequence, -} from '@langchain/core/runnables'; -import { StreamEvent } from '@langchain/core/tracers/log_stream'; -import { ChatOpenAI } from '@langchain/openai'; import eventEmitter from 'events'; -import { Document } from 'langchain/document'; -import fs from 'node:fs'; -import path from 'node:path'; -import LineOutputParser from '../outputParsers/lineOutputParser'; -import LineListOutputParser from '../outputParsers/listLineOutputParser'; -import { searchSearxng } from '../searxng'; -import { formatDateForLLM } from '../utils'; -import computeSimilarity from '../utils/computeSimilarity'; -import { getDocumentsFromLinks, getWebContent } from '../utils/documents'; -import formatChatHistoryAsString from '../utils/formatHistory'; -import { getModelName } from '../utils/modelUtils'; import { AgentSearch } from './agentSearch'; +import SpeedSearchAgent from './speedSearch'; export interface MetaSearchAgentType { searchAndAnswer: ( @@ -34,7 +11,7 @@ export interface MetaSearchAgentType { history: BaseMessage[], llm: BaseChatModel, embeddings: Embeddings, - optimizationMode: 'speed' | 'balanced' | 'agent', + optimizationMode: 'speed' | 'agent', fileIds: string[], systemInstructions: string, signal: AbortSignal, @@ -54,623 +31,13 @@ interface Config { additionalSearchCriteria?: string; } -type BasicChainInput = { - chat_history: BaseMessage[]; - query: string; -}; - class MetaSearchAgent implements MetaSearchAgentType { private config: Config; - private strParser = new StringOutputParser(); - private searchQuery?: string; - private searxngUrl?: string; constructor(config: Config) { this.config = config; } - /** - * Emit a progress event with the given percentage and message - */ - private emitProgress( - emitter: eventEmitter, - percentage: number, - message: string, - subMessage?: string, - ) { - const progressData: any = { - message, - current: percentage, - total: 100, - }; - - // Add subMessage if provided - if (subMessage) { - progressData.subMessage = subMessage; - } - - emitter.emit( - 'progress', - JSON.stringify({ - type: 'progress', - data: progressData, - }), - ); - } - - private async createSearchRetrieverChain( - llm: BaseChatModel, - systemInstructions: string, - emitter: eventEmitter, - signal: AbortSignal, - ) { - // TODO: Don't we want to set this back to default once search is done? - (llm as unknown as ChatOpenAI).temperature = 0; - - this.emitProgress(emitter, 10, `Building search query`); - - return RunnableSequence.from([ - PromptTemplate.fromTemplate(this.config.queryGeneratorPrompt), - llm, - this.strParser, - RunnableLambda.from(async (input: string) => { - try { - //console.log(`LLM response for initial web search:"${input}"`); - const linksOutputParser = new LineListOutputParser({ - key: 'links', - }); - - const questionOutputParser = new LineOutputParser({ - key: 'answer', - }); - - const links = await linksOutputParser.parse(input); - let question = await questionOutputParser.parse(input); - - //console.log('question', question); - - if (question === 'not_needed') { - return { query: '', docs: [] }; - } - - if (links.length > 0) { - if (question.length === 0) { - question = 'summarize'; - } - - let docs: Document[] = []; - - const linkDocs = await getDocumentsFromLinks({ links }); - - const docGroups: Document[] = []; - - linkDocs.map((doc) => { - const URLDocExists = docGroups.find( - (d) => - d.metadata.url === doc.metadata.url && - d.metadata.totalDocs < 10, - ); - - if (!URLDocExists) { - docGroups.push({ - ...doc, - metadata: { - ...doc.metadata, - totalDocs: 1, - }, - }); - } - - const docIndex = docGroups.findIndex( - (d) => - d.metadata.url === doc.metadata.url && - d.metadata.totalDocs < 10, - ); - - if (docIndex !== -1) { - docGroups[docIndex].pageContent = - docGroups[docIndex].pageContent + `\n\n` + doc.pageContent; - docGroups[docIndex].metadata.totalDocs += 1; - } - }); - - this.emitProgress(emitter, 20, `Summarizing content`); - - await Promise.all( - docGroups.map(async (doc) => { - const systemPrompt = systemInstructions - ? `${systemInstructions}\n\n` - : ''; - - const res = await llm.invoke( - `${systemPrompt}You are a web search summarizer, tasked with summarizing a piece of text retrieved from a web search. Your job is to summarize the - text into a detailed, 2-4 paragraph explanation that captures the main ideas and provides a comprehensive answer to the query. - If the query is \"summarize\", you should provide a detailed summary of the text. If the query is a specific question, you should answer it in the summary. - - - **Journalistic tone**: The summary should sound professional and journalistic, not too casual or vague. - - **Thorough and detailed**: Ensure that every key point from the text is captured and that the summary directly answers the query. - - **Not too lengthy, but detailed**: The summary should be informative but not excessively long. Focus on providing detailed information in a concise format. - - The text will be shared inside the \`text\` XML tag, and the query inside the \`query\` XML tag. - - - 1. \` - Docker is a set of platform-as-a-service products that use OS-level virtualization to deliver software in packages called containers. - It was first released in 2013 and is developed by Docker, Inc. Docker is designed to make it easier to create, deploy, and run applications - by using containers. - - - - What is Docker and how does it work? - - - Response: - Docker is a revolutionary platform-as-a-service product developed by Docker, Inc., that uses container technology to make application - deployment more efficient. It allows developers to package their software with all necessary dependencies, making it easier to run in - any environment. Released in 2013, Docker has transformed the way applications are built, deployed, and managed. - \` - 2. \` - The theory of relativity, or simply relativity, encompasses two interrelated theories of Albert Einstein: special relativity and general - relativity. However, the word "relativity" is sometimes used in reference to Galilean invariance. The term "theory of relativity" was based - on the expression "relative theory" used by Max Planck in 1906. The theory of relativity usually encompasses two interrelated theories by - Albert Einstein: special relativity and general relativity. Special relativity applies to all physical phenomena in the absence of gravity. - General relativity explains the law of gravitation and its relation to other forces of nature. It applies to the cosmological and astrophysical - realm, including astronomy. - - - - summarize - - - Response: - The theory of relativity, developed by Albert Einstein, encompasses two main theories: special relativity and general relativity. Special - relativity applies to all physical phenomena in the absence of gravity, while general relativity explains the law of gravitation and its - relation to other forces of nature. The theory of relativity is based on the concept of "relative theory," as introduced by Max Planck in - 1906. It is a fundamental theory in physics that has revolutionized our understanding of the universe. - \` - - - Everything below is the actual data you will be working with. Good luck! - - - ${question} - - - - ${doc.pageContent} - - - Make sure to answer the query in the summary. - `, - { signal }, - ); - - const document = new Document({ - pageContent: res.content as string, - metadata: { - title: doc.metadata.title, - url: doc.metadata.url, - }, - }); - - docs.push(document); - }), - ); - - return { query: question, docs: docs }; - } else { - if (this.config.additionalSearchCriteria) { - question = `${question} ${this.config.additionalSearchCriteria}`; - } - this.emitProgress( - emitter, - 20, - `Searching the web`, - `Search Query: ${question}`, - ); - - const searxngResult = await searchSearxng(question, { - language: 'en', - engines: this.config.activeEngines, - }); - - // Store the SearXNG URL for later use in emitting to the client - this.searxngUrl = searxngResult.searchUrl; - - const documents = searxngResult.results.map( - (result) => - new Document({ - pageContent: - result.content || - (this.config.activeEngines.includes('youtube') - ? result.title - : '') /* Todo: Implement transcript grabbing using Youtubei (source: https://www.npmjs.com/package/youtubei) */, - metadata: { - title: result.title, - url: result.url, - ...(result.img_src && { img_src: result.img_src }), - }, - }), - ); - - return { query: question, docs: documents, searchQuery: question }; - } - } catch (error) { - console.error('Error in search retriever chain:', error); - emitter.emit('error', JSON.stringify({ data: error })); - throw error; - } - }), - ]); - } - - private async createAnsweringChain( - llm: BaseChatModel, - fileIds: string[], - embeddings: Embeddings, - optimizationMode: 'speed' | 'balanced' | 'agent', - systemInstructions: string, - signal: AbortSignal, - emitter: eventEmitter, - personaInstructions?: string, - ) { - return RunnableSequence.from([ - RunnableMap.from({ - systemInstructions: () => systemInstructions, - query: (input: BasicChainInput) => input.query, - chat_history: (input: BasicChainInput) => input.chat_history, - date: () => formatDateForLLM(), - personaInstructions: () => personaInstructions || '', - context: RunnableLambda.from( - async ( - input: BasicChainInput, - options?: { signal?: AbortSignal }, - ) => { - // Check if the request was aborted - if (options?.signal?.aborted || signal?.aborted) { - console.log('Request cancelled by user'); - throw new Error('Request cancelled by user'); - } - - const processedHistory = formatChatHistoryAsString( - input.chat_history, - ); - - let docs: Document[] | null = null; - let query = input.query; - - if (this.config.searchWeb) { - const searchRetrieverChain = - await this.createSearchRetrieverChain( - llm, - systemInstructions, - emitter, - signal, - ); - var date = formatDateForLLM(); - - const searchRetrieverResult = await searchRetrieverChain.invoke( - { - chat_history: processedHistory, - query, - date, - systemInstructions, - }, - { signal: options?.signal }, - ); - - query = searchRetrieverResult.query; - docs = searchRetrieverResult.docs; - - // Store the search query in the context for emitting to the client - if (searchRetrieverResult.searchQuery) { - this.searchQuery = searchRetrieverResult.searchQuery; - } - } - - const sortedDocs = await this.rerankDocs( - query, - docs ?? [], - fileIds, - embeddings, - optimizationMode, - llm, - systemInstructions, - emitter, - signal, - ); - - if (options?.signal?.aborted || signal?.aborted) { - console.log('Request cancelled by user'); - throw new Error('Request cancelled by user'); - } - - this.emitProgress(emitter, 100, `Done`); - return sortedDocs; - }, - ) - .withConfig({ - runName: 'FinalSourceRetriever', - }) - .pipe(this.processDocs), - }), - ChatPromptTemplate.fromMessages([ - ['system', this.config.responsePrompt], - new MessagesPlaceholder('chat_history'), - ['user', '{query}'], - ]), - llm, - this.strParser, - ]).withConfig({ - runName: 'FinalResponseGenerator', - }); - } - - private async rerankDocs( - query: string, - docs: Document[], - fileIds: string[], - embeddings: Embeddings, - optimizationMode: 'speed' | 'balanced' | 'agent', - llm: BaseChatModel, - systemInstructions: string, - emitter: eventEmitter, - signal: AbortSignal, - ): Promise { - try { - if (docs.length === 0 && fileIds.length === 0) { - return docs; - } - - if (query.toLocaleLowerCase() === 'summarize') { - return docs.slice(0, 15); - } - - const filesData = fileIds - .map((file) => { - const filePath = path.join(process.cwd(), 'uploads', file); - - const contentPath = filePath + '-extracted.json'; - const embeddingsPath = filePath + '-embeddings.json'; - - const content = JSON.parse(fs.readFileSync(contentPath, 'utf8')); - const embeddings = JSON.parse( - fs.readFileSync(embeddingsPath, 'utf8'), - ); - - const fileSimilaritySearchObject = content.contents.map( - (c: string, i: number) => { - return { - fileName: content.title, - content: c, - embeddings: embeddings.embeddings[i], - }; - }, - ); - - return fileSimilaritySearchObject; - }) - .flat(); - - let docsWithContent = docs.filter( - (doc) => doc.pageContent && doc.pageContent.length > 0, - ); - - const queryEmbedding = await embeddings.embedQuery(query); - - const getRankedDocs = async ( - queryEmbedding: number[], - includeFiles: boolean, - includeNonFileDocs: boolean, - maxDocs: number, - ) => { - let docsToRank = includeNonFileDocs ? docsWithContent : []; - - if (includeFiles) { - // Add file documents to the ranking - const fileDocs = filesData.map((fileData) => { - return new Document({ - pageContent: fileData.content, - metadata: { - title: fileData.fileName, - url: `File`, - embeddings: fileData.embeddings, - }, - }); - }); - docsToRank.push(...fileDocs); - } - - const similarity = await Promise.all( - docsToRank.map(async (doc, i) => { - const sim = computeSimilarity( - queryEmbedding, - doc.metadata?.embeddings - ? doc.metadata?.embeddings - : (await embeddings.embedDocuments([doc.pageContent]))[0], - ); - return { - index: i, - similarity: sim, - }; - }), - ); - - let rankedDocs = similarity - .filter( - (sim) => sim.similarity > (this.config.rerankThreshold ?? 0.3), - ) - .sort((a, b) => b.similarity - a.similarity) - .map((sim) => docsToRank[sim.index]); - - rankedDocs = - docsToRank.length > 0 ? rankedDocs.slice(0, maxDocs) : rankedDocs; - return rankedDocs; - }; - if (optimizationMode === 'speed' || this.config.rerank === false) { - this.emitProgress( - emitter, - 50, - `Ranking sources`, - this.searchQuery ? `Search Query: ${this.searchQuery}` : undefined, - ); - if (filesData.length > 0) { - const sortedFiles = await getRankedDocs( - queryEmbedding, - true, - false, - 8, - ); - - return [ - ...sortedFiles, - ...docsWithContent.slice(0, 15 - sortedFiles.length), - ]; - } else { - return docsWithContent.slice(0, 15); - } - } else if (optimizationMode === 'balanced') { - this.emitProgress( - emitter, - 40, - `Ranking sources`, - this.searchQuery ? `Search Query: ${this.searchQuery}` : undefined, - ); - // Get the top ranked attached files, if any - let sortedDocs = await getRankedDocs(queryEmbedding, true, false, 8); - - sortedDocs = [ - ...sortedDocs, - ...docsWithContent.slice(0, 15 - sortedDocs.length), - ]; - - this.emitProgress( - emitter, - 60, - `Enriching sources`, - this.searchQuery ? `Search Query: ${this.searchQuery}` : undefined, - ); - sortedDocs = await Promise.all( - sortedDocs.map(async (doc) => { - const webContent = await getWebContent(doc.metadata.url); - const chunks = - webContent?.pageContent - .match(/.{1,500}/g) - ?.map((chunk) => chunk.trim()) || []; - const chunkEmbeddings = await embeddings.embedDocuments(chunks); - const similarities = chunkEmbeddings.map((chunkEmbedding) => { - return computeSimilarity(queryEmbedding, chunkEmbedding); - }); - - const topChunks = similarities - .map((similarity, index) => ({ similarity, index })) - .sort((a, b) => b.similarity - a.similarity) - .slice(0, 5) - .map((chunk) => chunks[chunk.index]); - const excerpt = topChunks.join('\n\n'); - - let newDoc = { - ...doc, - pageContent: excerpt - ? `${excerpt}\n\n${doc.pageContent}` - : doc.pageContent, - }; - return newDoc; - }), - ); - - return sortedDocs; - } - } catch (error) { - console.error('Error in rerankDocs:', error); - emitter.emit('error', JSON.stringify({ data: error })); - } - return []; - } - - private processDocs(docs: Document[]) { - const fullDocs = docs - .map( - (_, index) => - `<${index + 1}>\n -${docs[index].metadata.title}\n -${docs[index].metadata?.url.toLowerCase().includes('file') ? '' : '\n' + docs[index].metadata.url + '\n'} -\n${docs[index].pageContent}\n\n -\n`, - ) - .join('\n'); - console.log('Processed docs:', fullDocs); - return fullDocs; - } - - private async handleStream( - stream: AsyncGenerator, - emitter: eventEmitter, - llm: BaseChatModel, - signal: AbortSignal, - ) { - if (signal.aborted) { - return; - } - - for await (const event of stream) { - if (signal.aborted) { - return; - } - - if ( - event.event === 'on_chain_end' && - event.name === 'FinalSourceRetriever' - ) { - const sourcesData = event.data.output; - if (this.searchQuery) { - emitter.emit( - 'data', - JSON.stringify({ - type: 'sources', - data: sourcesData, - searchQuery: this.searchQuery, - searchUrl: this.searxngUrl, - }), - ); - } else { - emitter.emit( - 'data', - JSON.stringify({ type: 'sources', data: sourcesData }), - ); - } - } - if ( - event.event === 'on_chain_stream' && - event.name === 'FinalResponseGenerator' - ) { - emitter.emit( - 'data', - JSON.stringify({ type: 'response', data: event.data.chunk }), - ); - } - if ( - event.event === 'on_chain_end' && - event.name === 'FinalResponseGenerator' - ) { - const modelName = getModelName(llm); - - // Send model info before ending - emitter.emit( - 'stats', - JSON.stringify({ - type: 'modelStats', - data: { - modelName, - }, - }), - ); - - emitter.emit('end'); - } - } - } - /** * Execute agent workflow asynchronously with proper streaming support */ @@ -719,7 +86,7 @@ ${docs[index].metadata?.url.toLowerCase().includes('file') ? '' : '\n' + do history: BaseMessage[], llm: BaseChatModel, embeddings: Embeddings, - optimizationMode: 'speed' | 'balanced' | 'agent', + optimizationMode: 'speed' | 'agent', fileIds: string[], systemInstructions: string, signal: AbortSignal, @@ -728,50 +95,35 @@ ${docs[index].metadata?.url.toLowerCase().includes('file') ? '' : '\n' + do ) { const emitter = new eventEmitter(); - // Branch to agent search if optimization mode is 'agent' - if (optimizationMode === 'agent') { - // Execute agent workflow asynchronously to maintain streaming - this.executeAgentWorkflow( - llm, - embeddings, - emitter, + // Branch to speed search if optimization mode is 'speed' + if (optimizationMode === 'speed') { + const speedSearchAgent = new SpeedSearchAgent(this.config); + return speedSearchAgent.searchAndAnswer( message, history, - fileIds, + llm, + embeddings, systemInstructions, - personaInstructions || '', signal, - focusMode || 'webSearch', + personaInstructions, + focusMode, ); - return emitter; } - // Existing logic for other optimization modes - const answeringChain = await this.createAnsweringChain( + // Execute agent workflow for 'agent' mode + this.executeAgentWorkflow( llm, - fileIds, embeddings, - optimizationMode, - systemInstructions, - signal, emitter, - personaInstructions, + message, + history, + fileIds, + systemInstructions, + personaInstructions || '', + signal, + focusMode || 'webSearch', ); - const stream = answeringChain.streamEvents( - { - chat_history: history, - query: message, - }, - { - version: 'v1', - // Pass the abort signal to the LLM streaming chain - signal, - }, - ); - - this.handleStream(stream, emitter, llm, signal); - return emitter; } } diff --git a/src/lib/search/speedSearch.ts b/src/lib/search/speedSearch.ts new file mode 100644 index 0000000..b09a2b4 --- /dev/null +++ b/src/lib/search/speedSearch.ts @@ -0,0 +1,560 @@ +import type { Embeddings } from '@langchain/core/embeddings'; +import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import { BaseMessage } from '@langchain/core/messages'; +import { StringOutputParser } from '@langchain/core/output_parsers'; +import { + ChatPromptTemplate, + MessagesPlaceholder, + PromptTemplate, +} from '@langchain/core/prompts'; +import { + RunnableLambda, + RunnableMap, + RunnableSequence, +} from '@langchain/core/runnables'; +import { StreamEvent } from '@langchain/core/tracers/log_stream'; +import { ChatOpenAI } from '@langchain/openai'; +import eventEmitter from 'events'; +import { Document } from 'langchain/document'; +import LineOutputParser from '../outputParsers/lineOutputParser'; +import LineListOutputParser from '../outputParsers/listLineOutputParser'; +import { searchSearxng } from '../searxng'; +import { formatDateForLLM } from '../utils'; +import { getDocumentsFromLinks } from '../utils/documents'; +import formatChatHistoryAsString from '../utils/formatHistory'; +import { getModelName } from '../utils/modelUtils'; + +export interface SpeedSearchAgentType { + searchAndAnswer: ( + message: string, + history: BaseMessage[], + llm: BaseChatModel, + embeddings: Embeddings, + systemInstructions: string, + signal: AbortSignal, + personaInstructions?: string, + focusMode?: string, + ) => Promise; +} + +interface Config { + searchWeb: boolean; + rerank: boolean; + summarizer: boolean; + rerankThreshold: number; + queryGeneratorPrompt: string; + responsePrompt: string; + activeEngines: string[]; + additionalSearchCriteria?: string; +} + +type BasicChainInput = { + chat_history: BaseMessage[]; + query: string; +}; + +class SpeedSearchAgent implements SpeedSearchAgentType { + private config: Config; + private strParser = new StringOutputParser(); + private searchQuery?: string; + private searxngUrl?: string; + + constructor(config: Config) { + this.config = config; + } + + /** + * Emit a progress event with the given percentage and message + */ + private emitProgress( + emitter: eventEmitter, + percentage: number, + message: string, + subMessage?: string, + ) { + const progressData: any = { + message, + current: percentage, + total: 100, + }; + + // Add subMessage if provided + if (subMessage) { + progressData.subMessage = subMessage; + } + + emitter.emit( + 'progress', + JSON.stringify({ + type: 'progress', + data: progressData, + }), + ); + } + + private async createSearchRetrieverChain( + llm: BaseChatModel, + systemInstructions: string, + emitter: eventEmitter, + signal: AbortSignal, + ) { + // TODO: Don't we want to set this back to default once search is done? + (llm as unknown as ChatOpenAI).temperature = 0; + + this.emitProgress(emitter, 10, `Building search query`); + + return RunnableSequence.from([ + PromptTemplate.fromTemplate(this.config.queryGeneratorPrompt), + llm, + this.strParser, + RunnableLambda.from(async (input: string) => { + try { + //console.log(`LLM response for initial web search:"${input}"`); + const linksOutputParser = new LineListOutputParser({ + key: 'links', + }); + + const questionOutputParser = new LineOutputParser({ + key: 'answer', + }); + + const links = await linksOutputParser.parse(input); + let question = await questionOutputParser.parse(input); + + //console.log('question', question); + + if (question === 'not_needed') { + return { query: '', docs: [] }; + } + + if (links.length > 0) { + if (question.length === 0) { + question = 'summarize'; + } + + let docs: Document[] = []; + + const linkDocs = await getDocumentsFromLinks({ links }); + + const docGroups: Document[] = []; + + linkDocs.map((doc) => { + const URLDocExists = docGroups.find( + (d) => + d.metadata.url === doc.metadata.url && + d.metadata.totalDocs < 10, + ); + + if (!URLDocExists) { + docGroups.push({ + ...doc, + metadata: { + ...doc.metadata, + totalDocs: 1, + }, + }); + } + + const docIndex = docGroups.findIndex( + (d) => + d.metadata.url === doc.metadata.url && + d.metadata.totalDocs < 10, + ); + + if (docIndex !== -1) { + docGroups[docIndex].pageContent = + docGroups[docIndex].pageContent + `\n\n` + doc.pageContent; + docGroups[docIndex].metadata.totalDocs += 1; + } + }); + + this.emitProgress(emitter, 20, `Summarizing content`); + + await Promise.all( + docGroups.map(async (doc) => { + const systemPrompt = systemInstructions + ? `${systemInstructions}\n\n` + : ''; + + const res = await llm.invoke( + `${systemPrompt}You are a web search summarizer, tasked with summarizing a piece of text retrieved from a web search. Your job is to summarize the + text into a detailed, 2-4 paragraph explanation that captures the main ideas and provides a comprehensive answer to the query. + If the query is \"summarize\", you should provide a detailed summary of the text. If the query is a specific question, you should answer it in the summary. + + - **Journalistic tone**: The summary should sound professional and journalistic, not too casual or vague. + - **Thorough and detailed**: Ensure that every key point from the text is captured and that the summary directly answers the query. + - **Not too lengthy, but detailed**: The summary should be informative but not excessively long. Focus on providing detailed information in a concise format. + + The text will be shared inside the \`text\` XML tag, and the query inside the \`query\` XML tag. + + + 1. \` + Docker is a set of platform-as-a-service products that use OS-level virtualization to deliver software in packages called containers. + It was first released in 2013 and is developed by Docker, Inc. Docker is designed to make it easier to create, deploy, and run applications + by using containers. + + + + What is Docker and how does it work? + + + Response: + Docker is a revolutionary platform-as-a-service product developed by Docker, Inc., that uses container technology to make application + deployment more efficient. It allows developers to package their software with all necessary dependencies, making it easier to run in + any environment. Released in 2013, Docker has transformed the way applications are built, deployed, and managed. + \` + 2. \` + The theory of relativity, or simply relativity, encompasses two interrelated theories of Albert Einstein: special relativity and general + relativity. However, the word "relativity" is sometimes used in reference to Galilean invariance. The term "theory of relativity" was based + on the expression "relative theory" used by Max Planck in 1906. The theory of relativity usually encompasses two interrelated theories by + Albert Einstein: special relativity and general relativity. Special relativity applies to all physical phenomena in the absence of gravity. + General relativity explains the law of gravitation and its relation to other forces of nature. It applies to the cosmological and astrophysical + realm, including astronomy. + + + + summarize + + + Response: + The theory of relativity, developed by Albert Einstein, encompasses two main theories: special relativity and general relativity. Special + relativity applies to all physical phenomena in the absence of gravity, while general relativity explains the law of gravitation and its + relation to other forces of nature. The theory of relativity is based on the concept of "relative theory," as introduced by Max Planck in + 1906. It is a fundamental theory in physics that has revolutionized our understanding of the universe. + \` + + + Everything below is the actual data you will be working with. Good luck! + + + ${question} + + + + ${doc.pageContent} + + + Make sure to answer the query in the summary. + `, + { signal }, + ); + + const document = new Document({ + pageContent: res.content as string, + metadata: { + title: doc.metadata.title, + url: doc.metadata.url, + }, + }); + + docs.push(document); + }), + ); + + return { query: question, docs: docs }; + } else { + if (this.config.additionalSearchCriteria) { + question = `${question} ${this.config.additionalSearchCriteria}`; + } + this.emitProgress( + emitter, + 20, + `Searching the web`, + `Search Query: ${question}`, + ); + + const searxngResult = await searchSearxng(question, { + language: 'en', + engines: this.config.activeEngines, + }); + + // Store the SearXNG URL for later use in emitting to the client + this.searxngUrl = searxngResult.searchUrl; + + const documents = searxngResult.results.map( + (result) => + new Document({ + pageContent: + result.content || + (this.config.activeEngines.includes('youtube') + ? result.title + : '') /* Todo: Implement transcript grabbing using Youtubei (source: https://www.npmjs.com/package/youtubei) */, + metadata: { + title: result.title, + url: result.url, + ...(result.img_src && { img_src: result.img_src }), + }, + }), + ); + + return { query: question, docs: documents, searchQuery: question }; + } + } catch (error) { + console.error('Error in search retriever chain:', error); + emitter.emit('error', JSON.stringify({ data: error })); + throw error; + } + }), + ]); + } + + private async createAnsweringChain( + llm: BaseChatModel, + embeddings: Embeddings, + systemInstructions: string, + signal: AbortSignal, + emitter: eventEmitter, + personaInstructions?: string, + ) { + return RunnableSequence.from([ + RunnableMap.from({ + systemInstructions: () => systemInstructions, + query: (input: BasicChainInput) => input.query, + chat_history: (input: BasicChainInput) => input.chat_history, + date: () => formatDateForLLM(), + personaInstructions: () => personaInstructions || '', + context: RunnableLambda.from( + async ( + input: BasicChainInput, + options?: { signal?: AbortSignal }, + ) => { + // Check if the request was aborted + if (options?.signal?.aborted || signal?.aborted) { + console.log('Request cancelled by user'); + throw new Error('Request cancelled by user'); + } + + const processedHistory = formatChatHistoryAsString( + input.chat_history, + ); + + let docs: Document[] | null = null; + let query = input.query; + + if (this.config.searchWeb) { + const searchRetrieverChain = + await this.createSearchRetrieverChain( + llm, + systemInstructions, + emitter, + signal, + ); + var date = formatDateForLLM(); + + const searchRetrieverResult = await searchRetrieverChain.invoke( + { + chat_history: processedHistory, + query, + date, + systemInstructions, + }, + { signal: options?.signal }, + ); + + query = searchRetrieverResult.query; + docs = searchRetrieverResult.docs; + + // Store the search query in the context for emitting to the client + if (searchRetrieverResult.searchQuery) { + this.searchQuery = searchRetrieverResult.searchQuery; + } + } + + const sortedDocs = await this.rerankDocsForSpeed( + query, + docs ?? [], + embeddings, + emitter, + signal, + ); + + if (options?.signal?.aborted || signal?.aborted) { + console.log('Request cancelled by user'); + throw new Error('Request cancelled by user'); + } + + this.emitProgress(emitter, 100, `Done`); + return sortedDocs; + }, + ) + .withConfig({ + runName: 'FinalSourceRetriever', + }) + .pipe(this.processDocs), + }), + ChatPromptTemplate.fromMessages([ + ['system', this.config.responsePrompt], + new MessagesPlaceholder('chat_history'), + ['user', '{query}'], + ]), + llm, + this.strParser, + ]).withConfig({ + runName: 'FinalResponseGenerator', + }); + } + + /** + * Speed-optimized document reranking with simplified logic for web results only + */ + private async rerankDocsForSpeed( + query: string, + docs: Document[], + embeddings: Embeddings, + emitter: eventEmitter, + signal: AbortSignal, + ): Promise { + try { + if (docs.length === 0) { + return docs; + } + + if (query.toLocaleLowerCase() === 'summarize') { + return docs.slice(0, 15); + } + + // Filter out documents with no content + let docsWithContent = docs.filter( + (doc) => doc.pageContent && doc.pageContent.length > 0, + ); + + // Speed mode logic - simply return first 15 documents with content + // No similarity ranking to prioritize speed + this.emitProgress( + emitter, + 50, + `Ranking sources`, + this.searchQuery ? `Search Query: ${this.searchQuery}` : undefined, + ); + + return docsWithContent.slice(0, 15); + } catch (error) { + console.error('Error in rerankDocsForSpeed:', error); + emitter.emit('error', JSON.stringify({ data: error })); + } + return []; + } + + private processDocs(docs: Document[]) { + const fullDocs = docs + .map( + (_, index) => + `<${index + 1}>\n +${docs[index].metadata.title}\n +${docs[index].metadata?.url.toLowerCase().includes('file') ? '' : '\n' + docs[index].metadata.url + '\n'} +\n${docs[index].pageContent}\n\n +\n`, + ) + .join('\n'); + console.log('Processed docs:', fullDocs); + return fullDocs; + } + + private async handleStream( + stream: AsyncGenerator, + emitter: eventEmitter, + llm: BaseChatModel, + signal: AbortSignal, + ) { + if (signal.aborted) { + return; + } + + for await (const event of stream) { + if (signal.aborted) { + return; + } + + if ( + event.event === 'on_chain_end' && + event.name === 'FinalSourceRetriever' + ) { + const sourcesData = event.data.output; + if (this.searchQuery) { + emitter.emit( + 'data', + JSON.stringify({ + type: 'sources', + data: sourcesData, + searchQuery: this.searchQuery, + searchUrl: this.searxngUrl, + }), + ); + } else { + emitter.emit( + 'data', + JSON.stringify({ type: 'sources', data: sourcesData }), + ); + } + } + if ( + event.event === 'on_chain_stream' && + event.name === 'FinalResponseGenerator' + ) { + emitter.emit( + 'data', + JSON.stringify({ type: 'response', data: event.data.chunk }), + ); + } + if ( + event.event === 'on_chain_end' && + event.name === 'FinalResponseGenerator' + ) { + const modelName = getModelName(llm); + + // Send model info before ending + emitter.emit( + 'stats', + JSON.stringify({ + type: 'modelStats', + data: { + modelName, + }, + }), + ); + + emitter.emit('end'); + } + } + } + + async searchAndAnswer( + message: string, + history: BaseMessage[], + llm: BaseChatModel, + embeddings: Embeddings, + systemInstructions: string, + signal: AbortSignal, + personaInstructions?: string, + focusMode?: string, + ) { + const emitter = new eventEmitter(); + + const answeringChain = await this.createAnsweringChain( + llm, + embeddings, + systemInstructions, + signal, + emitter, + personaInstructions, + ); + + const stream = answeringChain.streamEvents( + { + chat_history: history, + query: message, + }, + { + version: 'v1', + // Pass the abort signal to the LLM streaming chain + signal, + }, + ); + + this.handleStream(stream, emitter, llm, signal); + + return emitter; + } +} + +export default SpeedSearchAgent; diff --git a/src/lib/utils/fileProcessing.ts b/src/lib/utils/fileProcessing.ts index a53ee08..8d94028 100644 --- a/src/lib/utils/fileProcessing.ts +++ b/src/lib/utils/fileProcessing.ts @@ -17,7 +17,9 @@ export interface FileData { * @param fileIds Array of file IDs to process * @returns Array of Document objects with content and embeddings */ -export async function processFilesToDocuments(fileIds: string[]): Promise { +export async function processFilesToDocuments( + fileIds: string[], +): Promise { if (fileIds.length === 0) { return []; } @@ -91,7 +93,7 @@ export function getRankedDocs( } // Import computeSimilarity utility - + const similarity = documents.map((doc, i) => { const sim = computeSimilarity( queryEmbedding, diff --git a/testAttachments/sporting-events.txt b/testAttachments/sporting-events.txt new file mode 100644 index 0000000..1a5598f --- /dev/null +++ b/testAttachments/sporting-events.txt @@ -0,0 +1,3 @@ +Who won the 2025 Super Bowl? +Who won the 2023 Formula One Driver's Championship? +Who won the 2022 World Cup? \ No newline at end of file