Search answers
This commit is contained in:
@@ -1,9 +1,2 @@
|
|||||||
# AGENTS.md
|
# 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.
|
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
|||||||
- `GET /v1/searches/:searchId`
|
- `GET /v1/searches/:searchId`
|
||||||
- `POST /v1/searches/:searchId/run`
|
- `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.
|
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:
|
`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?
|
latencyMs Int?
|
||||||
error String?
|
error String?
|
||||||
|
|
||||||
|
answerText String?
|
||||||
|
answerRequestId String?
|
||||||
|
answerCitations Json?
|
||||||
|
answerRawResponse Json?
|
||||||
|
answerError String?
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId String?
|
userId String?
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await exaClient().searchAndContents(query, {
|
const exa = exaClient();
|
||||||
|
const [searchOutcome, answerOutcome] = await Promise.allSettled([
|
||||||
|
exa.searchAndContents(query, {
|
||||||
type: body.type ?? "auto",
|
type: body.type ?? "auto",
|
||||||
numResults: body.numResults ?? 10,
|
numResults: body.numResults ?? 10,
|
||||||
includeDomains: body.includeDomains,
|
includeDomains: body.includeDomains,
|
||||||
@@ -155,11 +157,22 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
moderation: true,
|
moderation: true,
|
||||||
userLocation: "US",
|
userLocation: "US",
|
||||||
} as any);
|
} 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 latencyMs = Math.round(performance.now() - startedAt);
|
||||||
const normalizedTitle = body.title?.trim() || query.slice(0, 80);
|
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,
|
searchId,
|
||||||
rank: index,
|
rank: index,
|
||||||
title: result.title ?? null,
|
title: result.title ?? null,
|
||||||
@@ -173,6 +186,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
favicon: result.favicon ?? null,
|
favicon: result.favicon ?? null,
|
||||||
image: result.image ?? 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 prisma.$transaction(async (tx) => {
|
||||||
await tx.search.update({
|
await tx.search.update({
|
||||||
@@ -180,10 +199,15 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
data: {
|
data: {
|
||||||
query,
|
query,
|
||||||
title: normalizedTitle,
|
title: normalizedTitle,
|
||||||
requestId: response.requestId ?? null,
|
requestId: searchResponse?.requestId ?? null,
|
||||||
rawResponse: response as any,
|
rawResponse: searchResponse as any,
|
||||||
latencyMs,
|
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 } });
|
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({
|
const search = await prisma.search.findUnique({
|
||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
include: { results: { orderBy: { rank: "asc" } } },
|
include: { results: { orderBy: { rank: "asc" } } },
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Default dev URL: `http://localhost:5173`
|
|||||||
- Left panel: mixed list of chat conversations and Exa searches.
|
- Left panel: mixed list of chat conversations and Exa searches.
|
||||||
- Right panel:
|
- Right panel:
|
||||||
- Chat mode: transcript + provider/model controls.
|
- 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:
|
- Composer adapts to the active item:
|
||||||
- Chat sends `POST /v1/chat-completions`.
|
- Chat sends `POST /v1/chat-completions`.
|
||||||
- Search sends `POST /v1/searches/:searchId/run`.
|
- Search sends `POST /v1/searches/:searchId/run`.
|
||||||
|
|||||||
@@ -431,6 +431,10 @@ export default function App() {
|
|||||||
requestId: null,
|
requestId: null,
|
||||||
latencyMs: null,
|
latencyMs: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
answerText: null,
|
||||||
|
answerRequestId: null,
|
||||||
|
answerCitations: null,
|
||||||
|
answerError: null,
|
||||||
results: [],
|
results: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -440,6 +444,10 @@ export default function App() {
|
|||||||
query,
|
query,
|
||||||
error: null,
|
error: null,
|
||||||
latencyMs: null,
|
latencyMs: null,
|
||||||
|
answerText: null,
|
||||||
|
answerRequestId: null,
|
||||||
|
answerCitations: null,
|
||||||
|
answerError: null,
|
||||||
results: [],
|
results: [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -718,6 +726,38 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{(isSearchRunning || !!selectedSearch?.answerText || !!selectedSearch?.answerError) && (
|
||||||
|
<section className="mb-6 rounded-xl border border-slate-600/60 bg-[#121a2e] p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-sky-300/90">Answer</p>
|
||||||
|
{isSearchRunning && !selectedSearch?.answerText ? (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Generating answer...</p>
|
||||||
|
) : null}
|
||||||
|
{selectedSearch?.answerText ? (
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-100">{selectedSearch.answerText}</p>
|
||||||
|
) : null}
|
||||||
|
{selectedSearch?.answerError ? <p className="mt-2 text-sm text-red-500">{selectedSearch.answerError}</p> : null}
|
||||||
|
{!!selectedSearch?.answerCitations?.length && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{selectedSearch.answerCitations.slice(0, 6).map((citation, index) => {
|
||||||
|
const href = citation.url || citation.id || "";
|
||||||
|
if (!href) return null;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={`${href}-${index}`}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-md border border-slate-500/60 px-2 py-1 text-xs text-sky-200 hover:bg-slate-700/40"
|
||||||
|
>
|
||||||
|
{citation.title?.trim() || formatHost(href)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{(isLoadingSelection || isSearchRunning) && !selectedSearch?.results.length ? (
|
{(isLoadingSelection || isSearchRunning) && !selectedSearch?.results.length ? (
|
||||||
<p className="text-sm text-muted-foreground">{isSearchRunning ? "Searching Exa..." : "Loading search..."}</p>
|
<p className="text-sm text-muted-foreground">{isSearchRunning ? "Searching Exa..." : "Loading search..."}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ export type SearchDetail = {
|
|||||||
requestId: string | null;
|
requestId: string | null;
|
||||||
latencyMs: number | null;
|
latencyMs: number | null;
|
||||||
error: string | 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[];
|
results: SearchResultItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user