src/services/kb-agent.test.ts
Rama main · réplica de lectura del repositorio original.
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AppConfig } from '../core/config/config';
import type { ConfigStoreContract } from '../infrastructure/config/configstore';
import type { KnowledgeRepository, KbDocRecord, KbIndexRecord } from './kb-repository';
import { KbService, agentKnowledgeDocumentRevision, chunkText } from './kb';
function fixture() {
const documents = new Map<number, KbDocRecord>([[1, { doc_id: 1, kb_id: 'allowed', title: '装修指南', content: '装修必须绑定页面设置。\n\n其余步骤按需读取。',
content_hash: 'legacy-hash', status: 'ready', chunk_count: 1, created_at: '', updated_at: '' }]]);
const disabled = new Set<string>(); const failed = new Set<string>(); let indexReads = 0;
const vector = Float32Array.from([1, 0]);
let rows: KbIndexRecord[] = [{ id: 1, doc_id: 1, seq: 0, title: '装修指南', content: chunkText(documents.get(1)!.content)[0]!,
embedding: Buffer.from(vector.buffer) }];
const repository = { getBase: async (kb: string) => failed.has(kb) ? null : { kb_id: kb, name: 'not-returned-name', credential: 'embedding-secret-name',
model: 'synthetic', dim: 2, enabled: !disabled.has(kb) }, getDoc: async (id: number) => documents.get(id) ?? null,
listIndexRows: async (kb: string) => { indexReads++; if (failed.has(kb)) throw new Error('raw-sql-private'); return kb === 'allowed' ? rows : []; } } as unknown as KnowledgeRepository;
const store = { credentials: { get: async () => ({ enabled: true, kind: 'embedding', base_url: 'https://synthetic.invalid', api_key: 'secret' }), touch: async () => undefined } } as unknown as ConfigStoreContract;
const service = new KbService(store, {} as AppConfig, repository);
return { service, documents, disabled, failed, rows: () => rows, setRows: (value: KbIndexRecord[]) => { rows = value; }, indexReads: () => indexReads };
}
test('Agent retrieval uses fresh index rows and current ready document snapshots without populating native cache', async (t) => {
t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify({ data: [{ index: 0, embedding: [1, 0] }] })));
const f = fixture();
const first = await f.service.searchForAgent(['allowed'], '装修', 8, 0.5, 3);
assert.equal(first.status, 'ok'); assert.equal(first.documents.length, 1);
assert.equal(first.documents[0]!.revision, agentKnowledgeDocumentRevision('装修指南', f.documents.get(1)!.content));
assert.equal(first.documents[0]!.summary, f.rows()[0]!.content);
await f.service.searchForAgent(['allowed'], '装修', 8, 0.5, 3);
assert.equal(f.indexReads(), 2);
assert.equal((f.service as unknown as { indexCache: Map<string, unknown> }).indexCache.size, 0);
});
test('Agent retrieval discards stale chunks, stale titles, nonready documents, wrong-KB parents and deleted documents', async (t) => {
t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify({ data: [{ index: 0, embedding: [1, 0] }] })));
for (const failure of ['content', 'title', 'embedding', 'error', 'wrong_kb', 'deleted']) {
const f = fixture(); const doc = f.documents.get(1)!;
if (failure === 'content') doc.content = '替换后的原文不包含旧设计。';
if (failure === 'title') doc.title = '已更名';
if (failure === 'embedding' || failure === 'error') doc.status = failure;
if (failure === 'wrong_kb') doc.kb_id = 'unavailable';
if (failure === 'deleted') f.documents.delete(1);
const result = await f.service.searchForAgent(['allowed'], '装修', 8, 0.5, 3);
assert.equal(result.status, 'ok', failure); assert.deepEqual(result.documents, [], failure);
}
});
test('Agent retrieval distinguishes real empty matches, partial failure and total unavailability', async (t) => {
t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify({ data: [{ index: 0, embedding: [1, 0] }] })));
const f = fixture();
const empty = await f.service.searchForAgent(['empty'], '装修', 8, 0.5, 3);
assert.equal(empty.status, 'ok'); assert.deepEqual(empty.documents, []);
f.failed.add('broken');
assert.equal((await f.service.searchForAgent(['allowed', 'broken'], '装修', 8, 0.5, 3)).status, 'partial');
assert.equal((await f.service.searchForAgent(['broken'], '装修', 8, 0.5, 3)).status, 'unavailable');
f.disabled.add('allowed');
assert.equal((await f.service.searchForAgent(['allowed'], '装修', 8, 0.5, 3)).status, 'unavailable');
});
test('Agent retrieval keeps configured min_score and deduplicates parent documents without filling weak matches', async (t) => {
t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify({ data: [{ index: 0, embedding: [1, 0] }] })));
const f = fixture(); const weak = Float32Array.from([0.2, 0.98]);
f.setRows([{ ...f.rows()[0]!, embedding: Buffer.from(weak.buffer) }]);
assert.deepEqual((await f.service.searchForAgent(['allowed'], '装修', 8, 0.5, 3)).documents, []);
assert.equal((await f.service.searchForAgent(['allowed'], '装修', 8, 0.1, 3)).documents.length, 1);
f.setRows([f.rows()[0]!, { ...f.rows()[0]!, id: 2 }]);
assert.equal((await f.service.searchForAgent(['allowed'], '装修', 8, 0.1, 3)).documents.length, 1);
});
test('Agent snapshots require enabled KB, exact ownership, ready state and recalculate actual title/body revisions', async () => {
const f = fixture(); const original = await f.service.getAgentDocumentSnapshot('allowed', 1);
assert.ok(original); f.documents.get(1)!.content += '新内容';
const current = await f.service.getAgentDocumentSnapshot('allowed', 1); assert.notEqual(current!.revision, original.revision);
assert.equal(await f.service.getAgentDocumentSnapshot('another', 1), null);
f.disabled.add('allowed'); assert.equal(await f.service.getAgentDocumentSnapshot('allowed', 1), null);
});
test('Agent embedding failures are reflected as unavailability without returning upstream secrets', async (t) => {
t.mock.method(globalThis, 'fetch', async () => { throw new Error('secret-api-key private-host raw-body'); });
const f = fixture(); const result = await f.service.searchForAgent(['allowed'], '装修', 8, 0.5, 3);
assert.deepEqual(result, { status: 'unavailable', documents: [], truncated: false });
assert.doesNotMatch(JSON.stringify(result), /secret|private-host|raw-body/);
});