Просмотр исходного кода

feat: add REST semantic search endpoint

Add POST /api/shops/:id/search as a thin REST wrapper over the Qdrant
vector search that already powers the MCP tools, so plain HTTP clients
can do keyword/sentence search without speaking MCP.

- Single category via `category` body field, or all four buckets
  merged and score-ranked when omitted
- Default JSON response (score/url/title/content_type/snippet), or
  `?format=text` for voice/LLM pipelines
- Accepts internal UUID or custom_id via getShopByAnyId
- Guards: 400 bad input, 404 missing shop, 409 if shop lacks
  custom_id or qdrant_enabled is off, 503 if server-level Qdrant or
  embeddings not configured
- Logs to mcp_logs with tool_name="rest_search" so REST and MCP
  traffic appear together in /api/mcp/analytics
- Self-documenting via the endpoint registry; shows up automatically
  in /doc and /doc/openapi

Docs updated in docs/API.md (new "Semantic search" section) and
README.md (quick curl example).
fszontagh 3 месяцев назад
Родитель
Сommit
15292bbaff
4 измененных файлов с 376 добавлено и 0 удалено
  1. 11 0
      README.md
  2. 102 0
      docs/API.md
  3. 261 0
      src/api/components/SearchEndpoint.ts
  4. 2 0
      src/api/routes.ts

+ 11 - 0
README.md

