ceb7638d74
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。 模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」 - 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數 (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+ 每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列 (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。 - record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留, 驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。 - entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry 接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。 - 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列, LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀), GET /maintenance/relation-orphans 唯讀巡檢。 - cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS, 整檔送 /query 會在重跑時假紅)。 - 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描; 釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。 遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型 與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
12 KiB
TypeScript
225 lines
12 KiB
TypeScript
// Gitea #66/#67 — /entries/search 兩個檢索缺口的回歸測試。
|
||
// #66:keyword 路徑 source 參數解析後丟棄(#5.1 只接了 listEntries 那半)→ searchEntries 補
|
||
// json_extract 謂詞、route 傳入;含向後相容(不帶 source = SQL 一字不變)。
|
||
// #67:semantic 固定 topK=20、零分數閾值 → route 曝 top_k/min_score、hit 依 min_score 過濾、
|
||
// 回應 entry 附 score;含向後相容(不帶新參數 = 行為不變,僅多 score 資訊)。
|
||
// 測試手法同 library-filter.test.ts:fake 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 params;getEntry(SELECT … 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, src_id: null, rel_id: null, dst_id: 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 filter(SQL 形狀)', () => {
|
||
it('帶 source → LIKE+json_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('source+library 併用 → 兩謂詞都在、參數順序對(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/search(keyword)source 下傳', () => {
|
||
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', () => {
|
||
// 🔴 2026-08-05:預設 min_score 由 0(不過濾)改為 DEFAULT_MIN_SCORE(跟著 embed 模型走)。
|
||
// 原因=閾值原本硬寫在 portal 呼叫端,換 bge-m3 後沒人回頭改 ⇒ 語義搜尋全 0 命中。
|
||
// 測資分數 0.9 / 0.5 / 0.2:預設閾值 0.5 ⇒ 只有 0.2 的低分尾被砍。
|
||
it('不帶 min_score → topK=20、套用預設閾值(低分尾 0.2 被砍)', 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?.map((h) => h.id)).toEqual(['e-high', 'e-mid']);
|
||
expect(hits?.map((h) => h.score)).toEqual([0.9, 0.5]); // 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/search(semantic)top_k / min_score / score 欄', () => {
|
||
function makeSemanticApp(calls: { opts: Record<string, unknown> }[], captured: Captured[] = []) {
|
||
return makeApp(captured, makeSemanticEnv(calls));
|
||
}
|
||
|
||
// daemon-beta t24(07-24 補位變更):route 現在對 Vectorize 的實際查詢 topK 會做「補位」
|
||
// (預設過濾 deprecated 時 ×3 封頂 100,見 entries.ts 補位註解),不再是 top_k 原封透傳到
|
||
// VECTORIZE.query。route 對 caller 的回應仍會照 top_k 截斷(見 body.count/entries 斷言不變)
|
||
// ——這裡改的只是「送進 Vectorize 那次呼叫的 topK 參數」,非對外契約。三筆測試同步更新
|
||
// calls[0].opts.topK 期望值(5→15=5×3、20→60=20×3),其餘斷言(回應筆數/內容/score)不動。
|
||
|
||
it('?top_k=5&min_score=0.5 → Vectorize 補位 topK=15(5×3)、回應仍照 top_k 截後低分尾、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(15); // t24 補位:5 × 3
|
||
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('不帶新參數 → Vectorize 補位 topK=60(預設 20×3),套用相對門檻後只回最高分那筆,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(60); // t24 補位:預設 20 × 3
|
||
// 08-05:未帶 min_score ⇒ 相對門檻 max(0.45, 0.9×0.8)=0.72 ⇒ 只有 0.9 留下
|
||
expect(body.count).toBe(1);
|
||
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 → 視同沒帶(回預設 20,補位後 Vectorize topK=60,不 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(60); // t24 補位:預設 20 × 3
|
||
// 壞值=視同沒帶 ⇒ 落回相對門檻 max(0.45, 0.9×0.8)=0.72 ⇒ 只留最高分那筆。
|
||
expect(body.count).toBe(1);
|
||
}
|
||
});
|
||
|
||
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 形狀不變
|
||
});
|
||
});
|