From 7318e4f98bcc798a986307df686f754a34fb49a1 Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Mon, 19 Oct 2026 09:50:36 +0530 Subject: [PATCH] feat(bazaar): add GET /discovery/search with relevance ranking and quality metrics (#129) --- .changeset/discovery-search-129.md | 5 + openapi.json | 48 +++++++++ openapi.yaml | 31 ++++++ src/__tests__/discoverySearch.test.ts | 48 +++++++++ src/routes/discovery.ts | 149 +++++++++++++++++++++++++- 5 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 .changeset/discovery-search-129.md create mode 100644 src/__tests__/discoverySearch.test.ts diff --git a/.changeset/discovery-search-129.md b/.changeset/discovery-search-129.md new file mode 100644 index 00000000..f0d652d9 --- /dev/null +++ b/.changeset/discovery-search-129.md @@ -0,0 +1,5 @@ +--- +'lens': minor +--- + +Add GET /discovery/search with relevance ranking and quality evaluation (#129) diff --git a/openapi.json b/openapi.json index 94cb0a74..9921752a 100644 --- a/openapi.json +++ b/openapi.json @@ -1714,6 +1714,54 @@ } } }, + "/discovery/search": { + "get": { + "operationId": "searchDiscoveryResources", + "summary": "Natural language search with ranking and quality evaluation for discoverable resources", + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Search results with relevance ranking and metadata quality", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, "/webhooks": { "post": { "operationId": "createWebhook", diff --git a/openapi.yaml b/openapi.yaml index eb49b630..587b452b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1106,6 +1106,37 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' + /discovery/search: + get: + operationId: searchDiscoveryResources + summary: Natural language search with ranking and quality evaluation for discoverable resources + parameters: + - name: q + in: query + required: true + schema: + type: string + - name: limit + in: query + required: false + schema: + type: integer + default: 20 + - name: cursor + in: query + required: false + schema: + type: string + responses: + '200': + description: Search results with relevance ranking and metadata quality + content: + application/json: + schema: + type: object + '429': + $ref: '#/components/responses/TooManyRequests' + /webhooks: post: operationId: createWebhook diff --git a/src/__tests__/discoverySearch.test.ts b/src/__tests__/discoverySearch.test.ts new file mode 100644 index 00000000..19496c98 --- /dev/null +++ b/src/__tests__/discoverySearch.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest' +import { + calculateQualityScore, + calculateRelevanceScore, +} from '../routes/discovery' + +vi.mock('../db', () => ({ + prisma: { + bazaarResource: { findMany: vi.fn() }, + }, + pgPool: { query: vi.fn() }, +})) + +describe('Bazaar Discovery Search & Ranking (#129)', () => { + it('calculates quality score based on metadata completeness', () => { + const poor = { serviceName: 'a' } + const rich = { + serviceName: 'Lens Stellar Oracle', + description: 'Real-time multi-venue price discovery for Stellar and Soroban assets.', + tags: ['oracle', 'defi', 'stellar'], + iconUrl: 'https://lens.stellar/icon.png', + bazaarSchema: { input: {} }, + } + + expect(calculateQualityScore(poor)).toBeLessThan(0.5) + expect(calculateQualityScore(rich)).toBeGreaterThanOrEqual(0.9) + }) + + it('calculates relevance score matching query terms in serviceName, tags, and description', () => { + const resource = { + serviceName: 'Phoenix DEX Swaps', + description: 'Automated market maker protocol on Soroban.', + tags: ['dex', 'amm', 'phoenix'], + url: 'https://phoenix.fi', + } + + const matchName = calculateRelevanceScore('Phoenix', resource) + expect(matchName.score).toBeGreaterThan(0) + expect(matchName.reasons.some(r => r.includes('service_name_match'))).toBe(true) + + const matchTag = calculateRelevanceScore('amm', resource) + expect(matchTag.score).toBeGreaterThan(0) + expect(matchTag.reasons.some(r => r.includes('tag_match'))).toBe(true) + + const noMatch = calculateRelevanceScore('bitcoin lightning', resource) + expect(noMatch.score).toBe(0) + }) +}) diff --git a/src/routes/discovery.ts b/src/routes/discovery.ts index 6b91b7e9..1f0a7979 100644 --- a/src/routes/discovery.ts +++ b/src/routes/discovery.ts @@ -1,16 +1,155 @@ import type { FastifyInstance } from 'fastify' import { parseDiscoveryFilters, queryDiscoveryResources } from '../bazaar/catalog' +import { prisma } from '../db' +import { activeNetwork } from '../config' + +export interface RankedSearchResult { + id: string + type: string + network: string + url: string + serviceName: string | null + description: string | null + mimeType: string | null + tags: string[] + iconUrl: string | null + payTo: string + score: number + relevanceScore: number + qualityScore: number + matchReasons: string[] +} + +/** + * Calculates quality score based on metadata completeness (0.0 to 1.0). + */ +export function calculateQualityScore(res: any): number { + let score = 0.2 // baseline for existing + if (res.serviceName && res.serviceName.length > 2) score += 0.2 + if (res.description && res.description.length > 10) score += 0.25 + if (res.tags && res.tags.length > 0) score += 0.15 + if (res.iconUrl) score += 0.1 + if (res.bazaarSchema) score += 0.1 + return Math.min(1.0, score) +} + +/** + * Calculates text relevance score against query terms. + */ +export function calculateRelevanceScore(query: string, res: any): { score: number; reasons: string[] } { + const terms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0) + if (terms.length === 0) return { score: 1.0, reasons: ['empty_query_match'] } + + let score = 0 + const reasons: string[] = [] + const serviceName = (res.serviceName ?? '').toLowerCase() + const desc = (res.description ?? '').toLowerCase() + const url = (res.url ?? '').toLowerCase() + const tags = (Array.isArray(res.tags) ? res.tags : []).map((t: string) => t.toLowerCase()) + + for (const term of terms) { + if (serviceName.includes(term)) { + score += 0.4 + reasons.push(`service_name_match: "${term}"`) + } + if (tags.some((t: string) => t.includes(term))) { + score += 0.3 + reasons.push(`tag_match: "${term}"`) + } + if (desc.includes(term)) { + score += 0.2 + reasons.push(`description_match: "${term}"`) + } + if (url.includes(term)) { + score += 0.1 + reasons.push(`url_match: "${term}"`) + } + } + + return { score: Math.min(1.0, score), reasons } +} /** - * Registers GET /discovery/resources — the Bazaar catalog-browsing endpoint - * from the x402 `bazaar` extension (specs/extensions/bazaar.md in - * x402-foundation/x402). Public and un-gated: discovery has to work before a - * client has any payment method configured, the same reasoning that keeps - * GET /supported un-gated (see routes/facilitator.ts). + * Registers discovery routes for Bazaar: + * - GET /discovery/resources — browsing + * - GET /discovery/search — natural language search with ranking & quality evaluation (#129) */ export async function registerDiscoveryRoutes(app: FastifyInstance) { app.get('/discovery/resources', { config: { public: true } }, async (req) => { const filters = parseDiscoveryFilters(req.query as Record) return queryDiscoveryResources(filters) }) + + app.get<{ + Querystring: { q?: string; cursor?: string; limit?: string; type?: string; network?: string } + }>('/discovery/search', { config: { public: true } }, async (req, reply) => { + const query = req.query.q?.trim() ?? '' + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit ?? '20', 10))) + const networkFilter = req.query.network ?? activeNetwork + const typeFilter = req.query.type + + let resources: any[] = [] + try { + resources = await prisma.bazaarResource.findMany({ + where: { + network: networkFilter, + ...(typeFilter ? { type: typeFilter } : {}), + }, + take: 200, // Search sample space + }) + } catch { + // Return empty in mock/in-memory environments without Postgres + resources = [] + } + + const scored: RankedSearchResult[] = [] + + for (const res of resources) { + const { score: relevanceScore, reasons } = calculateRelevanceScore(query, res) + if (query.length > 0 && relevanceScore === 0) continue + + const qualityScore = calculateQualityScore(res) + // Weighted ranking: 70% relevance, 30% quality + const compositeScore = parseFloat((relevanceScore * 0.7 + qualityScore * 0.3).toFixed(4)) + + scored.push({ + id: res.id, + type: res.type, + network: res.network, + url: res.url, + serviceName: res.serviceName, + description: res.description, + mimeType: res.mimeType, + tags: res.tags, + iconUrl: res.iconUrl, + payTo: res.payTo, + score: compositeScore, + relevanceScore, + qualityScore, + matchReasons: reasons, + }) + } + + // Rank highest score first + scored.sort((a, b) => b.score - a.score) + + // Cursor pagination + let startIndex = 0 + if (req.query.cursor) { + const foundIdx = scored.findIndex(s => s.id === req.query.cursor) + if (foundIdx >= 0) startIndex = foundIdx + 1 + } + + const items = scored.slice(startIndex, startIndex + limit) + const nextCursor = items.length === limit ? items[items.length - 1].id : null + + return { + query, + network: networkFilter, + totalMatched: scored.length, + limit, + nextCursor, + results: items, + } + }) }