Explorar el Código

feat: add list_contents tool and simplify search output for voice

- Add list_contents tool to show available content with associated search tools
- Simplify search tool output to "title\nchunk_text" format
- Remove URLs and formatting - optimized for phone/voice use

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Fszontagh hace 8 meses
padre
commit
7d02d2ba8e
Se han modificado 3 ficheros con 174 adiciones y 55 borrados
  1. 4 0
      src/mcp/McpServer.ts
  2. 9 55
      src/mcp/tools/BaseTool.ts
  3. 161 0
      src/mcp/tools/ListContentsTool.ts

+ 4 - 0
src/mcp/McpServer.ts

@@ -11,6 +11,7 @@ import { ShippingSearchTool } from './tools/ShippingSearchTool';
 import { ContactsSearchTool } from './tools/ContactsSearchTool';
 import { ContactsSearchTool } from './tools/ContactsSearchTool';
 import { TermsSearchTool } from './tools/TermsSearchTool';
 import { TermsSearchTool } from './tools/TermsSearchTool';
 import { FaqSearchTool } from './tools/FaqSearchTool';
 import { FaqSearchTool } from './tools/FaqSearchTool';
+import { ListContentsTool } from './tools/ListContentsTool';
 import type { AppConfig } from '../config';
 import type { AppConfig } from '../config';
 
 
 export class McpServer {
 export class McpServer {
@@ -94,7 +95,10 @@ export class McpServer {
       this.mcpLogger
       this.mcpLogger
     );
     );
 
 
+    const listContentsTool = new ListContentsTool(this.database);
+
     // Register tools
     // Register tools
+    this.tools.set('list_contents', listContentsTool);
     this.tools.set('shipping_search', shippingTool);
     this.tools.set('shipping_search', shippingTool);
     this.tools.set('contacts_search', contactsTool);
     this.tools.set('contacts_search', contactsTool);
     this.tools.set('terms_search', termsTool);
     this.tools.set('terms_search', termsTool);

+ 9 - 55
src/mcp/tools/BaseTool.ts

@@ -116,6 +116,7 @@ export abstract class BaseTool {
 
 
   /**
   /**
    * Format search results into tool response
    * Format search results into tool response
+   * Returns simple format: "title\nchunk_text" for each result
    */
    */
   protected formatResults(searchResults: any[], query: string, limit: number): ToolResult {
   protected formatResults(searchResults: any[], query: string, limit: number): ToolResult {
     if (searchResults.length === 0) {
     if (searchResults.length === 0) {
@@ -129,68 +130,21 @@ export abstract class BaseTool {
       };
       };
     }
     }
 
 
-    // Group results by content_id to combine chunks from the same page
-    const groupedResults = new Map<string, any[]>();
+    // Sort by score (highest first)
+    const sortedResults = [...searchResults].sort((a, b) => b.score - a.score);
 
 
-    searchResults.forEach(result => {
-      const contentId = result.payload?.content_id || result.id;
-      if (!groupedResults.has(contentId)) {
-        groupedResults.set(contentId, []);
-      }
-      groupedResults.get(contentId)!.push(result);
+    // Format each result as "title\nchunk_text"
+    const formattedResults = sortedResults.map(result => {
+      const title = result.payload?.title || 'Untitled';
+      const chunkText = result.payload?.chunk_text || '';
+      return `${title}\n${chunkText}`;
     });
     });
 
 
-    // Format grouped results
-    let responseText = `Found ${groupedResults.size} page${groupedResults.size === 1 ? '' : 's'} with ${searchResults.length} relevant section${searchResults.length === 1 ? '' : 's'} for "${query}" in ${this.contentType}:\n\n`;
-
-    let pageIndex = 0;
-    for (const [contentId, chunks] of groupedResults) {
-      pageIndex++;
-
-      // Sort chunks by index
-      chunks.sort((a, b) => (a.payload?.chunk_index || 0) - (b.payload?.chunk_index || 0));
-
-      const firstChunk = chunks[0];
-      const title = firstChunk.payload?.title || 'Untitled';
-      const url = firstChunk.payload?.url || '';
-      const avgScore = chunks.reduce((sum, c) => sum + c.score, 0) / chunks.length;
-
-      responseText += `${pageIndex}. **${title}**\n`;
-      responseText += `   URL: ${url}\n`;
-      responseText += `   Relevance: ${(avgScore * 100).toFixed(1)}%\n`;
-
-      if (chunks.length === 1) {
-        // Single chunk - show full chunk text
-        const chunkText = firstChunk.payload?.chunk_text || '';
-        responseText += `   Content: ${chunkText}\n`;
-      } else {
-        // Multiple chunks - show each relevant section
-        responseText += `   ${chunks.length} relevant sections found:\n`;
-
-        chunks.forEach((chunk, idx) => {
-          const chunkText = chunk.payload?.chunk_text || '';
-          const chunkIndex = chunk.payload?.chunk_index ?? idx;
-          const totalChunks = chunk.payload?.total_chunks || chunks.length;
-
-          responseText += `\n   Section ${chunkIndex + 1} of ${totalChunks} (${(chunk.score * 100).toFixed(1)}% match):\n`;
-          responseText += `   ${chunkText}\n`;
-        });
-      }
-
-      if (pageIndex < groupedResults.size) {
-        responseText += '\n';
-      }
-    }
-
-    if (searchResults.length === limit) {
-      responseText += '\n\n*Results limited to top ' + limit + ' matches. Refine your query for more specific results.*';
-    }
-
     return {
     return {
       content: [
       content: [
         {
         {
           type: 'text',
           type: 'text',
-          text: responseText,
+          text: formattedResults.join('\n\n'),
         },
         },
       ],
       ],
     };
     };

+ 161 - 0
src/mcp/tools/ListContentsTool.ts

@@ -0,0 +1,161 @@
+import { ShopDatabase } from '../../database/Database';
+import { logger } from '../../utils/logger';
+
+export interface ToolResult {
+  content: Array<{
+    type: 'text';
+    text: string;
+  }>;
+  isError?: boolean;
+}
+
+// Map content types to their search tools
+const CONTENT_TYPE_TO_TOOL: Record<string, string> = {
+  shipping: 'shipping_search',
+  contacts: 'contacts_search',
+  terms: 'terms_search',
+  faq: 'faq_search',
+};
+
+export class ListContentsTool {
+  constructor(private database: ShopDatabase) {}
+
+  /**
+   * Execute the tool - list all content titles with their associated search tools
+   */
+  async execute(args: any): Promise<ToolResult> {
+    try {
+      // Validate arguments
+      const validation = this.validateArgs(args);
+      if (!validation.valid) {
+        return this.createErrorResult(validation.error || 'Invalid arguments');
+      }
+
+      const shopId = String(args.shop_id);
+
+      // Check if shop exists
+      const shop = this.database.getShopByCustomId(shopId);
+      if (!shop) {
+        return this.createErrorResult('Shop not found');
+      }
+
+      // Get all content for this shop (only enabled content)
+      const allContent = this.database.getAllShopContent(shop.id, false);
+
+      if (allContent.length === 0) {
+        return {
+          content: [
+            {
+              type: 'text',
+              text: `No content available for shop "${shopId}". The shop may not have been scraped yet.`,
+            },
+          ],
+        };
+      }
+
+      // Group content by type and get unique titles
+      const contentByType: Record<string, string[]> = {
+        shipping: [],
+        contacts: [],
+        terms: [],
+        faq: [],
+      };
+
+      // Use a Set to track unique URLs per type (to avoid duplicates from versioning)
+      const seenUrls: Record<string, Set<string>> = {
+        shipping: new Set(),
+        contacts: new Set(),
+        terms: new Set(),
+        faq: new Set(),
+      };
+
+      for (const content of allContent) {
+        const type = content.content_type;
+        if (contentByType[type] && !seenUrls[type].has(content.url)) {
+          seenUrls[type].add(content.url);
+          contentByType[type].push(content.title || 'Untitled');
+        }
+      }
+
+      // Format the response - simple list for voice/phone use
+      const lines: string[] = [];
+
+      for (const [type, titles] of Object.entries(contentByType)) {
+        if (titles.length > 0) {
+          const toolName = CONTENT_TYPE_TO_TOOL[type];
+          for (const title of titles) {
+            lines.push(`${title} - ${toolName}`);
+          }
+        }
+      }
+
+      const responseText = lines.join('\n');
+
+      return {
+        content: [
+          {
+            type: 'text',
+            text: responseText,
+          },
+        ],
+      };
+
+    } catch (error: any) {
+      logger.error('Error in list_contents tool:', error);
+      return this.createErrorResult(error.message || 'Failed to list contents');
+    }
+  }
+
+  /**
+   * Validate tool arguments
+   */
+  private validateArgs(args: any): { valid: boolean; error?: string } {
+    if (!args || typeof args !== 'object') {
+      return { valid: false, error: 'Arguments must be an object' };
+    }
+
+    if (!args.shop_id || typeof args.shop_id !== 'string') {
+      return { valid: false, error: 'Missing required parameter: shop_id' };
+    }
+
+    return { valid: true };
+  }
+
+  /**
+   * Create error result
+   */
+  private createErrorResult(message: string): ToolResult {
+    return {
+      content: [
+        {
+          type: 'text',
+          text: message,
+        },
+      ],
+      isError: true,
+    };
+  }
+
+  /**
+   * Get tool description for MCP
+   */
+  getDescription(): string {
+    return 'List all available content titles for a webshop with their associated search tools. Use this tool first to discover what content is available and which search tool to use for finding specific information.';
+  }
+
+  /**
+   * Get input schema for MCP
+   */
+  getInputSchema(): object {
+    return {
+      type: 'object',
+      properties: {
+        shop_id: {
+          type: 'string',
+          description: 'The unique identifier of the webshop to list contents for',
+        },
+      },
+      required: ['shop_id'],
+    };
+  }
+}