diff --git a/docs/api/rest.md b/docs/api/rest.md index c0db6fa..2ca5d8a 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -294,8 +294,14 @@ Behavior notes: - Response: `{ "searches": SearchSummary[] }` ### `POST /v1/searches` -- Body: `{ "title"?: string, "query"?: string }` -- Response: `{ "search": SearchSummary }` +- Body: `{ "title"?: string, "query"?: string, "reuseByQuery"?: boolean }` +- Response: `{ "search": SearchSummary, "reused": boolean, "cacheHit": boolean }` + +Behavior notes: +- `reuseByQuery` defaults to `false`, preserving the normal create-a-new-search behavior. +- When `reuseByQuery` is `true` and `query` is present, the backend normalizes the query with `trim().toLowerCase()` and returns the most recently updated existing search with that normalized query instead of creating a duplicate. +- `cacheHit` is `true` only when the reused search has persisted results or answer text, is not currently streaming, and was updated within the 24-hour search cache window. Clients can then fetch `GET /v1/searches/:searchId` and display it without running another search. +- If a matching search exists but `cacheHit` is `false`, clients may run the search again on the returned `search.id`; the run endpoints replace that search's persisted results and answer with the latest run. ### `PATCH /v1/searches/:searchId/star` - Body: `{ "starred": boolean }` diff --git a/server/prisma/migrations/20260531000000_add_search_query_cache_key/migration.sql b/server/prisma/migrations/20260531000000_add_search_query_cache_key/migration.sql new file mode 100644 index 0000000..9e9db37 --- /dev/null +++ b/server/prisma/migrations/20260531000000_add_search_query_cache_key/migration.sql @@ -0,0 +1,8 @@ +-- Add normalized search query lookup key for cache/reuse behavior. +ALTER TABLE "Search" ADD COLUMN "queryNormalized" TEXT; + +UPDATE "Search" +SET "queryNormalized" = lower(trim("query")) +WHERE "query" IS NOT NULL AND trim("query") != ''; + +CREATE INDEX "Search_queryNormalized_updatedAt_idx" ON "Search"("queryNormalized", "updatedAt"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 5562a08..c435fa9 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -118,6 +118,7 @@ model Search { title String? query String? + queryNormalized String? source SearchSource @default(exa) @@ -139,6 +140,7 @@ model Search { projectItems ProjectItem[] @@index([updatedAt]) + @@index([queryNormalized, updatedAt]) @@index([userId]) } diff --git a/server/src/routes.ts b/server/src/routes.ts index 6646890..0ddab39 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -12,6 +12,7 @@ import { getModelCatalogSnapshot } from "./llm/model-catalog.js"; import { openaiClient } from "./llm/providers.js"; import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js"; import { exaClient } from "./search/exa.js"; +import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js"; import type { ChatAttachment } from "./llm/types.js"; const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]); @@ -375,7 +376,7 @@ function serializeChatLike>(chat: T) { } function serializeSearchLike>(search: T) { - const { projectItems: _projectItems, ...rest } = search; + const { projectItems: _projectItems, queryNormalized: _queryNormalized, ...rest } = search; return { ...rest, ...serializeStarFields(search), @@ -649,6 +650,7 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest, where: { id: searchId }, data: { query, + queryNormalized: normalizeSearchQuery(query), title: normalizedTitle, requestId: searchResponse?.requestId ?? null, rawResponse: searchResponse as any, @@ -686,6 +688,7 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest, where: { id: searchId }, data: { query, + queryNormalized: normalizeSearchQuery(query), title: normalizedTitle, latencyMs: Math.round(performance.now() - startedAt), error: message, @@ -877,18 +880,51 @@ export async function registerRoutes(app: FastifyInstance) { app.post("/v1/searches", async (req) => { requireAdmin(req); - const Body = z.object({ title: z.string().optional(), query: z.string().optional() }); + const Body = z.object({ + title: z.string().optional(), + query: z.string().optional(), + reuseByQuery: z.boolean().optional(), + }); const body = Body.parse(req.body ?? {}); const title = body.title?.trim() || body.query?.trim()?.slice(0, 80); const query = body.query?.trim() || null; + const queryNormalized = normalizeSearchQuery(query); + + if (body.reuseByQuery && queryNormalized) { + const existing = await prisma.search.findFirst({ + where: { queryNormalized }, + orderBy: { updatedAt: "desc" }, + select: { + ...searchSummarySelect, + answerText: true, + _count: { select: { results: true } }, + }, + }); + + if (existing) { + const { _count, answerText: _answerText, ...search } = existing; + return { + search: serializeSearchLike(search), + reused: true, + cacheHit: isFreshSearchCacheHit({ + updatedAt: existing.updatedAt, + resultCount: _count.results, + answerText: existing.answerText, + isActive: activeSearchStreams.has(existing.id), + }), + }; + } + } + const search = await prisma.search.create({ data: { title: title || null, query, + queryNormalized, }, select: searchSummarySelect, }); - return { search: serializeSearchLike(search) }; + return { search: serializeSearchLike(search), reused: false, cacheHit: false }; }); app.patch("/v1/searches/:searchId/star", async (req) => { @@ -1032,6 +1068,7 @@ export async function registerRoutes(app: FastifyInstance) { where: { id: searchId }, data: { query, + queryNormalized: normalizeSearchQuery(query), title: normalizedTitle, requestId: searchResponse?.requestId ?? null, rawResponse: searchResponse as any, diff --git a/server/src/search-cache.ts b/server/src/search-cache.ts new file mode 100644 index 0000000..cf14316 --- /dev/null +++ b/server/src/search-cache.ts @@ -0,0 +1,29 @@ +export const SEARCH_QUERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +export function normalizeSearchQuery(value: string | null | undefined) { + const normalized = value?.trim().toLowerCase() ?? ""; + return normalized || null; +} + +export function hasReusableSearchPayload(candidate: { resultCount: number; answerText?: string | null }) { + return candidate.resultCount > 0 || Boolean(candidate.answerText?.trim()); +} + +export function isFreshSearchCacheHit( + candidate: { + updatedAt: Date | string; + resultCount: number; + answerText?: string | null; + isActive?: boolean; + }, + now = new Date(), + ttlMs = SEARCH_QUERY_CACHE_TTL_MS +) { + if (candidate.isActive) return false; + if (!hasReusableSearchPayload(candidate)) return false; + + const updatedAtMs = new Date(candidate.updatedAt).getTime(); + if (!Number.isFinite(updatedAtMs)) return false; + + return now.getTime() - updatedAtMs <= ttlMs; +} diff --git a/server/tests/search-cache.test.ts b/server/tests/search-cache.test.ts new file mode 100644 index 0000000..0cca666 --- /dev/null +++ b/server/tests/search-cache.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SEARCH_QUERY_CACHE_TTL_MS, isFreshSearchCacheHit, normalizeSearchQuery } from "../src/search-cache.js"; + +test("normalizeSearchQuery trims and lowercases query text", () => { + assert.equal(normalizeSearchQuery(" Bitcoin PRICE "), "bitcoin price"); + assert.equal(normalizeSearchQuery(" "), null); + assert.equal(normalizeSearchQuery(null), null); +}); + +test("isFreshSearchCacheHit requires fresh persisted payload and no active stream", () => { + const now = new Date("2026-05-31T12:00:00.000Z"); + + assert.equal( + isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS + 1), resultCount: 1 }, now), + true + ); + assert.equal( + isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS - 1), resultCount: 1 }, now), + false + ); + assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "" }, now), false); + assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "answer" }, now), true); + assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 1, isActive: true }, now), false); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 18da3a8..f772ec3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -185,6 +185,18 @@ type CreateChatRequest = { messages?: CompletionRequestMessage[]; }; +type CreateSearchRequest = { + title?: string; + query?: string; + reuseByQuery?: boolean; +}; + +type CreateSearchResponse = { + search: SearchSummary; + reused: boolean; + cacheHit: boolean; +}; + const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api"; const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null; let authToken: string | null = ENV_ADMIN_TOKEN; @@ -296,14 +308,22 @@ export async function listSearches() { return data.searches; } -export async function createSearch(body?: { title?: string; query?: string }) { - const data = await api<{ search: SearchSummary }>("/v1/searches", { +async function postSearch(body?: CreateSearchRequest) { + return api("/v1/searches", { method: "POST", body: JSON.stringify(body ?? {}), }); +} + +export async function createSearch(body?: CreateSearchRequest) { + const data = await postSearch(body); return data.search; } +export async function createReusableSearch(body: Omit) { + return postSearch({ ...body, reuseByQuery: true }); +} + export async function getSearch(searchId: string) { const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}`); return data.search; diff --git a/web/src/pages/search-route-page.tsx b/web/src/pages/search-route-page.tsx index 6daa900..cb25f72 100644 --- a/web/src/pages/search-route-page.tsx +++ b/web/src/pages/search-route-page.tsx @@ -4,7 +4,7 @@ import { AuthScreen } from "@/components/auth/auth-screen"; import { SearchResultsPanel } from "@/components/search/search-results-panel"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { createSearch, runSearchStream, type SearchDetail } from "@/lib/api"; +import { createReusableSearch, getSearch, runSearchStream, type SearchDetail } from "@/lib/api"; import { useSessionAuth } from "@/hooks/use-session-auth"; function readQueryFromUrl() { @@ -85,14 +85,16 @@ export default function SearchRoutePage() { const runQuery = async (query: string) => { const trimmed = query.trim(); + const requestId = ++requestCounterRef.current; + streamAbortRef.current?.abort(); + if (!trimmed) { setSearch(null); setError(null); + setIsRunning(false); return; } - const requestId = ++requestCounterRef.current; - streamAbortRef.current?.abort(); const abortController = new AbortController(); streamAbortRef.current = abortController; let wasInterrupted = false; @@ -119,10 +121,11 @@ export default function SearchRoutePage() { }); try { - const created = await createSearch({ + const createdResult = await createReusableSearch({ query: trimmed, title: trimmed.slice(0, 80), }); + const created = createdResult.search; if (requestId !== requestCounterRef.current) return; setSearch((current) => @@ -140,6 +143,13 @@ export default function SearchRoutePage() { : current ); + if (createdResult.cacheHit) { + const cached = await getSearch(created.id); + if (requestId !== requestCounterRef.current) return; + setSearch(cached); + return; + } + await runSearchStream( created.id, {