Эх сурвалжийг харах

feat: search endpoint accepts ?full=true and ?limit=N query params

Two non-breaking additions to POST /api/sites/:id/search:

- ?full=true (or =1 / =yes) replaces the truncated `snippet` field with
  a `content` field carrying the full markdown body of the page (looked
  up via content_id from site_content). Results are deduplicated by
  content_id — without this, multiple chunks of the same page each carry
  the same 5–50 KB body. We over-fetch from Qdrant (3× per category) so
  dedup doesn't shrink the result count below `limit`. The text-format
  output benefits the same way. Default shape with `snippet` is
  unchanged for existing clients.

- ?limit=N is now accepted as a query string in addition to the existing
  `limit` field in the JSON body. Query value wins when both are
  supplied, so callers can override without rewriting their POST body.

The text response's Content-Type was already `text/plain; charset=utf-8`
(verified live) — broken clients that ignore Content-Type aren't
something the server can fix.
fszontagh 1 сар өмнө
parent
commit
40d0043713

+ 61 - 14
src/api/components/SearchEndpoint.ts

@@ -38,8 +38,10 @@ export class SearchEndpoint extends BaseEndpointComponent {
           summary: 'Semantic search in scraped content',
           description:
             'Run a semantic (vector) search over a site\'s scraped content. ' +
-            'Omit `category` to search all four buckets in parallel and return a merged, score-ranked list. ' +
+            'Omit `category` to search all categories 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. ' +
+            'Use `?full=true` to replace the truncated `snippet` field with a `content` field containing the full page markdown; results are deduplicated by content_id. ' +
+            'Use `?limit=N` (or include `limit` in the body) to cap result count (1-20). Query takes precedence. ' +
             'Requires the site to have Qdrant enabled and a custom_id set.',
           tags: ['Search'],
           requiresAuth: true,
@@ -58,6 +60,20 @@ export class SearchEndpoint extends BaseEndpointComponent {
               required: false,
               description: 'Response format: "json" (default) or "text"',
             },
+            {
+              name: 'full',
+              in: 'query',
+              type: 'boolean',
+              required: false,
+              description: 'When truthy, each result gets a `content` field with the full page markdown instead of `snippet`. Results are deduplicated by content_id (highest score kept).',
+            },
+            {
+              name: 'limit',
+              in: 'query',
+              type: 'number',
+              required: false,
+              description: 'Max results (1-20). Takes precedence over `limit` in the request body. Default 10.',
+            },
           ],
           requestBody: {
             required: true,
@@ -96,7 +112,8 @@ export class SearchEndpoint extends BaseEndpointComponent {
     const startTime = Date.now();
     const identifier = req.params.id;
     const format = String(req.query.format || 'json').toLowerCase();
-    const { q, category, limit: limitRaw } = req.body || {};
+    const full = ['1', 'true', 'yes'].includes(String(req.query.full || '').toLowerCase());
+    const { q, category, limit: bodyLimit } = req.body || {};
 
     // Service availability
     if (!this.qdrantService || !this.embeddingService || !this.config.qdrant.enabled) {
@@ -122,8 +139,11 @@ export class SearchEndpoint extends BaseEndpointComponent {
       return;
     }
 
-    // Validate limit
+    // Validate limit — query string wins over body for backwards-friendly
+    // override (clients can keep posting limit in body; new callers can use
+    // ?limit=2 without rewriting their JSON body).
     let limit = 10;
+    const limitRaw = req.query.limit !== undefined ? req.query.limit : bodyLimit;
     if (limitRaw !== undefined) {
       const parsed = Number(limitRaw);
       if (!Number.isFinite(parsed) || parsed < 1 || parsed > 20) {
@@ -162,18 +182,35 @@ export class SearchEndpoint extends BaseEndpointComponent {
         return;
       }
 
-      // Run search — single category or all four in parallel
+      // Run search — single category or all categories in parallel.
+      // When `full=true` we over-fetch (3×) so deduplicating by content_id
+      // afterwards still leaves room to fill `limit` results.
       const categoriesToSearch: Category[] = category ? [category as Category] : CATEGORIES;
+      const perCategoryLimit = full ? limit * 3 : limit;
 
       const perCategory = await Promise.all(
         categoriesToSearch.map((cat) =>
-          this.qdrantService!.searchInCategory(siteCustomId, cat, embeddingResult.embedding, limit)
+          this.qdrantService!.searchInCategory(siteCustomId, cat, embeddingResult.embedding, perCategoryLimit)
         )
       );
 
-      // 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);
+      let flat = perCategory.flat().sort((a, b) => b.score - a.score);
+
+      // Dedupe by content_id when returning full content — otherwise multiple
+      // chunks of the same page each carry the same 5–50 KB body and the
+      // response balloons. Highest-scoring chunk wins.
+      if (full) {
+        const seen = new Set<string>();
+        flat = flat.filter((hit) => {
+          const cid = (hit.payload as any)?.content_id;
+          if (!cid) return true;
+          if (seen.has(cid)) return false;
+          seen.add(cid);
+          return true;
+        });
+      }
+
+      flat = flat.slice(0, limit);
 
       const durationMs = Date.now() - startTime;
 
@@ -182,11 +219,13 @@ export class SearchEndpoint extends BaseEndpointComponent {
 
       if (format === 'text') {
         res.setHeader('Content-Type', 'text/plain; charset=utf-8');
-        res.send(this.formatAsText(flat, q));
+        res.send(this.formatAsText(flat, q, full));
         return;
       }
 
-      // Default JSON response
+      // Default JSON response. `full=true` swaps the truncated snippet for
+      // a `content` field carrying the full markdown of the page (looked up
+      // by content_id). Default shape is unchanged for backwards compat.
       res.json({
         site_id: identifier,
         query: q,
@@ -194,14 +233,18 @@ export class SearchEndpoint extends BaseEndpointComponent {
         total: flat.length,
         results: flat.map((hit) => {
           const p = hit.payload as any;
-          return {
+          const base = {
             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 || ''),
           };
+          if (full) {
+            const row = p?.content_id ? this.db.getContentById(p.content_id) : null;
+            return { ...base, content: row?.content ?? p?.chunk_text ?? '' };
+          }
+          return { ...base, snippet: this.makeSnippet(p?.chunk_text || '') };
         }),
       });
     } catch (error: any) {
@@ -246,7 +289,7 @@ export class SearchEndpoint extends BaseEndpointComponent {
     return (lastSpace > maxLength * 0.6 ? cut.slice(0, lastSpace) : cut) + '…';
   }
 
-  private formatAsText(hits: Array<{ score: number; payload: any }>, query: string): string {
+  private formatAsText(hits: Array<{ score: number; payload: any }>, query: string, full = false): string {
     if (hits.length === 0) {
       return `No results for: "${query}"`;
     }
@@ -254,7 +297,11 @@ export class SearchEndpoint extends BaseEndpointComponent {
       .map((hit) => {
         const p = hit.payload || {};
         const title = p.title || 'Untitled';
-        const text = p.chunk_text || '';
+        let text = p.chunk_text || '';
+        if (full && p.content_id) {
+          const row = this.db.getContentById(p.content_id);
+          if (row?.content) text = row.content;
+        }
         return `${title}\n${text}`;
       })
       .join('\n\n');