Files
SRCmail/lib/ai/retrieval/__tests__/fusion.test.ts
T
Bernd Rodler 91b282d746 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).
2026-08-06 00:21:13 +02:00

46 lines
1.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { reciprocalRankFusion } from '../fusion';
import type { SourceRef } from '../types';
function ref(itemId: string): SourceRef {
return { product: 'mail', accountId: 'acct-1', collectionId: 'inbox', itemId, chunkIx: 0 };
}
describe('reciprocalRankFusion', () => {
it('ranks an item found by both legs above one found by only one', () => {
const local = [{ ref: ref('a'), score: 1 }, { ref: ref('b'), score: 0.9 }];
const server = [{ ref: ref('a'), score: 0.8 }, { ref: ref('c'), score: 0.7 }];
const fused = reciprocalRankFusion([local, server], 10);
expect(fused[0].ref.itemId).toBe('a'); // rank 1 in both legs
expect(fused.map((f) => f.ref.itemId)).toEqual(['a', 'b', 'c']);
});
it('degrades to a single retriever when one leg is empty, no special-casing needed', () => {
const local = [{ ref: ref('a'), score: 1 }, { ref: ref('b'), score: 0.5 }];
const fused = reciprocalRankFusion([local, []], 10);
expect(fused.map((f) => f.ref.itemId)).toEqual(['a', 'b']);
});
it('returns nothing when both legs are empty', () => {
expect(reciprocalRankFusion([[], []], 10)).toEqual([]);
});
it('respects the limit', () => {
const local = [ref('a'), ref('b'), ref('c')].map((r, i) => ({ ref: r, score: 1 - i * 0.1 }));
const fused = reciprocalRankFusion([local, []], 2);
expect(fused).toHaveLength(2);
});
it('does not double-count the same item across legs when collectionId differs', () => {
// Same email, but the two legs report a different mailbox for it - see
// fusion.ts's refKey comment for why collectionId is deliberately not
// part of the fusion identity.
const local = [{ ref: { ...ref('a'), collectionId: 'inbox' }, score: 1 }];
const server = [{ ref: { ...ref('a'), collectionId: 'archive' }, score: 1 }];
const fused = reciprocalRankFusion([local, server], 10);
expect(fused).toHaveLength(1);
});
});