|
|
@@ -0,0 +1,261 @@
|
|
|
+import { Request, Response } from 'express';
|
|
|
+import { BaseEndpointComponent, EndpointMethod } from '../core/types';
|
|
|
+import { logger } from '../../utils/logger';
|
|
|
+import { ShopDatabase } from '../../database/Database';
|
|
|
+import { QdrantService } from '../../services/QdrantService';
|
|
|
+import { EmbeddingService } from '../../services/EmbeddingService';
|
|
|
+import { McpLogger } from '../../mcp/McpLogger';
|
|
|
+import type { AppConfig } from '../../config';
|
|
|
+
|
|
|
+type Category = 'shipping' | 'contacts' | 'terms' | 'faq';
|
|
|
+const CATEGORIES: Category[] = ['shipping', 'contacts', 'terms', 'faq'];
|
|
|
+
|
|
|
+export class SearchEndpoint extends BaseEndpointComponent {
|
|
|
+ private qdrantService?: QdrantService;
|
|
|
+ private embeddingService?: EmbeddingService;
|
|
|
+ private mcpLogger?: McpLogger;
|
|
|
+
|
|
|
+ constructor(private db: ShopDatabase, private config: AppConfig) {
|
|
|
+ super();
|
|
|
+
|
|
|
+ try {
|
|
|
+ this.qdrantService = new QdrantService(config);
|
|
|
+ this.embeddingService = new EmbeddingService(config);
|
|
|
+ this.mcpLogger = new McpLogger(db);
|
|
|
+ } catch (error) {
|
|
|
+ logger.warn('Search services not available:', error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ getEndpoints(): EndpointMethod[] {
|
|
|
+ return [
|
|
|
+ this.createEndpoint(
|
|
|
+ 'POST',
|
|
|
+ '/api/shops/:id/search',
|
|
|
+ this.search.bind(this),
|
|
|
+ {
|
|
|
+ summary: 'Semantic search in scraped content',
|
|
|
+ description:
|
|
|
+ 'Run a semantic (vector) search over a shop\'s scraped content. ' +
|
|
|
+ 'Omit `category` to search all four buckets in parallel and return a merged, score-ranked list. ' +
|
|
|
+ 'Use `?format=text` for plain-text output suitable for voice/LLM pipelines; JSON is the default. ' +
|
|
|
+ 'Requires the shop to have Qdrant enabled and a custom_id set.',
|
|
|
+ tags: ['Search'],
|
|
|
+ requiresAuth: true,
|
|
|
+ parameters: [
|
|
|
+ {
|
|
|
+ name: 'id',
|
|
|
+ in: 'path',
|
|
|
+ type: 'string',
|
|
|
+ required: true,
|
|
|
+ description: 'Shop identifier (internal UUID or custom_id)',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: 'format',
|
|
|
+ in: 'query',
|
|
|
+ type: 'string',
|
|
|
+ required: false,
|
|
|
+ description: 'Response format: "json" (default) or "text"',
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ requestBody: {
|
|
|
+ required: true,
|
|
|
+ schema: {
|
|
|
+ type: 'object',
|
|
|
+ properties: {
|
|
|
+ q: {
|
|
|
+ type: 'string',
|
|
|
+ description: 'Natural-language query',
|
|
|
+ },
|
|
|
+ category: {
|
|
|
+ type: 'string',
|
|
|
+ description: 'Optional category filter: shipping | contacts | terms | faq',
|
|
|
+ },
|
|
|
+ limit: {
|
|
|
+ type: 'number',
|
|
|
+ description: 'Max results (1-20, default 10)',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ required: ['q'],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ responses: {
|
|
|
+ '200': { description: 'Search results' },
|
|
|
+ '400': { description: 'Invalid query, category or limit' },
|
|
|
+ '404': { description: 'Shop not found' },
|
|
|
+ '409': { description: 'Qdrant not enabled for this shop' },
|
|
|
+ '503': { description: 'Qdrant or embedding service unavailable' },
|
|
|
+ },
|
|
|
+ }
|
|
|
+ ),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ private async search(req: Request, res: Response): Promise<void> {
|
|
|
+ const startTime = Date.now();
|
|
|
+ const identifier = req.params.id;
|
|
|
+ const format = String(req.query.format || 'json').toLowerCase();
|
|
|
+ const { q, category, limit: limitRaw } = req.body || {};
|
|
|
+
|
|
|
+ // Service availability
|
|
|
+ if (!this.qdrantService || !this.embeddingService || !this.config.qdrant.enabled) {
|
|
|
+ res.status(503).json({ error: 'Qdrant search is not enabled on this server' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!this.embeddingService.isEnabled()) {
|
|
|
+ res.status(503).json({ error: 'Embedding service is not configured' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate query
|
|
|
+ if (typeof q !== 'string' || q.trim().length === 0) {
|
|
|
+ res.status(400).json({ error: 'Missing or empty "q" field' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate category
|
|
|
+ if (category !== undefined && !CATEGORIES.includes(category)) {
|
|
|
+ res.status(400).json({
|
|
|
+ error: `Invalid category. Must be one of: ${CATEGORIES.join(', ')}`,
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate limit
|
|
|
+ let limit = 10;
|
|
|
+ if (limitRaw !== undefined) {
|
|
|
+ const parsed = Number(limitRaw);
|
|
|
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > 20) {
|
|
|
+ res.status(400).json({ error: 'limit must be a number between 1 and 20' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ limit = Math.floor(parsed);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Resolve shop
|
|
|
+ const shop = this.db.getShopByAnyId(identifier);
|
|
|
+ if (!shop) {
|
|
|
+ res.status(404).json({ error: 'Shop not found' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!shop.custom_id) {
|
|
|
+ res.status(409).json({
|
|
|
+ error: 'Shop has no custom_id; set one before enabling Qdrant search',
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!shop.qdrant_enabled) {
|
|
|
+ res.status(409).json({ error: 'Qdrant is not enabled for this shop' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const shopCustomId = shop.custom_id;
|
|
|
+
|
|
|
+ try {
|
|
|
+ // Generate query embedding
|
|
|
+ const embeddingResult = await this.embeddingService.generateQueryEmbedding(q);
|
|
|
+ if (!embeddingResult.success) {
|
|
|
+ logger.warn('Failed to generate query embedding', { error: embeddingResult.error });
|
|
|
+ await this.logSearch(shopCustomId, q, 0, Date.now() - startTime, false);
|
|
|
+ res.status(503).json({ error: 'Failed to generate query embedding' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Run search — single category or all four in parallel
|
|
|
+ const categoriesToSearch: Category[] = category ? [category as Category] : CATEGORIES;
|
|
|
+
|
|
|
+ const perCategory = await Promise.all(
|
|
|
+ categoriesToSearch.map((cat) =>
|
|
|
+ this.qdrantService!.searchInCategory(shopCustomId, cat, embeddingResult.embedding, limit)
|
|
|
+ )
|
|
|
+ );
|
|
|
+
|
|
|
+ // Flatten, preserve each hit's category (Qdrant payload already has content_type),
|
|
|
+ // sort by score desc, cap at `limit`
|
|
|
+ const flat = perCategory.flat().sort((a, b) => b.score - a.score).slice(0, limit);
|
|
|
+
|
|
|
+ const durationMs = Date.now() - startTime;
|
|
|
+
|
|
|
+ // Log to mcp_logs under a dedicated tool name so REST and MCP stats stay unified
|
|
|
+ await this.logSearch(shopCustomId, q, flat.length, durationMs, true);
|
|
|
+
|
|
|
+ if (format === 'text') {
|
|
|
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
|
+ res.send(this.formatAsText(flat, q));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Default JSON response
|
|
|
+ res.json({
|
|
|
+ shop_id: identifier,
|
|
|
+ query: q,
|
|
|
+ category: category || null,
|
|
|
+ total: flat.length,
|
|
|
+ results: flat.map((hit) => {
|
|
|
+ const p = hit.payload as any;
|
|
|
+ return {
|
|
|
+ score: hit.score,
|
|
|
+ content_type: p?.content_type || null,
|
|
|
+ url: p?.url || null,
|
|
|
+ title: p?.title || null,
|
|
|
+ content_id: p?.content_id || null,
|
|
|
+ snippet: this.makeSnippet(p?.chunk_text || ''),
|
|
|
+ };
|
|
|
+ }),
|
|
|
+ });
|
|
|
+ } catch (error: any) {
|
|
|
+ const durationMs = Date.now() - startTime;
|
|
|
+ await this.logSearch(shopCustomId, q, 0, durationMs, false);
|
|
|
+ logger.error('Search endpoint error:', error);
|
|
|
+ res.status(500).json({ error: error?.message || 'Search failed' });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async logSearch(
|
|
|
+ shopCustomId: string,
|
|
|
+ query: string,
|
|
|
+ resultsCount: number,
|
|
|
+ executionTimeMs: number,
|
|
|
+ success: boolean
|
|
|
+ ): Promise<void> {
|
|
|
+ if (!this.mcpLogger) return;
|
|
|
+ try {
|
|
|
+ await this.mcpLogger.logToolCall({
|
|
|
+ shopId: shopCustomId,
|
|
|
+ toolName: 'rest_search',
|
|
|
+ query,
|
|
|
+ responseSummary: success ? `${resultsCount} results` : 'Error: search failed',
|
|
|
+ executionTimeMs,
|
|
|
+ resultsCount,
|
|
|
+ success,
|
|
|
+ ipAddress: null,
|
|
|
+ userAgent: null,
|
|
|
+ });
|
|
|
+ } catch {
|
|
|
+ // Logging must never break the request
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private makeSnippet(chunkText: string, maxLength = 240): string {
|
|
|
+ if (!chunkText) return '';
|
|
|
+ const clean = chunkText.replace(/\s+/g, ' ').trim();
|
|
|
+ if (clean.length <= maxLength) return clean;
|
|
|
+ const cut = clean.slice(0, maxLength);
|
|
|
+ const lastSpace = cut.lastIndexOf(' ');
|
|
|
+ return (lastSpace > maxLength * 0.6 ? cut.slice(0, lastSpace) : cut) + '…';
|
|
|
+ }
|
|
|
+
|
|
|
+ private formatAsText(hits: Array<{ score: number; payload: any }>, query: string): string {
|
|
|
+ if (hits.length === 0) {
|
|
|
+ return `No results for: "${query}"`;
|
|
|
+ }
|
|
|
+ return hits
|
|
|
+ .map((hit) => {
|
|
|
+ const p = hit.payload || {};
|
|
|
+ const title = p.title || 'Untitled';
|
|
|
+ const text = p.chunk_text || '';
|
|
|
+ return `${title}\n${text}`;
|
|
|
+ })
|
|
|
+ .join('\n\n');
|
|
|
+ }
|
|
|
+}
|