Search: async answer/results
This commit is contained in:
@@ -49,6 +49,84 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
|
||||
});
|
||||
}
|
||||
|
||||
const SearchRunBody = z.object({
|
||||
query: z.string().trim().min(1).optional(),
|
||||
title: z.string().trim().min(1).optional(),
|
||||
type: z.enum(["auto", "fast", "deep", "instant"]).optional(),
|
||||
numResults: z.number().int().min(1).max(25).optional(),
|
||||
includeDomains: z.array(z.string().trim().min(1)).max(50).optional(),
|
||||
excludeDomains: z.array(z.string().trim().min(1)).max(50).optional(),
|
||||
});
|
||||
|
||||
function mapSearchResultRow(searchId: string, result: any, index: number) {
|
||||
return {
|
||||
searchId,
|
||||
rank: index,
|
||||
title: result.title ?? null,
|
||||
url: result.url,
|
||||
publishedDate: result.publishedDate ?? null,
|
||||
author: result.author ?? null,
|
||||
text: result.text ?? null,
|
||||
highlights: Array.isArray(result.highlights) ? (result.highlights as any) : null,
|
||||
highlightScores: Array.isArray(result.highlightScores) ? (result.highlightScores as any) : null,
|
||||
score: typeof result.score === "number" ? result.score : null,
|
||||
favicon: result.favicon ?? null,
|
||||
image: result.image ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSearchResultPreview(result: any, index: number) {
|
||||
return {
|
||||
id: `preview-result-${index}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
rank: index,
|
||||
title: result.title ?? null,
|
||||
url: result.url,
|
||||
publishedDate: result.publishedDate ?? null,
|
||||
author: result.author ?? null,
|
||||
text: result.text ?? null,
|
||||
highlights: Array.isArray(result.highlights) ? (result.highlights as any) : null,
|
||||
highlightScores: Array.isArray(result.highlightScores) ? (result.highlightScores as any) : null,
|
||||
score: typeof result.score === "number" ? result.score : null,
|
||||
favicon: result.favicon ?? null,
|
||||
image: result.image ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAnswerText(answerResponse: any) {
|
||||
if (typeof answerResponse?.answer === "string") return answerResponse.answer;
|
||||
if (answerResponse?.answer) return JSON.stringify(answerResponse.answer, null, 2);
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeUrlForMatch(input: string | null | undefined) {
|
||||
if (!input) return "";
|
||||
try {
|
||||
const parsed = new URL(input);
|
||||
parsed.hash = "";
|
||||
const normalized = parsed.toString();
|
||||
return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
||||
} catch {
|
||||
return input.trim().replace(/\/$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
function buildSseHeaders(originHeader: string | undefined) {
|
||||
const origin = originHeader && originHeader !== "null" ? originHeader : "*";
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Access-Control-Allow-Origin": origin,
|
||||
Vary: "Origin",
|
||||
};
|
||||
if (origin !== "*") {
|
||||
headers["Access-Control-Allow-Credentials"] = "true";
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function registerRoutes(app: FastifyInstance) {
|
||||
app.get("/health", { logLevel: "silent" }, async () => ({ ok: true }));
|
||||
|
||||
@@ -150,17 +228,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
app.post("/v1/searches/:searchId/run", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ searchId: z.string() });
|
||||
const Body = z.object({
|
||||
query: z.string().trim().min(1).optional(),
|
||||
title: z.string().trim().min(1).optional(),
|
||||
type: z.enum(["auto", "fast", "deep", "instant"]).optional(),
|
||||
numResults: z.number().int().min(1).max(25).optional(),
|
||||
includeDomains: z.array(z.string().trim().min(1)).max(50).optional(),
|
||||
excludeDomains: z.array(z.string().trim().min(1)).max(50).optional(),
|
||||
});
|
||||
|
||||
const { searchId } = Params.parse(req.params);
|
||||
const body = Body.parse(req.body ?? {});
|
||||
const body = SearchRunBody.parse(req.body ?? {});
|
||||
|
||||
const existing = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
@@ -205,26 +275,8 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
const latencyMs = Math.round(performance.now() - startedAt);
|
||||
const normalizedTitle = body.title?.trim() || query.slice(0, 80);
|
||||
const rows = (searchResponse?.results ?? []).map((result: any, index: number) => ({
|
||||
searchId,
|
||||
rank: index,
|
||||
title: result.title ?? null,
|
||||
url: result.url,
|
||||
publishedDate: result.publishedDate ?? null,
|
||||
author: result.author ?? null,
|
||||
text: result.text ?? null,
|
||||
highlights: Array.isArray(result.highlights) ? (result.highlights as any) : null,
|
||||
highlightScores: Array.isArray(result.highlightScores) ? (result.highlightScores as any) : null,
|
||||
score: typeof result.score === "number" ? result.score : null,
|
||||
favicon: result.favicon ?? null,
|
||||
image: result.image ?? null,
|
||||
}));
|
||||
const answerText =
|
||||
typeof answerResponse?.answer === "string"
|
||||
? answerResponse.answer
|
||||
: answerResponse?.answer
|
||||
? JSON.stringify(answerResponse.answer, null, 2)
|
||||
: null;
|
||||
const rows = (searchResponse?.results ?? []).map((result: any, index: number) => mapSearchResultRow(searchId, result, index));
|
||||
const answerText = parseAnswerText(answerResponse);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.search.update({
|
||||
@@ -271,6 +323,179 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/v1/searches/:searchId/run/stream", async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ searchId: z.string() });
|
||||
const { searchId } = Params.parse(req.params);
|
||||
const body = SearchRunBody.parse(req.body ?? {});
|
||||
|
||||
const existing = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
select: { id: true, query: true },
|
||||
});
|
||||
if (!existing) return app.httpErrors.notFound("search not found");
|
||||
|
||||
const query = body.query?.trim() || existing.query?.trim();
|
||||
if (!query) return app.httpErrors.badRequest("query is required");
|
||||
|
||||
const startedAt = performance.now();
|
||||
const normalizedTitle = body.title?.trim() || query.slice(0, 80);
|
||||
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
|
||||
const send = (event: string, data: any) => {
|
||||
if (reply.raw.writableEnded) return;
|
||||
reply.raw.write(`event: ${event}\n`);
|
||||
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
try {
|
||||
const exa = exaClient();
|
||||
const searchPromise = exa.search(query, {
|
||||
type: body.type ?? "auto",
|
||||
numResults: body.numResults ?? 10,
|
||||
includeDomains: body.includeDomains,
|
||||
excludeDomains: body.excludeDomains,
|
||||
moderation: true,
|
||||
userLocation: "US",
|
||||
contents: false,
|
||||
} as any);
|
||||
const answerPromise = exa.answer(query, {
|
||||
text: true,
|
||||
model: "exa",
|
||||
userLocation: "US",
|
||||
});
|
||||
|
||||
let searchResponse: any | null = null;
|
||||
let answerResponse: any | null = null;
|
||||
let enrichedResults: any[] | null = null;
|
||||
let searchError: string | null = null;
|
||||
let answerError: string | null = null;
|
||||
|
||||
const searchSettled = searchPromise.then(
|
||||
async (value) => {
|
||||
searchResponse = value;
|
||||
const previewResults = (value?.results ?? []).map((result: any, index: number) => mapSearchResultPreview(result, index));
|
||||
send("search_results", {
|
||||
requestId: value?.requestId ?? null,
|
||||
results: previewResults,
|
||||
});
|
||||
|
||||
const urls = (value?.results ?? []).map((result: any) => result?.url).filter((url: string | undefined) => typeof url === "string");
|
||||
if (!urls.length) return;
|
||||
|
||||
try {
|
||||
const contentsResponse = await exa.getContents(urls, {
|
||||
text: { maxCharacters: 1200 },
|
||||
highlights: {
|
||||
query,
|
||||
maxCharacters: 320,
|
||||
numSentences: 2,
|
||||
highlightsPerUrl: 2,
|
||||
},
|
||||
} as any);
|
||||
const byUrl = new Map<string, any>();
|
||||
for (const contentItem of contentsResponse?.results ?? []) {
|
||||
byUrl.set(normalizeUrlForMatch(contentItem?.url), contentItem);
|
||||
}
|
||||
|
||||
enrichedResults = (value?.results ?? []).map((result: any) => {
|
||||
const contentItem = byUrl.get(normalizeUrlForMatch(result?.url));
|
||||
if (!contentItem) return result;
|
||||
return {
|
||||
...result,
|
||||
text: contentItem.text ?? result.text ?? null,
|
||||
highlights: Array.isArray(contentItem.highlights) ? contentItem.highlights : result.highlights ?? null,
|
||||
highlightScores: Array.isArray(contentItem.highlightScores) ? contentItem.highlightScores : result.highlightScores ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
send("search_results", {
|
||||
requestId: value?.requestId ?? null,
|
||||
results: enrichedResults.map((result: any, index: number) => mapSearchResultPreview(result, index)),
|
||||
});
|
||||
} catch {
|
||||
// keep preview results if content enrichment fails
|
||||
}
|
||||
},
|
||||
(reason) => {
|
||||
searchError = reason?.message ?? String(reason);
|
||||
send("search_error", { error: searchError });
|
||||
}
|
||||
);
|
||||
|
||||
const answerSettled = answerPromise.then(
|
||||
(value) => {
|
||||
answerResponse = value;
|
||||
send("answer", {
|
||||
answerText: parseAnswerText(value),
|
||||
answerRequestId: value?.requestId ?? null,
|
||||
answerCitations: (value?.citations as any) ?? null,
|
||||
});
|
||||
},
|
||||
(reason) => {
|
||||
answerError = reason?.message ?? String(reason);
|
||||
send("answer_error", { error: answerError });
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all([searchSettled, answerSettled]);
|
||||
|
||||
const latencyMs = Math.round(performance.now() - startedAt);
|
||||
const persistedResults = enrichedResults ?? searchResponse?.results ?? [];
|
||||
const rows = persistedResults.map((result: any, index: number) => mapSearchResultRow(searchId, result, index));
|
||||
const answerText = parseAnswerText(answerResponse);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.search.update({
|
||||
where: { id: searchId },
|
||||
data: {
|
||||
query,
|
||||
title: normalizedTitle,
|
||||
requestId: searchResponse?.requestId ?? null,
|
||||
rawResponse: searchResponse as any,
|
||||
latencyMs,
|
||||
error: searchError,
|
||||
answerText,
|
||||
answerRequestId: answerResponse?.requestId ?? null,
|
||||
answerCitations: (answerResponse?.citations as any) ?? null,
|
||||
answerRawResponse: answerResponse as any,
|
||||
answerError,
|
||||
},
|
||||
});
|
||||
await tx.searchResult.deleteMany({ where: { searchId } });
|
||||
if (rows.length) {
|
||||
await tx.searchResult.createMany({ data: rows as any });
|
||||
}
|
||||
});
|
||||
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
include: { results: { orderBy: { rank: "asc" } } },
|
||||
});
|
||||
if (!search) {
|
||||
send("error", { message: "search not found" });
|
||||
} else {
|
||||
send("done", { search });
|
||||
}
|
||||
} catch (err: any) {
|
||||
await prisma.search.update({
|
||||
where: { id: searchId },
|
||||
data: {
|
||||
query,
|
||||
title: normalizedTitle,
|
||||
latencyMs: Math.round(performance.now() - startedAt),
|
||||
error: err?.message ?? String(err),
|
||||
},
|
||||
});
|
||||
send("error", { message: err?.message ?? String(err) });
|
||||
} finally {
|
||||
reply.raw.end();
|
||||
}
|
||||
|
||||
return reply;
|
||||
});
|
||||
|
||||
app.get("/v1/chats/:chatId", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
@@ -382,11 +607,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
|
||||
const send = (event: string, data: any) => {
|
||||
reply.raw.write(`event: ${event}\n`);
|
||||
|
||||
Reference in New Issue
Block a user