search: cache results
This commit is contained in:
@@ -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 }`
|
||||
|
||||
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T extends Record<string, any>>(chat: T) {
|
||||
}
|
||||
|
||||
function serializeSearchLike<T extends Record<string, any>>(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,
|
||||
|
||||
29
server/src/search-cache.ts
Normal file
29
server/src/search-cache.ts
Normal file
@@ -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;
|
||||
}
|
||||
25
server/tests/search-cache.test.ts
Normal file
25
server/tests/search-cache.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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<CreateSearchResponse>("/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<CreateSearchRequest, "reuseByQuery">) {
|
||||
return postSearch({ ...body, reuseByQuery: true });
|
||||
}
|
||||
|
||||
export async function getSearch(searchId: string) {
|
||||
const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}`);
|
||||
return data.search;
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user