search: cache results

This commit is contained in:
2026-05-30 17:57:56 -07:00
parent 5b7ed25522
commit 600bc3befc
8 changed files with 148 additions and 11 deletions

View File

@@ -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 }`

View File

@@ -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");

View File

@@ -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])
}

View File

@@ -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,

View 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;
}

View 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);
});

View File

@@ -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;

View File

@@ -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,
{