139 lines
6.7 KiB
TypeScript
139 lines
6.7 KiB
TypeScript
/**
|
|
* KVARK Pipeline Smoke - assembled path validation (Milestone E1).
|
|
* Exercises real B1-B4 + conflict + C pipeline code.
|
|
* Only KVARK HTTP boundary is mocked via KvarkClientLike.
|
|
*/
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { CombinedRetrieval, detectConflict, type MemorySearchLike, type MemorySearchResultLike } from '../src/combined-retrieval.js';
|
|
import { formatCombinedResult } from '../src/tools.js';
|
|
import { createKvarkTools, type KvarkClientLike, type KvarkSearchResponseLike } from '../src/kvark-tools.js';
|
|
|
|
function memStub(r: Array<{ id: number; content: string; score: number; type?: string }>): MemorySearchLike {
|
|
const m: MemorySearchResultLike[] = r.map(x => ({ frame: { id: x.id, content: x.content, frame_type: x.type ?? 'fact', importance: 'normal' }, finalScore: x.score }));
|
|
return { search: vi.fn().mockResolvedValue(m) };
|
|
}
|
|
|
|
function kStub(r: Array<{ id: number; title: string; snippet: string; score: number; type?: string }>): KvarkClientLike {
|
|
const resp: KvarkSearchResponseLike = { query: 'q', total: r.length, results: r.map(x => ({ document_id: x.id, title: x.title, snippet: x.snippet, score: x.score, document_type: x.type ?? null })) };
|
|
return { search: vi.fn().mockResolvedValue(resp), askDocument: vi.fn().mockResolvedValue({ answer: '', sources: [] }), feedback: vi.fn().mockResolvedValue({ ok: true }) };
|
|
}
|
|
|
|
describe('Pipeline: happy path', () => {
|
|
it('all sources produce formatted output with attribution', async () => {
|
|
const cr = new CombinedRetrieval({
|
|
workspaceSearch: memStub([{ id: 1, content: 'We use PostgreSQL', score: 0.75, type: 'decision' }]),
|
|
personalSearch: memStub([{ id: 2, content: 'User prefers concise', score: 0.5 }]),
|
|
kvarkClient: kStub([{ id: 42, title: 'ADR', snippet: 'PostgreSQL selected', score: 0.88, type: 'pdf' }]),
|
|
});
|
|
const r = await cr.search('db');
|
|
const o = formatCombinedResult(r, true);
|
|
expect(o).toContain('## Workspace Memory');
|
|
expect(o).toContain('## Personal Memory');
|
|
expect(o).toContain('## Enterprise Knowledge (KVARK)');
|
|
expect(o).toContain('[workspace memory]');
|
|
expect(o).toContain('[personal memory]');
|
|
expect(o).toContain('[KVARK: pdf: ADR]');
|
|
expect(o).not.toContain('## Source Conflict');
|
|
expect(r.hasConflict).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Pipeline: conflict detection', () => {
|
|
it('approved vs cancelled triggers conflict', async () => {
|
|
const cr = new CombinedRetrieval({
|
|
workspaceSearch: memStub([{ id: 1, content: 'Vendor approved and confirmed', score: 0.85 }]),
|
|
personalSearch: memStub([]),
|
|
kvarkClient: kStub([{ id: 99, title: 'Policy', snippet: 'Vendor cancelled due to compliance', score: 0.9 }]),
|
|
});
|
|
const r = await cr.search('vendor');
|
|
expect(r.hasConflict).toBe(true);
|
|
const o = formatCombinedResult(r, true);
|
|
expect(o).toContain('## Source Conflict');
|
|
expect(o).toContain('## Workspace Memory');
|
|
expect(o).toContain('## Enterprise Knowledge');
|
|
});
|
|
|
|
it('detectConflict consistent with CombinedRetrieval', async () => {
|
|
const cr = new CombinedRetrieval({
|
|
workspaceSearch: memStub([{ id: 1, content: 'Project rejected', score: 0.8 }]),
|
|
personalSearch: memStub([]),
|
|
kvarkClient: kStub([{ id: 50, title: 'Minutes', snippet: 'Project approved', score: 0.85 }]),
|
|
});
|
|
const r = await cr.search('status');
|
|
const d = detectConflict(r.workspaceResults, r.kvarkResults);
|
|
expect(r.hasConflict).toBe(d !== null);
|
|
});
|
|
});
|
|
|
|
describe('Pipeline: KVARK skip', () => {
|
|
it('strong local results skip KVARK', async () => {
|
|
const k = kStub([{ id: 1, title: 'D', snippet: 't', score: 0.9 }]);
|
|
const cr = new CombinedRetrieval({
|
|
workspaceSearch: memStub([{ id: 1, content: 'A', score: 0.9 }, { id: 2, content: 'B', score: 0.85 }, { id: 3, content: 'C', score: 0.75 }]),
|
|
personalSearch: memStub([]), kvarkClient: k,
|
|
});
|
|
const r = await cr.search('test');
|
|
expect(r.kvarkSkipped).toBe(true);
|
|
expect(k.search).not.toHaveBeenCalled();
|
|
expect(formatCombinedResult(r, true)).not.toContain('Enterprise');
|
|
});
|
|
});
|
|
|
|
describe('Pipeline: KVARK failure', () => {
|
|
it('local results preserved on error', async () => {
|
|
const cr = new CombinedRetrieval({
|
|
workspaceSearch: memStub([{ id: 1, content: 'Local fact', score: 0.6 }]),
|
|
personalSearch: memStub([{ id: 2, content: 'Note', score: 0.4 }]),
|
|
kvarkClient: { search: vi.fn().mockRejectedValue(new Error('down')), askDocument: vi.fn() },
|
|
});
|
|
const r = await cr.search('x');
|
|
const o = formatCombinedResult(r, true);
|
|
expect(r.kvarkError).toBe('down');
|
|
expect(r.hasConflict).toBe(false);
|
|
expect(o).toContain('Local fact');
|
|
expect(o).toContain('error');
|
|
});
|
|
});
|
|
|
|
describe('Pipeline: feedback round-trip', () => {
|
|
it('search doc IDs valid for feedback', async () => {
|
|
const fb = vi.fn().mockResolvedValue({ ok: true });
|
|
const k = kStub([{ id: 42, title: 'Q3', snippet: 'Rev up', score: 0.9, type: 'pdf' }]);
|
|
k.feedback = fb;
|
|
const cr = new CombinedRetrieval({ workspaceSearch: memStub([]), personalSearch: memStub([]), kvarkClient: k });
|
|
const s = await cr.search('rev');
|
|
const did = s.kvarkResults[0].metadata.documentId;
|
|
expect(did).toBe(42);
|
|
const t = createKvarkTools({ client: k }).find(x => x.name === 'kvark_feedback')!;
|
|
const o = await t.execute({ document_id: did, query: 'rev', useful: true });
|
|
expect(fb).toHaveBeenCalledWith(42, 'rev', true, undefined);
|
|
expect(o).toContain('Feedback');
|
|
});
|
|
});
|
|
|
|
describe('Pipeline: scope filtering', () => {
|
|
it('personal skips workspace and KVARK', async () => {
|
|
const w = memStub([{ id: 1, content: 'ws', score: 0.8 }]);
|
|
const p = memStub([{ id: 2, content: 'pers', score: 0.7 }]);
|
|
const k = kStub([{ id: 42, title: 'D', snippet: 'e', score: 0.9 }]);
|
|
const cr = new CombinedRetrieval({ workspaceSearch: w, personalSearch: p, kvarkClient: k });
|
|
const r = await cr.search('t', { scope: 'personal' });
|
|
expect(r.workspaceResults).toHaveLength(0);
|
|
expect(r.kvarkResults).toHaveLength(0);
|
|
expect(w.search).not.toHaveBeenCalled();
|
|
expect(k.search).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('workspace skips personal and KVARK', async () => {
|
|
const w = memStub([{ id: 1, content: 'ws', score: 0.8 }]);
|
|
const p = memStub([{ id: 2, content: 'pers', score: 0.7 }]);
|
|
const k = kStub([{ id: 42, title: 'D', snippet: 'e', score: 0.9 }]);
|
|
const cr = new CombinedRetrieval({ workspaceSearch: w, personalSearch: p, kvarkClient: k });
|
|
const r = await cr.search('t', { scope: 'workspace' });
|
|
expect(r.personalResults).toHaveLength(0);
|
|
expect(r.kvarkResults).toHaveLength(0);
|
|
expect(p.search).not.toHaveBeenCalled();
|
|
expect(k.search).not.toHaveBeenCalled();
|
|
});
|
|
});
|