Files
Arcrun/kbdb/tests/search-source-and-score.test.ts
T
Claude 65d85eb08f fix(kbdb): search keyword 補 source filter(#66)+semantic 曝 top_k/min_score 帶 score(#67)
#66:/entries/search keyword 路徑 source 解析後丟棄(#5.1 只接了 listEntries 那半)——
searchEntries 尾端加 source?(既有 positional caller 全不用改),conds 補與 listEntries
同款 json_extract(metadata_json,'$.source') 謂詞;route keyword 分支與 semantic 降級
分支兩處傳入。

#67:semantic 固定 topK=20、零分數閾值、低分尾硬湊數——route 曝 top_k(預設 20、封頂
100)與 min_score(預設 0=不過濾)query 參數;semanticSearch 依 min_score 截低分尾;
semantic 回應 entry 附 score 欄(加欄不改形)。壞值(非數字/非正)視同沒帶,不 400。

向後相容:不帶新參數時輸出與現況一致(semantic 僅多 score 資訊);不動表(D6)、
不動 D1 結構(API-as-Wall)。測試:新增 search-source-and-score.test.ts 13 條
(source 謂詞形狀/route 下傳/降級不洩 filter/min_score 截斷/topK 透傳封頂/壞值防呆/
不帶參數行為不變),kbdb vitest 33/33 綠、tsc 0。

關聯 #66 #67。merge 後需 gated redeploy kbdb worker(leo 閘)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUmjwkHLVBHM3ydhT1WSW3
2026-07-19 07:53:46 +00:00

