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:
Bernd Rodler
2026-08-06 00:21:13 +02:00
parent dda7adf565
commit 91b282d746
8 changed files with 504 additions and 18 deletions
+67
View File
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { serverSearchMail, hydrateMailRefs } from '@/lib/ai/retrieval/mail-embeddings';
import { logger } from '@/lib/logger';
export const runtime = 'nodejs';
const MAX_QUERY_CHARS = 512;
const DEFAULT_LIMIT = 6;
/**
* POST /api/ai/retrieve — the server embedding leg (docs/AI-ASSISTANT-CONCEPT.md
* §7 step 2). Real JMAP fetch + real Ollama embeddings + real cosine ranking
* (lib/ai/retrieval/mail-embeddings.ts), not a mock.
*
* ACL note (§7 step 2b): this only ever embeds/searches the *authenticated
* session's own* JMAP account — there is no shared-mailbox fan-out to
* pre-filter yet, since group accounts are still deferred entirely (matches
* the doc's own "shared-mailbox retrieval ships server-only" decision, which
* itself hasn't been reached because there's no group account to retrieve
* from). Nothing here can leak across accounts because nothing crosses the
* account boundary in the first place.
*/
export async function POST(request: NextRequest) {
const auth = await getStalwartCredentials(request);
if (!auth) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
if (!process.env.AI_SERVER_BASE_URL) {
return new NextResponse(null, { status: 404 });
}
let body: { query?: unknown; limit?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
const query = typeof body.query === 'string' ? body.query.trim() : '';
if (!query) {
return NextResponse.json({ error: 'query is required' }, { status: 400 });
}
if (query.length > MAX_QUERY_CHARS) {
return NextResponse.json({ error: 'query too long' }, { status: 400 });
}
const limit = typeof body.limit === 'number' ? Math.min(Math.max(Math.trunc(body.limit), 1), 20) : DEFAULT_LIMIT;
try {
const scored = await serverSearchMail(auth.serverUrl, auth.authHeader, query, limit);
const chunks = await hydrateMailRefs(auth.serverUrl, auth.authHeader, scored.map((s) => s.ref));
const contextBlock = chunks
.map((c, i) => `[${i + 1}] Subject: ${c.title}\n${c.text}`)
.join('\n\n');
return NextResponse.json({
ok: true,
hits: chunks.map((c, i) => ({ ref: c.ref, title: c.title, snippet: c.text.slice(0, 200), rank: i + 1 })),
contextBlock,
}, { headers: { 'Cache-Control': 'no-store' } });
} catch (cause) {
logger.error('ai retrieve failed', { error: cause instanceof Error ? cause.message : String(cause) });
return NextResponse.json({ error: 'retrieval unavailable' }, { status: 502 });
}
}
+8 -2
View File
@@ -31,8 +31,14 @@ export async function GET(request: NextRequest) {
if (!res.ok) {
return NextResponse.json({ error: `upstream returned ${res.status}` }, { status: 502 });
}
const body = (await res.json()) as { models?: Array<{ name: string }> };
return NextResponse.json({ models: (body.models ?? []).map((m) => m.name).filter(Boolean) });
const body = (await res.json()) as { models?: Array<{ name: string; capabilities?: string[] }> };
// Excludes embedding-only models (e.g. nomic-embed-text, used by
// lib/ai/retrieval/mail-embeddings.ts) from the *chat* picker — Ollama
// lists them in the same /api/tags response, but calling /api/chat with
// one fails outright. `capabilities` absent (older Ollama) fails open
// rather than hiding every model on an upgrade.
const chatModels = (body.models ?? []).filter((m) => !m.capabilities || m.capabilities.includes('completion'));
return NextResponse.json({ models: chatModels.map((m) => m.name).filter(Boolean) });
} catch (cause) {
return NextResponse.json(
{ error: cause instanceof Error ? cause.message : 'AI server unreachable' },