@@ -90,6 +90,17 @@ curl "http://localhost:3000/api/shops/<id>/results?content_type=shipping&limit=1
   -H "Authorization: Bearer $API_KEY"
 ```
 
+**Semantic search across scraped content** (requires Qdrant + OpenRouter):
+
+```bash
+curl -X POST http://localhost:3000/api/shops/<id>/search \
+  -H "Authorization: Bearer $API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{"q":"do you ship to Germany","limit":5}'
+```
+
+Omit `category` to search all four content buckets in parallel. Add `?format=text` for voice/LLM output.
+
 `<id>` can be either the system-generated UUID or the optional `custom_id` you assigned to the shop.
 
 ## MCP tools

+ 102 - 0
docs/API.md

@@ -49,6 +49,7 @@ The registry is composed from endpoint components under `src/api/components/`. G
 | Content | `ContentEndpoint.ts` | Per-shop scraped content (enable/disable pages) |
 | Custom URLs | `CustomUrlsEndpoint.ts` | Manually add URLs the sitemap crawler would miss |
 | Webhooks | `WebhooksEndpoint.ts` | Per-shop webhook CRUD (scrape_started / completed / failed) |
+| Search | `SearchEndpoint.ts` | Semantic search over scraped content (REST wrapper over Qdrant) |
 | Qdrant | `QdrantEndpoint.ts` | Vector DB management, per-shop embedding lifecycle, v1→v2 migration |
 | MCP | `McpEndpoint.ts` | MCP call logs and analytics |
 | Documentation | `DocumentationEndpoint.ts` | `/doc`, `/doc/openapi`, `/doc/endpoints` |
@@ -92,6 +93,9 @@ Use `GET /doc/endpoints` for the exact, always-up-to-date list. A summarised sna
 - `PATCH /api/shops/:id/webhooks`
 - `DELETE /api/shops/:id/webhooks`
 
+### Semantic search
+- `POST /api/shops/:id/search` — see [Semantic search](#semantic-search) below
+
 ### Qdrant (system-wide)
 - `GET /api/qdrant/stats`
 - `GET /api/qdrant/health`
@@ -160,6 +164,104 @@ Errors return a JSON body:
 
 Status codes follow standard REST semantics: `400` for bad input, `401` for bad auth, `404` for missing resources, `409` for conflicts (e.g. duplicate `custom_id`), `500` for server errors, `503` when the database or Qdrant is unavailable.
 
+## Semantic search
+
+`POST /api/shops/:id/search` is a thin REST wrapper over the Qdrant vector search that powers the MCP tools. Use it when you want semantic (sentence/keyword) search from a plain HTTP client without speaking MCP.
+
+**Preconditions** (enforced by the endpoint):
+
+- `QDRANT_ENABLED=true` on the server and `OPENROUTER_API_KEY` is set.
+- The shop has a `custom_id` and is toggled on via `PUT /api/shops/:id/qdrant/toggle`.
+- The shop has been scraped at least once so embeddings exist.
+
+**Path parameter**
+
+- `:id` — either the internal UUID or the shop's `custom_id`.
+
+**Query string**
+
+- `format=json` *(default)* — structured JSON.
+- `format=text` — plain text, one block per result, suited for voice/LLM pipelines.
+
+**Request body**
+
+```json
+{
+  "q": "international delivery times",
+  "category": "shipping",
+  "limit": 5
+}
+```
+
+| Field | Required | Description |
+|---|---|---|
+| `q` | yes | Natural-language query. |
+| `category` | no | One of `shipping`, `contacts`, `terms`, `faq`. Omit to search all four in parallel and get a merged, score-ranked list. |
+| `limit` | no | 1–20, default 10. Applies to the final merged list. |
+
+**Example — search a single category:**
+
+```bash
+curl -X POST http://localhost:3000/api/shops/shop123/search \
+  -H "Authorization: Bearer $API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{"q":"express shipping to Germany","category":"shipping","limit":5}'
+```
+
+**Example — search everything, text output:**
+
+```bash
+curl -X POST "http://localhost:3000/api/shops/shop123/search?format=text" \
+  -H "Authorization: Bearer $API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{"q":"how do I return a defective product"}'
+```
+
+**JSON response shape:**
+
+```json
+{
+  "shop_id": "shop123",
+  "query": "express shipping to Germany",
+  "category": "shipping",
+  "total": 3,
+  "results": [
+    {
+      "score": 0.87,
+      "content_type": "shipping",
+      "url": "https://example-shop.com/shipping",
+      "title": "Shipping policy",
+      "content_id": "…",
+      "snippet": "We offer express delivery to Germany within 2-3 business days…"
+    }
+  ]
+}
+```
+
+**Text response shape:**
+
+```
+Shipping policy
+We offer express delivery to Germany within 2-3 business days…
+
+Delivery FAQ
+For express shipments, tracking numbers are issued within…
+```
+
+**Status codes**
+
+| Code | Meaning |
+|---|---|
+| `200` | Results (possibly empty). |
+| `400` | Missing `q`, invalid `category`, or `limit` out of range. |
+| `404` | Shop not found. |
+| `409` | Shop has no `custom_id`, or `qdrant_enabled` is false on the shop. |
+| `503` | Qdrant disabled server-wide, or embedding service not configured. |
+
+**Logging**
+
+Each REST search is written to `mcp_logs` with `tool_name: "rest_search"` so it shows up alongside MCP tool calls in the analytics endpoints — useful for comparing traffic sources.
+
 ## MCP (Model Context Protocol)
 
 When both `MCP_ENABLED=true` and `QDRANT_ENABLED=true`, the server mounts a **Streamable HTTP** MCP endpoint at `/mcp` (see `src/mcp/McpServer.ts`). Five tools are registered:

+ 261 - 0
src/api/components/SearchEndpoint.ts

@@ -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');
+  }
+}

+ 2 - 0
src/api/routes.ts

@@ -15,6 +15,7 @@ import { ContentEndpoint } from './components/ContentEndpoint';
 import { HealthEndpoint } from './components/HealthEndpoint';
 import { QdrantEndpoint } from './components/QdrantEndpoint';
 import { McpEndpoint } from './components/McpEndpoint';
+import { SearchEndpoint } from './components/SearchEndpoint';
 import type { AppConfig } from '../config';
 
 export function createRouter(jobQueue: JobQueue, apiKey: string, db?: ShopDatabase, config?: AppConfig): Router {
@@ -43,6 +44,7 @@ export function createRouter(jobQueue: JobQueue, apiKey: string, db?: ShopDataba
     // Register Qdrant endpoints if config is available
     if (config) {
       components.push(new QdrantEndpoint(db, config));
+      components.push(new SearchEndpoint(db, config));
     }
   }