214 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Gitea #66/#67 — /entries/search 兩個檢索缺口的回歸測試。
// #66keyword 路徑 source 參數解析後丟棄(#5.1 只接了 listEntries 那半)→ searchEntries 補
// json_extract 謂詞、route 傳入;含向後相容(不帶 source = SQL 一字不變)。
// #67semantic 固定 topK=20、零分數閾值 → route 曝 top_k/min_score、hit 依 min_score 過濾、
// 回應 entry 附 score;含向後相容(不帶新參數 = 行為不變,僅多 score 資訊)。
// 測試手法同 library-filter.test.tsfake D1 捕 SQL 形狀、mock VECTORIZE 捕 query opts——
// 真 SQL 語意由本機 miniflare 驗(PR 驗收證據)。
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import { searchEntries } from '../src/actions/entry-crud';
import { semanticSearch } from '../src/embed';
import type { Bindings, Entry } from '../src/types';
const SOURCE_PREDICATE = "json_extract(metadata_json, '$.source') = ?";
// ── fake D1:捕捉 prepared SQL 與 bound paramsgetEntrySELECT … WHERE id = ?)回假 entry
// 讓 semantic hydrate 路徑走得完 ──
interface Captured { sql: string; params: unknown[] }
function makeCaptureDB(captured: Captured[]) {
const prepare = (sql: string) => {
const rec: Captured = { sql, params: [] };
captured.push(rec);
const stmt = {
bind(...args: unknown[]) { rec.params = args; return stmt; },
async all<T>() { return { results: [] as T[] }; },
async first<T>() {
if (sql.includes('WHERE id = ?')) return mkEntry(String(rec.params[0])) as unknown as T;
return { total: 0, c: 0 } as unknown as T;
},
async run() { return { success: true }; },
};
return stmt;
};
return { prepare } as unknown as D1Database;
}
function mkEntry(id: string): Entry {
return {
id, content: 'some content', entry_type: 'block', owner_id: 'tenant1', parent_id: null,
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 0, confidence: null, metadata_json: null, created_at: 1, updated_at: 1,
};
}
function makeApp(captured: Captured[], extraEnv: Record<string, unknown> = {}) {
const app = new Hono<{ Bindings: Bindings }>();
app.route('/entries', entryRoutes);
const env = { DB: makeCaptureDB(captured), ENVIRONMENT: 'test', ...extraEnv } as unknown as Bindings;
return { app, env };
}
// ══ #66 source filter ══════════════════════════════════════════════════════
describe('#66 — searchEntries source filterSQL 形狀)', () => {
it('帶 source → LIKEjson_extract($.source) 謂詞+參數(與 listEntries #5.1 同款)', async () => {
const captured: Captured[] = [];
await searchEntries(makeCaptureDB(captured), '遷移', 'tenant1', undefined, undefined, undefined, 'gitea:Leo/kb@main/foo.md');
expect(captured[0].sql).toContain('content LIKE ?');
expect(captured[0].sql).toContain(SOURCE_PREDICATE);
expect(captured[0].params).toContain('gitea:Leo/kb@main/foo.md');
});
it('不帶 source → SQL 無 $.source 謂詞(向後相容:行為一字不變)', async () => {
const captured: Captured[] = [];
await searchEntries(makeCaptureDB(captured), '遷移', 'tenant1');
expect(captured[0].sql).not.toContain('$.source');
});
it('sourcelibrary 併用 → 兩謂詞都在、參數順序對(source 先於 library', async () => {
const captured: Captured[] = [];
await searchEntries(makeCaptureDB(captured), '遷移', undefined, undefined, undefined, ['finance'], 'src-a');
expect(captured[0].sql).toContain(SOURCE_PREDICATE);
expect(captured[0].sql).toContain('$.library');
// params: [%遷移%, 'src-a', 'finance', limit]
expect(captured[0].params[1]).toBe('src-a');
expect(captured[0].params[2]).toBe('finance');
});
});
describe('#66 — route GET /entries/searchkeywordsource 下傳', () => {
it('?q=x&source=… → 謂詞下到 searchEntries(原 bug:解析完即丟)', async () => {
const captured: Captured[] = [];
const { app, env } = makeApp(captured);
const res = await app.request('/entries/search?q=x&source=gitea%3ALeo%2Fkb%40main%2Ffoo.md', {}, env);
expect(res.status).toBe(200);
expect(captured[0].sql).toContain(SOURCE_PREDICATE);
expect(captured[0].params).toContain('gitea:Leo/kb@main/foo.md');
});
it('不帶 source → SQL 無 $.source(向後相容)', async () => {
const captured: Captured[] = [];
const { app, env } = makeApp(captured);
const res = await app.request('/entries/search?q=x', {}, env);
expect(res.status).toBe(200);
expect(captured[0].sql).not.toContain('$.source');
});
it('semantic 模組未開+帶 source → 降級 keyword 仍套 source filter(不因降級洩 source', async () => {
const captured: Captured[] = [];
const { app, env } = makeApp(captured); // 無 VECTORIZE/AI → semanticSearch 回 null
const res = await app.request('/entries/search?q=x&mode=semantic&source=src-a', {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { mode: string };
expect(body.mode).toBe('keyword');
expect(captured[0].sql).toContain(SOURCE_PREDICATE);
expect(captured[0].params).toContain('src-a');
});
});
// ══ #67 top_k / min_score ══════════════════════════════════════════════════
// mock VECTORIZE:捕 query opts、回三筆遞減分數(0.9 / 0.5 / 0.2)供閾值截斷驗證。
function makeSemanticEnv(queryCalls: { opts: Record<string, unknown> }[]) {
return {
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
VECTORIZE: {
async query(_vec: number[], opts: Record<string, unknown>) {
queryCalls.push({ opts });
return {
matches: [
{ id: 'e-high', score: 0.9, metadata: {} },
{ id: 'e-mid', score: 0.5, metadata: {} },
{ id: 'e-low', score: 0.2, metadata: {} },
],
};
},
async upsert(v: unknown[]) { return { count: (v as unknown[]).length }; },
},
};
}
describe('#67 — semanticSearch topK / min_score', () => {
it('不帶新參數 → topK=20、全 matches 回傳(行為與舊版一致)', async () => {
const calls: { opts: Record<string, unknown> }[] = [];
const env = { DB: makeCaptureDB([]), ENVIRONMENT: 'test', ...makeSemanticEnv(calls) } as unknown as Bindings;
const hits = await semanticSearch(env, 'query', {});
expect(calls[0].opts.topK).toBe(20);
expect(hits?.length).toBe(3);
expect(hits?.map((h) => h.score)).toEqual([0.9, 0.5, 0.2]); // score 帶回
});
it('min_score=0.5 → 低分尾截掉(>= 閾值者留)', async () => {
const calls: { opts: Record<string, unknown> }[] = [];
const env = { DB: makeCaptureDB([]), ENVIRONMENT: 'test', ...makeSemanticEnv(calls) } as unknown as Bindings;
const hits = await semanticSearch(env, 'query', { min_score: 0.5 });
expect(hits?.map((h) => h.id)).toEqual(['e-high', 'e-mid']);
});
it('topK 透傳且封頂 100', async () => {
const calls: { opts: Record<string, unknown> }[] = [];
const env = { DB: makeCaptureDB([]), ENVIRONMENT: 'test', ...makeSemanticEnv(calls) } as unknown as Bindings;
await semanticSearch(env, 'query', { topK: 5 });
expect(calls[0].opts.topK).toBe(5);
await semanticSearch(env, 'query', { topK: 500 });
expect(calls[1].opts.topK).toBe(100);
});
});
describe('#67 — route GET /entries/searchsemantictop_k / min_score / score 欄', () => {
function makeSemanticApp(calls: { opts: Record<string, unknown> }[], captured: Captured[] = []) {
return makeApp(captured, makeSemanticEnv(calls));
}
it('?top_k=5&min_score=0.5 → topK 透傳、低分截掉、entry 附 score', async () => {
const calls: { opts: Record<string, unknown> }[] = [];
const { app, env } = makeSemanticApp(calls);
const res = await app.request('/entries/search?q=x&mode=semantic&top_k=5&min_score=0.5', {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { mode: string; count: number; entries: (Entry & { score?: number })[] };
expect(body.mode).toBe('semantic');
expect(calls[0].opts.topK).toBe(5);
expect(body.count).toBe(2); // 0.2 的低分尾被 min_score 截掉
expect(body.entries.map((e) => e.id)).toEqual(['e-high', 'e-mid']);
expect(body.entries.map((e) => e.score)).toEqual([0.9, 0.5]);
});
it('不帶新參數 → topK=20、全量回傳(行為不變),entry 仍附 score(加欄不改形)', async () => {
const calls: { opts: Record<string, unknown> }[] = [];
const { app, env } = makeSemanticApp(calls);
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { count: number; entries: (Entry & { score?: number })[] };
expect(calls[0].opts.topK).toBe(20);
expect(body.count).toBe(3);
expect(body.entries[0].score).toBe(0.9);
// 原有欄位一個不少(回應形狀向後相容)
expect(body.entries[0].id).toBe('e-high');
expect(body.entries[0].entry_type).toBe('block');
});
it('壞值防呆:top_k=abc / top_k=0 / min_score=-1 → 視同沒帶(回預設,不 400)', async () => {
for (const qs of ['top_k=abc', 'top_k=0', 'min_score=-1', 'top_k=abc&min_score=xyz']) {
const calls: { opts: Record<string, unknown> }[] = [];
const { app, env } = makeSemanticApp(calls);
const res = await app.request(`/entries/search?q=x&mode=semantic&${qs}`, {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { count: number };
expect(calls[0].opts.topK).toBe(20);
expect(body.count).toBe(3); // 無閾值 → 全量
}
});
it('keyword 路徑不受 top_k/min_score 影響(參數只作用於 semantic', async () => {
const captured: Captured[] = [];
const { app, env } = makeApp(captured);
const res = await app.request('/entries/search?q=x&top_k=5&min_score=0.9', {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { mode: string };
expect(body.mode).toBe('keyword');
expect(captured[0].sql).toContain('content LIKE ?'); // SQL 形狀不變
});
});