diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 000000000..fa17b7b9c --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { searchStyleSlugs } from "@/lib/retrieval/style-search-service"; +import { + checkRateLimit, + createRateLimitHeaders, + getRequestClientKey, +} from "@/lib/security/rate-limit"; + +const MAX_QUERY_LENGTH = 200; + +// Rankings change only with a deployment; identical queries can be shared. +const SEARCH_CACHE_CONTROL = + "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"; + +/** + * Hybrid style search: BM25 + vector + RRF over the curated catalog. + * + * Returns ranked slugs only. Callers already hold the catalog (from + * `/api/styles`) and join on slug, so this stays small and cacheable. `mode` + * says whether the vector path answered or the keyword path served alone. + */ +export async function GET(request: NextRequest) { + const rateLimit = checkRateLimit({ + namespace: "style-search", + key: getRequestClientKey(request), + limit: 60, + windowMs: 60 * 1000, + }); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: "Too many requests. Try again later." }, + { status: 429, headers: createRateLimitHeaders(rateLimit) }, + ); + } + + const query = (request.nextUrl.searchParams.get("q") ?? "").trim(); + if (!query) { + return NextResponse.json({ error: "Missing query parameter q." }, { status: 400 }); + } + if (query.length > MAX_QUERY_LENGTH) { + return NextResponse.json( + { error: `Query must be at most ${MAX_QUERY_LENGTH} characters.` }, + { status: 400 }, + ); + } + + try { + const result = await searchStyleSlugs(query); + return NextResponse.json( + { query, total: result.results.length, ...result }, + // A degraded answer is cached briefly so the vector path gets retried soon. + { + headers: { + "Cache-Control": result.mode === "hybrid" ? SEARCH_CACHE_CONTROL : "public, max-age=60", + }, + }, + ); + } catch (error) { + console.error("[api/search] failed:", error); + return NextResponse.json({ error: "Search is temporarily unavailable." }, { status: 503 }); + } +} diff --git a/lib/retrieval/style-search-service.ts b/lib/retrieval/style-search-service.ts new file mode 100644 index 000000000..9dba083b5 --- /dev/null +++ b/lib/retrieval/style-search-service.ts @@ -0,0 +1,134 @@ +/** + * @module retrieval/style-search-service + * + * The production entry point for hybrid style search (BM25 + vector + RRF). + * + * `hybrid-search.ts` is the algorithm; this module is the wiring a server + * process needs around it: one corpus index per process, the vector index read + * from disk, and the embedding provider built from the environment. + * + * Every missing piece degrades rather than fails. No `DASHSCOPE_API_KEY`, no + * `.data/style-vectors.json`, an index built for another model, or a slow + * embedding call all fall back to the keyword path, and the response says + * which one answered. That keeps search available on a host that has not been + * given the key yet. + */ + +import { chunkStyles } from "@/lib/retrieval/chunk-styles"; +import { + DEFAULT_EMBEDDING_DIMENSIONS, + DEFAULT_EMBEDDING_MODEL, + createDashScopeEmbeddingProvider, + type EmbeddingProvider, +} from "@/lib/retrieval/embedding"; +import { + createHybridSearcher, + type DegradeReason, + type HybridSearcher, +} from "@/lib/retrieval/hybrid-search"; +import { JsonFileVectorStore, type VectorStore } from "@/lib/retrieval/vector-store"; +import { styles } from "@/lib/styles/registry"; + +/** A query answered from the network should not wait on a slow embedding. */ +const QUERY_EMBEDDING_TIMEOUT_MS = 1_500; +const QUERY_CACHE_LIMIT = 500; + +export type StyleSearchMode = "hybrid" | "keyword"; + +export interface StyleSearchResult { + mode: StyleSearchMode; + /** Set when the vector path was unavailable for this query. */ + degradeReason?: DegradeReason; + results: Array<{ slug: string; score: number }>; +} + +interface ServiceState { + searcher: HybridSearcher; + /** Why the vector path is off for every query, when it is. */ + staticDegrade?: DegradeReason; +} + +let statePromise: Promise | null = null; +const queryCache = new Map(); + +function createProvider(): EmbeddingProvider | null { + try { + return createDashScopeEmbeddingProvider({ timeoutMs: QUERY_EMBEDDING_TIMEOUT_MS, maxAttempts: 1 }); + } catch { + return null; + } +} + +async function openVectorStore(): Promise { + try { + const store = await JsonFileVectorStore.open({ + dimensions: DEFAULT_EMBEDDING_DIMENSIONS, + model: DEFAULT_EMBEDDING_MODEL, + }); + return store.size() > 0 ? store : null; + } catch { + // A stale index (other model or dimensions) is unusable, not fatal. + return null; + } +} + +async function buildState(): Promise { + const provider = createProvider(); + const vectorStore = await openVectorStore(); + const searcher = createHybridSearcher({ + chunks: chunkStyles(styles), + embeddingProvider: vectorStore ? provider : null, + vectorStore, + topK: styles.length, + vectorTimeoutMs: QUERY_EMBEDDING_TIMEOUT_MS, + }); + const staticDegrade: DegradeReason | undefined = !provider ? "no-provider" : !vectorStore ? "no-store" : undefined; + return { searcher, staticDegrade }; +} + +function getState(): Promise { + if (!statePromise) { + statePromise = buildState().catch((error: unknown) => { + statePromise = null; + throw error; + }); + } + return statePromise; +} + +export async function searchStyleSlugs(query: string): Promise { + const key = query.trim().toLowerCase(); + const cached = queryCache.get(key); + if (cached) return cached; + + const { searcher, staticDegrade } = await getState(); + let degradeReason: DegradeReason | undefined = staticDegrade; + const hits = await searcher.search(key, { + onDegrade: (info) => { + degradeReason ??= info.reason; + }, + }); + + const result: StyleSearchResult = { + mode: degradeReason ? "keyword" : "hybrid", + ...(degradeReason ? { degradeReason } : {}), + results: hits.map((hit) => ({ slug: hit.slug, score: hit.score })), + }; + + // Only full-quality answers are cached, so a transient embedding failure + // does not pin a keyword-only ranking for that query. + if (result.mode === "hybrid" || staticDegrade) { + if (queryCache.size >= QUERY_CACHE_LIMIT) { + const oldest = queryCache.keys().next().value; + if (oldest !== undefined) queryCache.delete(oldest); + } + queryCache.set(key, result); + } + return result; +} + +/** Exposed for tests. */ +export function resetStyleSearchService(): void { + statePromise = null; + queryCache.clear(); +} diff --git a/packages/core/src/discovery/index.ts b/packages/core/src/discovery/index.ts index 0cfa82bf5..85691dae1 100644 --- a/packages/core/src/discovery/index.ts +++ b/packages/core/src/discovery/index.ts @@ -45,4 +45,4 @@ export { clearRemoteCache, } from "./remote"; -export type { DataOrigin, Sourced, RemoteOptions } from "./remote"; +export type { DataOrigin, Sourced, RemoteOptions, SearchRanking } from "./remote"; diff --git a/packages/core/src/discovery/remote.ts b/packages/core/src/discovery/remote.ts index ac47e2e80..a8a4dc53c 100644 --- a/packages/core/src/discovery/remote.ts +++ b/packages/core/src/discovery/remote.ts @@ -13,10 +13,11 @@ * correct by construction; keeping the bundle as a fallback means losing the * network degrades to stale rather than to broken. * - * Ranking stays local. The API supplies which styles exist; the bundled - * scoring decides how they rank. Moving scoring server-side would create two - * implementations of the same logic, and they would drift the way the data - * just did. + * Query ranking prefers the site's hybrid search (`/api/search`: BM25 + + * vector + RRF). Its vector path needs an embedding key and a prebuilt index, + * neither of which can ship inside an npm package, so it has to run + * server-side. The bundled scorer stays as the fallback when that endpoint is + * unreachable, so a query always gets an answer. */ import { @@ -44,8 +45,15 @@ export interface Sourced { readonly origin: DataOrigin; /** Why the live catalogue was not used, when it was not. */ readonly fallbackReason?: string; + /** + * Which ranker ordered a query's results: the site's hybrid search, its + * keyword-only degradation, or the bundled scorer. + */ + readonly ranking?: SearchRanking; } +export type SearchRanking = "hybrid" | "keyword" | "local"; + export interface RemoteOptions { /** Override for testing or self-hosting. */ readonly baseUrl?: string; @@ -111,6 +119,7 @@ async function fetchJsonUncached( cacheKey: string, options: RemoteOptions, generation: number, + tripCircuit: boolean, ): Promise> { const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const controller = new AbortController(); @@ -122,7 +131,7 @@ async function fetchJsonUncached( headers: { accept: "application/json" }, }); if (!response.ok) { - openCircuit(baseUrl, generation); + if (tripCircuit) openCircuit(baseUrl, generation); return { error: `HTTP ${response.status}` }; } @@ -136,7 +145,7 @@ async function fetchJsonUncached( } return { value }; } catch (error) { - openCircuit(baseUrl, generation); + if (tripCircuit) openCircuit(baseUrl, generation); const message = error instanceof Error ? error.message : String(error); return { error: /abort/i.test(message) ? `timed out after ${timeoutMs}ms` : message, @@ -146,7 +155,15 @@ async function fetchJsonUncached( } } -async function fetchJson(path: string, options: RemoteOptions): Promise> { +/** + * `tripCircuit: false` is for optional endpoints: a site that predates one + * answers 404, and that must not take the catalogue offline with it. + */ +async function fetchJson( + path: string, + options: RemoteOptions, + tripCircuit = true, +): Promise> { if (options.live === false) return { error: "live fetching disabled" }; const baseUrl = normalizeBaseUrl(options.baseUrl ?? STYLEKIT_SITE_URL); @@ -163,7 +180,7 @@ async function fetchJson(path: string, options: RemoteOptions): Promise; const generation = cacheGeneration; - const request = fetchJsonUncached(path, baseUrl, cacheKey, options, generation); + const request = fetchJsonUncached(path, baseUrl, cacheKey, options, generation, tripCircuit); inFlight.set(cacheKey, request as Promise>); try { return await request; @@ -340,11 +357,53 @@ export async function searchStylesLive( ): Promise> { const catalogue = await liveCatalogue(options); if ("error" in catalogue) { - return { data: searchWithPool(opts), origin: "bundled", fallbackReason: catalogue.error }; + return { + data: searchWithPool(opts), + origin: "bundled", + fallbackReason: catalogue.error, + ...(opts.query?.trim() ? { ranking: "local" as const } : {}), + }; + } + + const query = opts.query?.trim(); + if (query) { + const ranked = await rankedSlugs(query, options); + if (ranked && ranked.slugs.length > 0) { + const bySlug = new Map(catalogue.styles.map((style) => [style.slug, style])); + const pool = ranked.slugs + .map((slug) => bySlug.get(slug)) + .filter((style): style is DesignStyle => style !== undefined); + // No query here: the pool is already in ranked order, and searchWithPool + // keeps that order while applying the category filter and limit. + return { + data: searchWithPool({ ...opts, query: undefined }, pool), + origin: "live", + ranking: ranked.mode, + }; + } + return { data: searchWithPool(opts, catalogue.styles), origin: "live", ranking: "local" }; } return { data: searchWithPool(opts, catalogue.styles), origin: "live" }; } +async function rankedSlugs( + query: string, + options: RemoteOptions, +): Promise<{ slugs: string[]; mode: "hybrid" | "keyword" } | null> { + const response = await fetchJson( + `/api/search?q=${encodeURIComponent(query)}`, + options, + false, + ); + if ("error" in response || !isRecord(response.value)) return null; + const { mode, results } = response.value; + if ((mode !== "hybrid" && mode !== "keyword") || !Array.isArray(results)) return null; + const slugs = results + .map((result) => (isRecord(result) ? result.slug : undefined)) + .filter((slug): slug is string => typeof slug === "string" && slug.length > 0); + return { slugs, mode }; +} + export async function getStyleDetailLive( slug: string, options: RemoteOptions = {}, diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index ce19e8a14..8e9f32ddf 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -30,6 +30,12 @@ const READ_ONLY = { const CATEGORIES = ["modern", "retro", "minimal", "expressive"] as const; +const RANKING_LABEL = { + hybrid: "hybrid search (BM25 + vector + RRF)", + keyword: "keyword search (vector path unavailable)", + local: "bundled scorer (live search unavailable)", +} as const; + // Shared output shapes (so clients get typed structuredContent). const SUMMARY_SHAPE = { slug: z.string(), @@ -209,6 +215,7 @@ Examples: count: z.number(), offset: z.number(), has_more: z.boolean(), + ranking: z.enum(["hybrid", "keyword", "local"]).optional(), results: z.array(z.object(SUMMARY_SHAPE)), }, annotations: READ_ONLY, @@ -228,7 +235,7 @@ Examples: const hasMore = offset + page.length < total; const lines = [ `# StyleKit styles${query ? ` matching "${query}"` : ""}`, - `Found ${total} (showing ${page.length}${offset ? ` from offset ${offset}` : ""}).`, + `Found ${total} (showing ${page.length}${offset ? ` from offset ${offset}` : ""})${search.ranking ? ` · ranked by ${RANKING_LABEL[search.ranking]}` : ""}.`, "", ...page.map( (r) => @@ -241,6 +248,7 @@ Examples: count: page.length, offset, has_more: hasMore, + ...(search.ranking ? { ranking: search.ranking } : {}), results: page, }); }, diff --git a/tests/unit/packages-core/remote-discovery.test.ts b/tests/unit/packages-core/remote-discovery.test.ts index 8d901decc..b1b0982f8 100644 --- a/tests/unit/packages-core/remote-discovery.test.ts +++ b/tests/unit/packages-core/remote-discovery.test.ts @@ -235,4 +235,54 @@ describe("remote discovery", () => { await searchStylesLive({}, { baseUrl: "https://ttl.test", cacheTtlMs: 100 }); expect(fetchMock).toHaveBeenCalledTimes(3); }); + + it("orders query results by the site's hybrid search and keeps the category filter", async () => { + const fetchMock = vi.fn((url: string) => { + if (url.includes("/api/search")) { + return Promise.resolve( + responseFor(url, { + mode: "hybrid", + results: [ + { slug: "gamma", score: 3 }, + { slug: "retro-one", score: 2 }, + { slug: "alpha", score: 1 }, + { slug: "unknown-slug", score: 0.5 }, + ], + }), + ); + } + return Promise.resolve( + catalogue([style("alpha"), style("gamma"), style("retro-one", "retro")]), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await searchStylesLive( + { query: "玻璃质感", category: "modern" }, + { baseUrl: "https://hybrid.test" }, + ); + expect(result.origin).toBe("live"); + expect(result.ranking).toBe("hybrid"); + expect(result.data.results.map((r) => r.slug)).toEqual(["gamma", "alpha"]); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes("q=%E7%8E%BB%E7%92%83"))).toBe(true); + }); + + it("falls back to the bundled scorer when search is missing, without backing off the catalogue", async () => { + const fetchMock = vi.fn((url: string) => + Promise.resolve( + url.includes("/api/search") + ? responseFor(url, { error: "not found" }, 404) + : catalogue([style("alpha"), style("gamma")]), + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const first = await searchStylesLive({ query: "gamma" }, { baseUrl: "https://old-site.test" }); + expect(first.origin).toBe("live"); + expect(first.ranking).toBe("local"); + expect(first.data.results.map((r) => r.slug)).toEqual(["gamma"]); + + const detail = await getStyleDetailLive("not-bundled-slug", { baseUrl: "https://old-site.test" }); + expect(detail.fallbackReason).not.toBe("live source unreachable, backing off"); + }); }); diff --git a/tests/unit/style-search-service.test.ts b/tests/unit/style-search-service.test.ts new file mode 100644 index 000000000..37dbacffb --- /dev/null +++ b/tests/unit/style-search-service.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { resetStyleSearchService, searchStyleSlugs } from "@/lib/retrieval/style-search-service"; + +afterEach(() => { + resetStyleSearchService(); + vi.unstubAllEnvs(); +}); + +describe("style search service", () => { + it("serves keyword results when no embedding key is configured", async () => { + vi.stubEnv("DASHSCOPE_API_KEY", ""); + + const result = await searchStyleSlugs("glassmorphism"); + expect(result.mode).toBe("keyword"); + expect(result.degradeReason).toBe("no-provider"); + expect(result.results.slice(0, 3).map((hit) => hit.slug)).toContain("glassmorphism"); + }); + + it("finds styles for a colloquial Chinese query", async () => { + vi.stubEnv("DASHSCOPE_API_KEY", ""); + + const result = await searchStyleSlugs("毛玻璃"); + expect(result.results.length).toBeGreaterThan(0); + expect(result.results.slice(0, 5).map((hit) => hit.slug)).toContain("glassmorphism"); + }); +});