Search answers
This commit is contained in:
@@ -55,6 +55,10 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
||||
- `GET /v1/searches/:searchId`
|
||||
- `POST /v1/searches/:searchId/run`
|
||||
|
||||
Search runs now execute both Exa `searchAndContents` and Exa `answer`, storing:
|
||||
- ranked search results (for result cards), and
|
||||
- a top-level answer block + citations.
|
||||
|
||||
When `chatId` is provided to completion endpoints, you can send full conversation context. The server now stores only new non-assistant messages to avoid duplicate history rows.
|
||||
|
||||
`POST /v1/chat-completions` body example:
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Search" ADD COLUMN "answerCitations" JSONB;
|
||||
ALTER TABLE "Search" ADD COLUMN "answerError" TEXT;
|
||||
ALTER TABLE "Search" ADD COLUMN "answerRawResponse" JSONB;
|
||||
ALTER TABLE "Search" ADD COLUMN "answerRequestId" TEXT;
|
||||
ALTER TABLE "Search" ADD COLUMN "answerText" TEXT;
|
||||
@@ -113,6 +113,12 @@ model Search {
|
||||
latencyMs Int?
|
||||
error String?
|
||||
|
||||
answerText String?
|
||||
answerRequestId String?
|
||||
answerCitations Json?
|
||||
answerRawResponse Json?
|
||||
answerError String?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
|
||||
|
||||
@@ -141,25 +141,38 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const startedAt = performance.now();
|
||||
|
||||
try {
|
||||
const response = await exaClient().searchAndContents(query, {
|
||||
type: body.type ?? "auto",
|
||||
numResults: body.numResults ?? 10,
|
||||
includeDomains: body.includeDomains,
|
||||
excludeDomains: body.excludeDomains,
|
||||
text: { maxCharacters: 1200 },
|
||||
highlights: {
|
||||
query,
|
||||
maxCharacters: 320,
|
||||
numSentences: 2,
|
||||
highlightsPerUrl: 2,
|
||||
},
|
||||
moderation: true,
|
||||
userLocation: "US",
|
||||
} as any);
|
||||
const exa = exaClient();
|
||||
const [searchOutcome, answerOutcome] = await Promise.allSettled([
|
||||
exa.searchAndContents(query, {
|
||||
type: body.type ?? "auto",
|
||||
numResults: body.numResults ?? 10,
|
||||
includeDomains: body.includeDomains,
|
||||
excludeDomains: body.excludeDomains,
|
||||
text: { maxCharacters: 1200 },
|
||||
highlights: {
|
||||
query,
|
||||
maxCharacters: 320,
|
||||
numSentences: 2,
|
||||
highlightsPerUrl: 2,
|
||||
},
|
||||
moderation: true,
|
||||
userLocation: "US",
|
||||
} as any),
|
||||
exa.answer(query, {
|
||||
text: true,
|
||||
model: "exa",
|
||||
userLocation: "US",
|
||||
}),
|
||||
]);
|
||||
|
||||
const searchResponse = searchOutcome.status === "fulfilled" ? searchOutcome.value : null;
|
||||
const answerResponse = answerOutcome.status === "fulfilled" ? answerOutcome.value : null;
|
||||
const searchError = searchOutcome.status === "rejected" ? searchOutcome.reason?.message ?? String(searchOutcome.reason) : null;
|
||||
const answerError = answerOutcome.status === "rejected" ? answerOutcome.reason?.message ?? String(answerOutcome.reason) : null;
|
||||
|
||||
const latencyMs = Math.round(performance.now() - startedAt);
|
||||
const normalizedTitle = body.title?.trim() || query.slice(0, 80);
|
||||
const rows = (response.results ?? []).map((result: any, index: number) => ({
|
||||
const rows = (searchResponse?.results ?? []).map((result: any, index: number) => ({
|
||||
searchId,
|
||||
rank: index,
|
||||
title: result.title ?? null,
|
||||
@@ -173,6 +186,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
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;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.search.update({
|
||||
@@ -180,10 +199,15 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
data: {
|
||||
query,
|
||||
title: normalizedTitle,
|
||||
requestId: response.requestId ?? null,
|
||||
rawResponse: response as any,
|
||||
requestId: searchResponse?.requestId ?? null,
|
||||
rawResponse: searchResponse as any,
|
||||
latencyMs,
|
||||
error: null,
|
||||
error: searchError,
|
||||
answerText,
|
||||
answerRequestId: answerResponse?.requestId ?? null,
|
||||
answerCitations: (answerResponse?.citations as any) ?? null,
|
||||
answerRawResponse: answerResponse as any,
|
||||
answerError,
|
||||
},
|
||||
});
|
||||
await tx.searchResult.deleteMany({ where: { searchId } });
|
||||
@@ -192,6 +216,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
if (searchError && answerError) {
|
||||
throw app.httpErrors.badGateway(`Exa search and answer failed: ${searchError}; ${answerError}`);
|
||||
}
|
||||
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
include: { results: { orderBy: { rank: "asc" } } },
|
||||
|
||||
Reference in New Issue
Block a user