Skip to content

Commit 661e289

Browse files
kfaracikclaude
andcommitted
test(rag): add buildMessageSources pipeline integration test
Exercises the retrieval → context → citation orchestrator end to end through real hybridRetrieve/contextUtils, mocking only the native vector store and FTS5 keyword search. Covers the Source-N ↔ citation lockstep invariant, attachment overview/ordering, citing an attachment with no retrieved chunk, the attachment-only and empty paths, and that no final citation references a block absent from the context sent to the model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 61d940d commit 661e289

1 file changed

Lines changed: 280 additions & 0 deletions

File tree

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import {
2+
buildMessageSources,
3+
pickCitationsByAnswer,
4+
restrictCitationsToContext,
5+
type SourceRow,
6+
} from '../utils/messageSources';
7+
import { sourcesPresentInContext } from '../utils/contextUtils';
8+
import * as keywordIndex from '../database/keywordIndex';
9+
import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite';
10+
11+
jest.mock('../database/keywordIndex', () => ({
12+
keywordSearch: jest.fn(),
13+
}));
14+
15+
const mockKeywordSearch = keywordIndex.keywordSearch as jest.Mock;
16+
17+
type VectorRow = {
18+
id: string;
19+
document: string;
20+
embedding: number[];
21+
similarity: number;
22+
metadata: { documentId: number; name?: string };
23+
};
24+
25+
const makeVectorStore = (queryResults: VectorRow[]) => {
26+
const byId = new Map(queryResults.map((r) => [r.id, r]));
27+
return {
28+
query: jest.fn().mockResolvedValue(queryResults),
29+
db: {
30+
execute: jest
31+
.fn()
32+
.mockImplementation(async (_sql: string, ids: string[]) => ({
33+
rows: ids
34+
.map((id) => byId.get(id))
35+
.filter(Boolean)
36+
.map((r) => ({
37+
id: r!.id,
38+
document: r!.document,
39+
embedding: r!.embedding,
40+
metadata: JSON.stringify(r!.metadata),
41+
})),
42+
})),
43+
},
44+
} as unknown as OPSQLiteVectorStore;
45+
};
46+
47+
const source = (id: number, name: string, firstChunk?: string): SourceRow => ({
48+
id,
49+
name,
50+
firstChunk,
51+
});
52+
53+
const presentNames = (context: string[]): Set<string> =>
54+
sourcesPresentInContext(context.join('\n'));
55+
56+
beforeEach(() => {
57+
mockKeywordSearch.mockReset();
58+
jest.spyOn(console, 'error').mockImplementation(() => {});
59+
jest.spyOn(console, 'warn').mockImplementation(() => {});
60+
});
61+
62+
afterEach(() => {
63+
jest.restoreAllMocks();
64+
});
65+
66+
describe('buildMessageSources — retrieval → context → citation pipeline', () => {
67+
it('keeps context "Source N" headers and the citation set in lockstep across two documents', async () => {
68+
const vectorStore = makeVectorStore([
69+
{
70+
id: '1:0',
71+
document: 'vacation policy grants twenty six days of paid leave',
72+
embedding: [1, 0],
73+
similarity: 0.85,
74+
metadata: { documentId: 1, name: 'handbook.pdf' },
75+
},
76+
{
77+
id: '2:0',
78+
document: 'quarterly revenue reached five million dollars',
79+
embedding: [0, 1],
80+
similarity: 0.82,
81+
metadata: { documentId: 2, name: 'q4_report.pdf' },
82+
},
83+
]);
84+
mockKeywordSearch.mockResolvedValue([
85+
{ chunkId: '1:0', documentId: 1, score: -1 },
86+
{ chunkId: '2:0', documentId: 2, score: -1.1 },
87+
]);
88+
89+
const { context, sourceDocuments, preferredSourceDocuments } =
90+
await buildMessageSources({
91+
userInput: 'how many vacation days and what was the revenue',
92+
attachmentSourceIds: [],
93+
enabledSources: [1, 2],
94+
sources: [source(1, 'handbook.pdf'), source(2, 'q4_report.pdf')],
95+
vectorStore,
96+
embeddings: null,
97+
});
98+
99+
expect(context.join('\n')).toContain('--- Source 1:');
100+
expect(context.join('\n')).toContain('--- Source 2:');
101+
102+
const citedNames = new Set(sourceDocuments.map((d) => d.name));
103+
expect(citedNames).toEqual(presentNames(context));
104+
expect(citedNames).toEqual(new Set(['handbook.pdf', 'q4_report.pdf']));
105+
expect(preferredSourceDocuments).toEqual([]);
106+
});
107+
108+
it('prepends the attachment overview and orders the attachment first in the citations', async () => {
109+
const vectorStore = makeVectorStore([
110+
{
111+
id: '1:0',
112+
document: 'older library document about vacation policy',
113+
embedding: [1, 0],
114+
similarity: 0.8,
115+
metadata: { documentId: 1, name: 'library.pdf' },
116+
},
117+
{
118+
id: '2:0',
119+
document: 'freshly attached note with low semantic overlap',
120+
embedding: [0, 1],
121+
similarity: 0.05,
122+
metadata: { documentId: 2, name: 'attachment.txt' },
123+
},
124+
]);
125+
mockKeywordSearch.mockResolvedValue([
126+
{ chunkId: '1:0', documentId: 1, score: -1 },
127+
]);
128+
129+
const { context, sourceDocuments, preferredSourceDocuments } =
130+
await buildMessageSources({
131+
userInput: 'what does the attachment say',
132+
attachmentSourceIds: [2],
133+
enabledSources: [1],
134+
sources: [
135+
source(1, 'library.pdf'),
136+
source(2, 'attachment.txt', 'attached overview snippet'),
137+
],
138+
vectorStore,
139+
embeddings: null,
140+
});
141+
142+
expect(context[0]).toContain(
143+
'Current Attachment Source: attachment.txt (Overview)'
144+
);
145+
expect(sourceDocuments[0].documentId).toBe(2);
146+
expect(preferredSourceDocuments.map((d) => d.documentId)).toEqual([2]);
147+
expect(new Set(sourceDocuments.map((d) => d.name))).toEqual(
148+
new Set(['attachment.txt', 'library.pdf'])
149+
);
150+
});
151+
152+
it('still cites a freshly attached source that produced no retrieved chunk', async () => {
153+
const vectorStore = makeVectorStore([
154+
{
155+
id: '1:0',
156+
document: 'the only retrievable content is in the library file',
157+
embedding: [1, 0],
158+
similarity: 0.8,
159+
metadata: { documentId: 1, name: 'library.pdf' },
160+
},
161+
]);
162+
mockKeywordSearch.mockResolvedValue([
163+
{ chunkId: '1:0', documentId: 1, score: -1 },
164+
]);
165+
166+
const { context, sourceDocuments } = await buildMessageSources({
167+
userInput: 'summarize everything',
168+
attachmentSourceIds: [2],
169+
enabledSources: [1],
170+
sources: [
171+
source(1, 'library.pdf'),
172+
source(2, 'attachment.pdf', 'attachment overview only'),
173+
],
174+
vectorStore,
175+
embeddings: null,
176+
});
177+
178+
expect(sourceDocuments.map((d) => d.documentId)).toEqual([2, 1]);
179+
expect(context[0]).toContain('attachment.pdf (Overview)');
180+
});
181+
182+
it('takes the attachment-only path when there is no user query', async () => {
183+
const vectorStore = makeVectorStore([]);
184+
mockKeywordSearch.mockResolvedValue([]);
185+
186+
const { context, sourceDocuments } = await buildMessageSources({
187+
userInput: ' ',
188+
attachmentSourceIds: [5],
189+
enabledSources: [],
190+
sources: [source(5, 'dropped.pdf', 'just attached, no question yet')],
191+
vectorStore,
192+
embeddings: null,
193+
});
194+
195+
expect(vectorStore.query).not.toHaveBeenCalled();
196+
expect(sourceDocuments).toEqual([
197+
{
198+
documentId: 5,
199+
name: 'dropped.pdf',
200+
passage: 'just attached, no question yet',
201+
},
202+
]);
203+
expect(context[0]).toContain('dropped.pdf (Overview)');
204+
});
205+
206+
it('returns nothing and never touches retrieval when no sources are active', async () => {
207+
const vectorStore = makeVectorStore([]);
208+
mockKeywordSearch.mockResolvedValue([]);
209+
210+
const result = await buildMessageSources({
211+
userInput: 'anything',
212+
attachmentSourceIds: [],
213+
enabledSources: [],
214+
sources: [source(1, 'unused.pdf')],
215+
vectorStore,
216+
embeddings: null,
217+
});
218+
219+
expect(result).toEqual({
220+
context: [],
221+
sourceDocuments: [],
222+
preferredSourceDocuments: [],
223+
});
224+
expect(vectorStore.query).not.toHaveBeenCalled();
225+
expect(mockKeywordSearch).not.toHaveBeenCalled();
226+
});
227+
228+
it('never emits a citation whose block is absent from the context sent to the model', async () => {
229+
const vectorStore = makeVectorStore([
230+
{
231+
id: '1:0',
232+
document: 'vacation policy grants twenty six days of paid leave',
233+
embedding: [1, 0],
234+
similarity: 0.85,
235+
metadata: { documentId: 1, name: 'handbook.pdf' },
236+
},
237+
{
238+
id: '2:0',
239+
document: 'quarterly revenue reached five million dollars',
240+
embedding: [0, 1],
241+
similarity: 0.82,
242+
metadata: { documentId: 2, name: 'q4_report.pdf' },
243+
},
244+
]);
245+
mockKeywordSearch.mockResolvedValue([
246+
{ chunkId: '1:0', documentId: 1, score: -1 },
247+
{ chunkId: '2:0', documentId: 2, score: -1.1 },
248+
]);
249+
250+
const { context, sourceDocuments, preferredSourceDocuments } =
251+
await buildMessageSources({
252+
userInput: 'how many vacation days do i get',
253+
attachmentSourceIds: [],
254+
enabledSources: [1, 2],
255+
sources: [source(1, 'handbook.pdf'), source(2, 'q4_report.pdf')],
256+
vectorStore,
257+
embeddings: null,
258+
});
259+
260+
const answer =
261+
'You are granted twenty six days of paid vacation leave each year.';
262+
263+
const byAnswer = pickCitationsByAnswer(
264+
sourceDocuments,
265+
answer,
266+
preferredSourceDocuments
267+
);
268+
const finalCitations = restrictCitationsToContext(
269+
byAnswer,
270+
context.join('\n'),
271+
preferredSourceDocuments
272+
);
273+
274+
expect(finalCitations.map((d) => d.documentId)).toEqual([1]);
275+
const present = presentNames(context);
276+
for (const cited of finalCitations) {
277+
expect(present.has(cited.name)).toBe(true);
278+
}
279+
});
280+
});

0 commit comments

Comments
 (0)