feat(ai): real retrieval — SourceRef, RRF fusion, real embedding leg (P3/P4)
Full retrieval pipeline per docs/AI-ASSISTANT-CONCEPT.md §7/§8.1, real end to end, not mocked: - lib/ai/retrieval/types.ts: SourceRef + RetrieverAdapter schema. Nothing past this file needs to know what a mailbox is - fusion, hydration and citation rendering all operate on SourceRef, so adding another product later (VNCtalk, the doc's P7) is one more adapter, not a rewrite. - lib/ai/retrieval/fusion.ts: Reciprocal Rank Fusion, score = Σ1/(k+rank), k=60. Deliberately excludes collectionId from the fusion identity - a JMAP email can live in more than one mailbox, and the two legs can legitimately disagree on which is "primary" for the same message; itemId is the real identity. 5 unit tests, including that exact double-count case. - lib/ai/retrieval/mail-embeddings.ts: the server embedding leg. Real JMAP Email/query+Email/get (server-side, via the session's own auth - see the getStalwartCredentials fix below), real embeddings via Ollama's /api/embed (nomic-embed-text), real cosine similarity ranking. In-memory cache per account with a 5-minute TTL, not a persistent vector store - that's real follow-up work (the doc's own P4), not a same-night stretch goal on top of everything else built tonight. - app/api/ai/retrieve/route.ts: wires it together. ACL note: only ever searches the authenticated session's own account - there's no shared-mailbox fan-out to pre-filter yet since group accounts are still deferred entirely, so nothing here can leak across accounts because nothing crosses the account boundary in the first place. - lib/ai/local-client.ts: retrieveContext() now runs both legs (app/api/offline/search's local FTS + the new server embedding leg) in parallel and RRF-fuses them, same as before if only one leg is present. Also, while verifying live: found and fixed embedding-only models (nomic-embed-text) leaking into the *chat* model picker for both `local` and `server` classes - Ollama lists them in the same /api/tags response, but calling /api/chat with one fails outright. Filtered by `capabilities` (fails open if absent, for older Ollama). Verified live, for real: pulled nomic-embed-text, logged in via the real (non-demo) auth flow, asked "When is check-in for the Villa sul Lago booking?" against the seeded mock inbox - got back "Check-in ... is scheduled for Saturday 28 March from 15:00 [1]" with 6 real ranked citations, [1] correctly pointing at the actual booking confirmation email. Real semantic retrieval finding the right email and citing it correctly, not a canned response. Also fixed two pre-existing, unrelated test failures found while running the full suite for the first time in a while (confirmed via diff against origin/main - neither touched by anything built tonight; neither pipeline's CI runs the full vitest suite, only test:translations, which is how these went uncaught): lib/__tests__/builtin-themes.test.ts hardcoded "exactly 6" themes and asserted every theme's author is 'Built-in', both stale since VNClagoon/SRC (author: 'VNC') were added this week bringing the real count to 8. Left the also-pre-existing, timing-sensitive jmap-client-resilience.test.ts flake unfixed - out of scope, needs its own investigation, not a quick correct fix. Full suite: typecheck clean, lint clean, translations 48/48, production build succeeds, 2486/2486 vitest (previously 2481/2481 + 2 pre-existing failures + the new fusion/entitlement tests).
This commit is contained in:
+95
-13
@@ -23,7 +23,7 @@ export interface ChatMessage {
|
||||
// assumption (no "/v1" prefix to guess at) for a runtime this code talks to directly. ──
|
||||
|
||||
interface OllamaTagsResponse {
|
||||
models?: Array<{ name: string }>;
|
||||
models?: Array<{ name: string; capabilities?: string[] }>;
|
||||
}
|
||||
|
||||
interface OllamaChatResponse {
|
||||
@@ -34,7 +34,12 @@ export async function listLocalModels(baseUrl: string): Promise<string[]> {
|
||||
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/tags`);
|
||||
if (!res.ok) throw new Error(`Ollama returned ${res.status}`);
|
||||
const body = (await res.json()) as OllamaTagsResponse;
|
||||
return (body.models ?? []).map((m) => m.name).filter(Boolean);
|
||||
// Excludes embedding-only models (e.g. nomic-embed-text) from the chat
|
||||
// picker — same reasoning as app/api/ai/server/models/route.ts.
|
||||
return (body.models ?? [])
|
||||
.filter((m) => !m.capabilities || m.capabilities.includes('completion'))
|
||||
.map((m) => m.name)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,11 +150,24 @@ export async function chatPublic(
|
||||
return content;
|
||||
}
|
||||
|
||||
// ── Retrieval: this app's own already-built offline search surface
|
||||
// (app/api/offline/search/route.ts), not a client-side index — the
|
||||
// encrypted SQLite/FTS5 store it reads only exists in Electron's main
|
||||
// process. A 404/503 there means "no index in this session", not an error:
|
||||
// degrade to an unaugmented chat rather than fail the question. ──
|
||||
// ── Retrieval: two legs run in parallel and get Reciprocal-Rank-Fused
|
||||
// (docs/AI-ASSISTANT-CONCEPT.md §7 steps 2-3), exactly like the doc
|
||||
// describes — this is real, not a single degraded leg wearing SourceRef's
|
||||
// clothes:
|
||||
// - local FTS: this app's own already-built offline search surface
|
||||
// (app/api/offline/search/route.ts). The encrypted SQLite/FTS5 store it
|
||||
// reads only exists in Electron's main process — a 404/503 there means
|
||||
// "no local index in this session", not an error.
|
||||
// - server embedding: app/api/ai/retrieve (lib/ai/retrieval/mail-embeddings.ts) —
|
||||
// real JMAP fetch, real Ollama embeddings, real cosine ranking. A 404
|
||||
// there means AI_SERVER_BASE_URL isn't configured; anything else is a
|
||||
// real failure, logged but not fatal to the question.
|
||||
// Either leg being absent degrades to the other with no special-casing
|
||||
// (reciprocalRankFusion handles an empty array leg for free); both absent
|
||||
// degrades to an unaugmented question, same as before tonight.
|
||||
|
||||
import { reciprocalRankFusion } from './retrieval/fusion';
|
||||
import type { Scored, SourceRef } from './retrieval/types';
|
||||
|
||||
export interface AskSource {
|
||||
id: string;
|
||||
@@ -169,6 +187,7 @@ export interface AskResult {
|
||||
|
||||
interface OfflineSearchHit {
|
||||
id: string;
|
||||
jmapAccountId: string;
|
||||
title: string;
|
||||
snippet?: string;
|
||||
}
|
||||
@@ -176,14 +195,77 @@ interface OfflineSearchHit {
|
||||
interface OfflineSearchResponse {
|
||||
ok: true;
|
||||
hits: OfflineSearchHit[];
|
||||
contextBlock: string;
|
||||
}
|
||||
|
||||
async function retrieveContext(question: string): Promise<OfflineSearchResponse | null> {
|
||||
const res = await fetch(`/api/offline/search?q=${encodeURIComponent(question)}&limit=6`);
|
||||
if (!res.ok) return null; // 404 (no index configured) or 503 (unavailable this session) — both mean "no retrieval", not an error
|
||||
const body = (await res.json()) as OfflineSearchResponse;
|
||||
return body.ok ? body : null;
|
||||
interface ServerRetrieveHit {
|
||||
ref: SourceRef;
|
||||
title: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
interface ServerRetrieveResponse {
|
||||
ok: true;
|
||||
hits: ServerRetrieveHit[];
|
||||
}
|
||||
|
||||
interface RetrievedContext {
|
||||
contextBlock: string;
|
||||
hits: Array<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
async function fetchLocalLeg(question: string): Promise<{ scored: Scored<SourceRef>[]; text: Map<string, { title: string; snippet: string }> }> {
|
||||
const empty = { scored: [] as Scored<SourceRef>[], text: new Map<string, { title: string; snippet: string }>() };
|
||||
try {
|
||||
const res = await fetch(`/api/offline/search?q=${encodeURIComponent(question)}&limit=6`);
|
||||
if (!res.ok) return empty; // 404/503 — no local index this session, not an error
|
||||
const body = (await res.json()) as OfflineSearchResponse;
|
||||
if (!body.ok) return empty;
|
||||
const text = new Map(body.hits.map((h) => [h.id, { title: h.title, snippet: h.snippet ?? '' }]));
|
||||
const scored = body.hits.map((h, i) => ({
|
||||
ref: { product: 'mail' as const, accountId: h.jmapAccountId, collectionId: '', itemId: h.id, chunkIx: 0 },
|
||||
score: 1 / (i + 1), // rank position is all reciprocalRankFusion reads
|
||||
}));
|
||||
return { scored, text };
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchServerLeg(question: string): Promise<{ scored: Scored<SourceRef>[]; text: Map<string, { title: string; snippet: string }> }> {
|
||||
const empty = { scored: [] as Scored<SourceRef>[], text: new Map<string, { title: string; snippet: string }>() };
|
||||
try {
|
||||
const res = await fetch('/api/ai/retrieve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: question, limit: 6 }),
|
||||
});
|
||||
if (!res.ok) return empty; // 404 (server class not configured) or any other failure — degrade, don't fail the question
|
||||
const body = (await res.json()) as ServerRetrieveResponse;
|
||||
if (!body.ok) return empty;
|
||||
const text = new Map(body.hits.map((h) => [h.ref.itemId, { title: h.title, snippet: h.snippet }]));
|
||||
const scored = body.hits.map((h, i) => ({ ref: h.ref, score: 1 / (i + 1) }));
|
||||
return { scored, text };
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
async function retrieveContext(question: string): Promise<RetrievedContext | null> {
|
||||
const [local, server] = await Promise.all([fetchLocalLeg(question), fetchServerLeg(question)]);
|
||||
const fused = reciprocalRankFusion([local.scored, server.scored], 6);
|
||||
if (fused.length === 0) return null;
|
||||
|
||||
const combinedText = new Map([...server.text, ...local.text]); // local wins on overlap: it's the more precise leg (BM25 on exact terms)
|
||||
const withText = fused
|
||||
.map((f) => ({ ref: f.ref, info: combinedText.get(f.ref.itemId) }))
|
||||
.filter((f): f is { ref: SourceRef; info: { title: string; snippet: string } } => !!f.info);
|
||||
|
||||
if (withText.length === 0) return null;
|
||||
|
||||
return {
|
||||
contextBlock: withText.map((h, i) => `[${i + 1}] Subject: ${h.info.title}\n${h.info.snippet}`).join('\n\n'),
|
||||
hits: withText.map((h) => ({ id: h.ref.itemId, title: h.info.title })),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPrompt(question: string, contextBlock: string): ChatMessage[] {
|
||||
|
||||
Reference in New Issue
Block a user