From 6f5787f923deba70226409ef5eb913056c8f9581 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 14 Feb 2026 00:14:10 -0800 Subject: [PATCH] Search answers --- AGENTS.md | 7 -- server/README.md | 4 ++ .../migration.sql | 6 ++ server/prisma/schema.prisma | 6 ++ server/src/routes.ts | 66 +++++++++++++------ web/README.md | 2 +- web/src/App.tsx | 40 +++++++++++ web/src/lib/api.ts | 11 ++++ 8 files changed, 115 insertions(+), 27 deletions(-) create mode 100644 server/prisma/migrations/20260214075925_add_search_answers/migration.sql diff --git a/AGENTS.md b/AGENTS.md index c5a7b13..bd8c5c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,2 @@ # AGENTS.md -## Server Change Verification - -- Whenever you modify files under `/Users/buzzert/src/sybil-2/server`, you must verify startup locally before finalizing. -- Verification means running `npm run dev` in `/Users/buzzert/src/sybil-2/server` and confirming: - - migrations run (or report already up-to-date), and - - the server reaches a listening state without crashing. -- Include the verification result in your final response. diff --git a/server/README.md b/server/README.md index 9c35ce7..cfed71e 100644 --- a/server/README.md +++ b/server/README.md @@ -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: diff --git a/server/prisma/migrations/20260214075925_add_search_answers/migration.sql b/server/prisma/migrations/20260214075925_add_search_answers/migration.sql new file mode 100644 index 0000000..4061d43 --- /dev/null +++ b/server/prisma/migrations/20260214075925_add_search_answers/migration.sql @@ -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; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 3b13edb..5bba200 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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? diff --git a/server/src/routes.ts b/server/src/routes.ts index 61db21e..495fb4b 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -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" } } }, diff --git a/web/README.md b/web/README.md index 9874742..fcf1390 100644 --- a/web/README.md +++ b/web/README.md @@ -36,7 +36,7 @@ Default dev URL: `http://localhost:5173` - Left panel: mixed list of chat conversations and Exa searches. - Right panel: - Chat mode: transcript + provider/model controls. - - Search mode: Google-style Exa results view. + - Search mode: top AI answer block + Google-style Exa results view. - Composer adapts to the active item: - Chat sends `POST /v1/chat-completions`. - Search sends `POST /v1/searches/:searchId/run`. diff --git a/web/src/App.tsx b/web/src/App.tsx index bd60ce4..3d53c7e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -431,6 +431,10 @@ export default function App() { requestId: null, latencyMs: null, error: null, + answerText: null, + answerRequestId: null, + answerCitations: null, + answerError: null, results: [], }; } @@ -440,6 +444,10 @@ export default function App() { query, error: null, latencyMs: null, + answerText: null, + answerRequestId: null, + answerCitations: null, + answerError: null, results: [], }; }); @@ -718,6 +726,38 @@ export default function App() { ) : null} + {(isSearchRunning || !!selectedSearch?.answerText || !!selectedSearch?.answerError) && ( +
+

Answer

+ {isSearchRunning && !selectedSearch?.answerText ? ( +

Generating answer...

+ ) : null} + {selectedSearch?.answerText ? ( +

{selectedSearch.answerText}

+ ) : null} + {selectedSearch?.answerError ?

{selectedSearch.answerError}

: null} + {!!selectedSearch?.answerCitations?.length && ( +
+ {selectedSearch.answerCitations.slice(0, 6).map((citation, index) => { + const href = citation.url || citation.id || ""; + if (!href) return null; + return ( + + {citation.title?.trim() || formatHost(href)} + + ); + })} +
+ )} +
+ )} + {(isLoadingSelection || isSearchRunning) && !selectedSearch?.results.length ? (

{isSearchRunning ? "Searching Exa..." : "Loading search..."}

) : null} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 3cf86ce..eb712ff 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -54,6 +54,17 @@ export type SearchDetail = { requestId: string | null; latencyMs: number | null; error: string | null; + answerText: string | null; + answerRequestId: string | null; + answerCitations: Array<{ + id?: string; + url?: string; + title?: string | null; + publishedDate?: string | null; + author?: string | null; + text?: string | null; + }> | null; + answerError: string | null; results: SearchResultItem[]; };