diff --git a/kbdb/src/actions/entry-crud.ts b/kbdb/src/actions/entry-crud.ts index e443d86..473b6ef 100644 --- a/kbdb/src/actions/entry-crud.ts +++ b/kbdb/src/actions/entry-crud.ts @@ -134,12 +134,50 @@ function libraryPredicate(libraries: string[]): string { return `COALESCE(json_extract(metadata_json, '$.library'), 'general') IN (${placeholders})`; } +// daemon-beta t24(總管 0.971 親復現、t11 斷點①②)——下架(rag_takedown_direct)只把 +// metadata_json.status 標成 'deprecated'(軟刪,append-only,見 KBDB 表不變鐵律),從不刪列。 +// 濾層過去只存在 cypher-executor/src/routes/portal-data.ts 的 filterDeprecatedEntries(客端治標, +// Arcrun#46),rag_chat 沒部署的實例(如 leo21c)等於完全沒濾——AI 實際會用到的 MCP/raw +// /entries/search 面直接把已下架內容當現役回傳(semantic 甚至最高分回傳,見 t11 斷點②)。 +// 本謂詞把過濾下沉到 KBDB 服務端(薄殼原則 07:能力只長一次),source-of-truth 修好後 +// portal-data.ts 的客端治標理論上可拔(未在本 PR 動,範圍只限 kbdb/)。 +// +// 用 json_extract 判等(不用 NOT LIKE '%"status":"deprecated"%')——LIKE 對 JSON 序列化格式敏感 +// (key 順序、空白、字串轉義都可能讓子字串比對誤判/漏判,例如 metadata_json 裡若有其他欄位的 +// 值恰好含這段子字串就會被誤殺),json_extract 是結構化取值,只認真正的 $.status 欄位,同一謂詞 +// 家族(source/library)已驗證過這個模式對 SQLite/D1 穩定可靠(issue #5.1、#18 mistake)。 +// NULL(沒有 metadata_json 或沒有 status 欄)視為未下架(保留,不誤殺——大多數既有資料沒有 +// status 欄)。 +const NOT_DEPRECATED_PREDICATE = + "(json_extract(metadata_json, '$.status') IS NULL OR json_extract(metadata_json, '$.status') != 'deprecated')"; + +/** + * JS 側判斷單筆 entry 是否已下架(status==='deprecated')。給 semantic 路徑用——Vectorize + * hit 的 metadata 沒有存 status(見 embed.ts upsert 的 indexed metadata 只有 + * owner_id/entry_type/source/library),要濾必須先 hydrate 回完整 entry 再判斷,故無法像 + * keyword 走 SQL 謂詞,只能在拿到 metadata_json 後用同一套判準(status==='deprecated')在 + * JS 層濾。metadata_json parse 失敗 → 視為保留(治標不誤殺,與 portal-data.ts + * filterDeprecatedEntries 同慣例)。 + */ +export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boolean { + if (!entry.metadata_json) return false; + try { + const meta = JSON.parse(entry.metadata_json) as { status?: unknown } | null; + return !!meta && meta.status === 'deprecated'; + } catch { + return false; + } +} + // D1 LIKE keyword search (base; semantic search is the optional embed module). // entry_type: optional base filter (generic — caller passes any type, base stays type-agnostic). // library: optional 多值庫 filter(portal-auth P1);未帶=行為與舊版一字不變(向後相容)。 // source: metadata_json.$.source filter(issue #66——#5.1 只接了 listEntries 那半,keyword search // 路徑 route 解析完即丟;謂詞與 listEntries 同款 json_extract,不動表)。加在參數尾端, // 既有 positional caller 一個都不用改(向後相容)。 +// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。 +// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。 +// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。 export async function searchEntries( db: D1Database, q: string, @@ -148,6 +186,7 @@ export async function searchEntries( limit = 50, library?: string[], source?: string, + includeDeprecated = false, ): Promise { const conds = ['content LIKE ?']; const params: unknown[] = [`%${q}%`]; @@ -155,6 +194,7 @@ export async function searchEntries( if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); } if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); } if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); } + if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); } const res = await db .prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`) .bind(...params, Math.min(limit, 200)) diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts index b578c7d..57a3661 100644 --- a/kbdb/src/routes/entries.ts +++ b/kbdb/src/routes/entries.ts @@ -8,6 +8,7 @@ import { updateEntry, deleteEntry, searchEntries, + isDeprecatedEntry, } from '../actions/entry-crud'; import { embedEnabled, embedOnWrite, semanticSearch } from '../embed'; @@ -65,6 +66,10 @@ entryRoutes.get('/', async (c) => { // - top_k / min_score(#67,semantic 專用):topK 可調(預設 20、上限 100)+分數閾值 // (預設 0=不過濾)。未帶=行為與舊版一致(向後相容);semantic 回應的 entry 另附 score // 欄讓 caller 自裁(加欄不改形,keyword 路徑不受影響)。 +// - include_deprecated(daemon-beta t24,預設 false):兩 mode 預設都濾掉已下架 +// (metadata_json.status==='deprecated')的 entry——這是本次修的洞(t11 斷點①②,總管 +// 0.971 親復現:下架後 keyword/semantic 都照樣回傳)。傳 `include_deprecated=true` +// 保留給管理面查殘留(驗證下架有沒有真的生效、盤點待清的向量殘留),一般搜尋不帶。 entryRoutes.get('/search', async (c) => { const q = c.req.query('q'); if (!q) return c.json({ success: false, error: 'q required' }, 400); @@ -73,6 +78,7 @@ entryRoutes.get('/search', async (c) => { const entry_type = c.req.query('entry_type') || undefined; const library = parseLibraryParam(c.req.query('library')); const mode = c.req.query('mode') === 'semantic' ? 'semantic' : 'keyword'; + const include_deprecated = c.req.query('include_deprecated') === 'true'; // 數字參數防呆:非數字/非正 → 當沒帶(回預設),不 400——與其他 filter「壞值靜默忽略」一致。 const topKNum = Number(c.req.query('top_k')); const top_k = Number.isFinite(topKNum) && topKNum > 0 ? Math.floor(topKNum) : undefined; @@ -80,12 +86,23 @@ entryRoutes.get('/search', async (c) => { const min_score = Number.isFinite(minScoreNum) && minScoreNum > 0 ? minScoreNum : undefined; if (mode === 'semantic') { + // 補位(daemon-beta t24):Vectorize 的 indexed metadata 沒存 status(見 embed.ts upsert, + // 只有 owner_id/entry_type/source/library),下架與否只能在 hydrate 回完整 entry 後才知道 + // ——換句話說 Vectorize 端沒辦法直接濾掉已下架向量,濾一定發生在 hydrate 之後。 + // 若濾完才截斷到請求的 topK,遇到「這頁命中大半已下架」(t11 ZZ-T10 實測案例:命中 + // 25 顆全下架)就會整頁被吃光、回傳筆數遠低於 caller 要的量。故过濾生效時(非 + // include_deprecated)**先多撈一批再濾再截斷**:單次 Vectorize query 成本不變(同一次 + // query 只是 topK 參數變大,非多一次 subrequest),用查詢端的餘量換掉「整頁被下架品吃光」 + // 的體驗劣化。這是單輪補位(非重試迴圈到湊滿為止)——若下架比例極高仍可能不足額, + // 已在 PR 描述向 leo 說明這個 trade-off(多倍 margin vs 迴圈重撈的取捨)。 + const requestedTopK = top_k ?? 20; // 與 embed.ts semanticSearch 的預設 topK 對齊 + const fetchTopK = include_deprecated ? requestedTopK : Math.min(requestedTopK * 3, 100); const hits = await semanticSearch(c.env, q, { - owner_id, source, entry_type, library, topK: top_k, min_score, + owner_id, source, entry_type, library, topK: fetchTopK, min_score, }); if (hits === null) { // 模組沒開:誠實降級 keyword + 告知「叫 CC 幫你開 vectorize」(不假裝有語義)。 - const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source); + const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated); return c.json({ success: true, entries, @@ -98,7 +115,7 @@ entryRoutes.get('/search', async (c) => { } // hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。 // #67:entry 附 score(相似分數)——加欄不改形,既有 caller 不解析多的欄位不受影響。 - const entries = ( + let entries = ( await Promise.all( hits.map(async (h) => { const e = await getEntry(c.env.DB, h.id); @@ -106,10 +123,15 @@ entryRoutes.get('/search', async (c) => { }), ) ).filter((e): e is NonNullable => e !== null); + if (!include_deprecated) { + entries = entries.filter((e) => !isDeprecatedEntry(e)); + } + // 補位後截斷回 caller 實際要的量(多撈的餘量只用來墊背,不多回傳超過請求的筆數)。 + entries = entries.slice(0, requestedTopK); return c.json({ success: true, entries, count: entries.length, mode: 'semantic' }); } - const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source); + const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated); return c.json({ success: true, entries, count: entries.length, mode: 'keyword' }); }); diff --git a/kbdb/tests/search-deprecated-filter.test.ts b/kbdb/tests/search-deprecated-filter.test.ts new file mode 100644 index 0000000..23bcc55 --- /dev/null +++ b/kbdb/tests/search-deprecated-filter.test.ts @@ -0,0 +1,226 @@ +// daemon-beta t24(總管 0.971 親復現、t11 斷點①②)——/entries/search 服務端濾 deprecated。 +// 背景:rag_takedown_direct 下架只把 metadata_json.status 標 'deprecated'(軟刪,append-only, +// KBDB 表不變鐵律),從不砍列、也不刪 Vectorize 向量。過去唯一的濾層在 +// cypher-executor/src/routes/portal-data.ts(客端治標,Arcrun#46),沒部署 rag_chat 的實例 +// (如 leo21c)等於完全沒濾——MCP/raw /entries/search 直接把已下架內容當現役吐出。semantic +// 甚至最高分照吐(t11 實測 0.971)。本測試覆蓋三案:keyword 濾、semantic 濾+補位、 +// include_deprecated 開關。 +// +// 測試手法同 search-source-and-score.test.ts:fake D1 捕 SQL 形狀 + getEntry 依 id 回可控 +// metadata_json;mock VECTORIZE 捕 query opts(驗補位 topK)並回混合 active/deprecated 命中。 +// 真 SQL 語意(json_extract 對 status 欄的實際判等)由本機 miniflare/wrangler d1 跑驗(PR 驗收證據)。 +import { describe, it, expect } from 'vitest'; +import { Hono } from 'hono'; +import { entryRoutes } from '../src/routes/entries'; +import { searchEntries, isDeprecatedEntry } from '../src/actions/entry-crud'; +import type { Bindings, Entry } from '../src/types'; + +const NOT_DEPRECATED_PREDICATE = + "(json_extract(metadata_json, '$.status') IS NULL OR json_extract(metadata_json, '$.status') != 'deprecated')"; + +// ── fake D1:捕捉 prepared SQL 與 bound params;getEntry(SELECT … WHERE id = ?)依 id 從 +// ENTRY_META 查表回可控 metadata_json,讓 semantic hydrate 路徑能測到 deprecated 過濾 ── +interface Captured { sql: string; params: unknown[] } + +function mkEntry(id: string, metadata_json: string | null): 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, created_at: 1, updated_at: 1, + }; +} + +function makeCaptureDB(captured: Captured[], entryMeta: Record = {}) { + const prepare = (sql: string) => { + const rec: Captured = { sql, params: [] }; + captured.push(rec); + const stmt = { + bind(...args: unknown[]) { rec.params = args; return stmt; }, + async all() { return { results: [] as T[] }; }, + async first() { + if (sql.includes('WHERE id = ?')) { + const id = String(rec.params[0]); + const meta = id in entryMeta ? entryMeta[id] : null; + return mkEntry(id, meta) 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 makeApp(captured: Captured[], extraEnv: Record = {}) { + const app = new Hono<{ Bindings: Bindings }>(); + app.route('/entries', entryRoutes); + const env = { DB: makeCaptureDB(captured, (extraEnv._entryMeta as Record) ?? {}), ENVIRONMENT: 'test', ...extraEnv } as unknown as Bindings; + return { app, env }; +} + +// ══ 案①:keyword 濾 ══════════════════════════════════════════════════════ + +describe('t24 案① — searchEntries(keyword)預設濾 deprecated', () => { + it('預設(不帶 includeDeprecated)→ SQL 含 NOT_DEPRECATED_PREDICATE', async () => { + const captured: Captured[] = []; + await searchEntries(makeCaptureDB(captured), '靛藍', 'tenant1'); + expect(captured[0].sql).toContain(NOT_DEPRECATED_PREDICATE); + }); + + it('includeDeprecated=true → SQL 不含濾 deprecated 謂詞(管理面查殘留用)', async () => { + const captured: Captured[] = []; + await searchEntries(makeCaptureDB(captured), '靛藍', 'tenant1', undefined, undefined, undefined, undefined, true); + expect(captured[0].sql).not.toContain(NOT_DEPRECATED_PREDICATE); + }); + + it('route GET /entries/search(keyword,不帶 include_deprecated)→ 濾謂詞下傳', async () => { + const captured: Captured[] = []; + const { app, env } = makeApp(captured); + const res = await app.request('/entries/search?q=靛藍', {}, env); + expect(res.status).toBe(200); + const body = (await res.json()) as { mode: string }; + expect(body.mode).toBe('keyword'); + expect(captured[0].sql).toContain(NOT_DEPRECATED_PREDICATE); + }); + + it('route GET /entries/search?include_deprecated=true(keyword)→ 濾謂詞不下傳', async () => { + const captured: Captured[] = []; + const { app, env } = makeApp(captured); + const res = await app.request('/entries/search?q=靛藍&include_deprecated=true', {}, env); + expect(res.status).toBe(200); + expect(captured[0].sql).not.toContain(NOT_DEPRECATED_PREDICATE); + }); + + it('semantic 模組未開+降級 keyword → 仍套濾(不因降級洩下架內容)', async () => { + const captured: Captured[] = []; + const { app, env } = makeApp(captured); // 無 VECTORIZE/AI → semanticSearch 回 null + const res = await app.request('/entries/search?q=靛藍&mode=semantic', {}, env); + expect(res.status).toBe(200); + const body = (await res.json()) as { mode: string }; + expect(body.mode).toBe('keyword'); + expect(captured[0].sql).toContain(NOT_DEPRECATED_PREDICATE); + }); + + it('semantic 模組未開+include_deprecated=true 降級 → 濾謂詞不下傳', async () => { + const captured: Captured[] = []; + const { app, env } = makeApp(captured); + const res = await app.request('/entries/search?q=靛藍&mode=semantic&include_deprecated=true', {}, env); + expect(res.status).toBe(200); + expect(captured[0].sql).not.toContain(NOT_DEPRECATED_PREDICATE); + }); +}); + +// ══ isDeprecatedEntry 單元測試(JS 側判準,semantic 路徑用) ══════════════ + +describe('t24 — isDeprecatedEntry(JS 側判準)', () => { + it('status:"deprecated" → true', () => { + expect(isDeprecatedEntry({ metadata_json: JSON.stringify({ status: 'deprecated' }) })).toBe(true); + }); + it('status 缺欄 / null metadata_json / 空字串 → false(未下架,保留)', () => { + expect(isDeprecatedEntry({ metadata_json: JSON.stringify({ embed: true }) })).toBe(false); + expect(isDeprecatedEntry({ metadata_json: null })).toBe(false); + expect(isDeprecatedEntry({ metadata_json: '' })).toBe(false); + }); + it('status 是其他值(非 deprecated)→ false', () => { + expect(isDeprecatedEntry({ metadata_json: JSON.stringify({ status: 'active' }) })).toBe(false); + }); + it('metadata_json parse 失敗(壞 JSON)→ false(治標不誤殺)', () => { + expect(isDeprecatedEntry({ metadata_json: '{not valid json' })).toBe(false); + }); +}); + +// ══ 案②:semantic 濾+補位 ═══════════════════════════════════════════════ + +// mock VECTORIZE:捕 query opts(驗補位 topK);命中組合可控(含 deprecated id 前綴 dep- 供辨識)。 +function makeSemanticEnv( + queryCalls: { opts: Record }[], + matches: { id: string; score: number }[], +) { + return { + AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } }, + VECTORIZE: { + async query(_vec: number[], opts: Record) { + queryCalls.push({ opts }); + return { matches: matches.map((m) => ({ id: m.id, score: m.score, metadata: {} })) }; + }, + async upsert(v: unknown[]) { return { count: (v as unknown[]).length }; }, + }, + }; +} + +describe('t24 案② — semantic 濾 deprecated + 補位(t11 斷點②:0.971 最高分照吐的洞)', () => { + it('命中含已下架(最高分)→ 回應濾掉,只留現役(覆現 t11 0.971 復現案)', async () => { + const calls: { opts: Record }[] = []; + const entryMeta = { + 'dep-highest': JSON.stringify({ status: 'deprecated' }), // 0.971 最高分但已下架 + 'e-active': null, + }; + const captured: Captured[] = []; + const { app, env } = makeApp(captured, { + ...makeSemanticEnv(calls, [ + { id: 'dep-highest', score: 0.971 }, + { id: 'e-active', score: 0.6 }, + ]), + _entryMeta: entryMeta, + }); + const res = await app.request('/entries/search?q=靛藍風鈴石的硬度&mode=semantic', {}, 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(body.entries.map((e) => e.id)).toEqual(['e-active']); // dep-highest 被濾掉 + expect(body.count).toBe(1); + }); + + it('補位:預設過濾生效時,Vectorize 查詢的 topK 大於 caller 要求(避免整頁被下架品吃光)', async () => { + const calls: { opts: Record }[] = []; + const captured: Captured[] = []; + const { app, env } = makeApp(captured, makeSemanticEnv(calls, [])); + await app.request('/entries/search?q=x&mode=semantic&top_k=10', {}, env); + expect(calls[0].opts.topK).toBeGreaterThan(10); // 補位餘量(實作=×3 封頂 100) + expect(calls[0].opts.topK).toBe(30); + }); + + it('補位 topK 封頂 100(不因 top_k 大就超過 Vectorize 上限)', async () => { + const calls: { opts: Record }[] = []; + const captured: Captured[] = []; + const { app, env } = makeApp(captured, makeSemanticEnv(calls, [])); + await app.request('/entries/search?q=x&mode=semantic&top_k=50', {}, env); + expect(calls[0].opts.topK).toBe(100); + }); + + it('補位後截斷:濾掉部分下架品後,回應筆數不超過 caller 要求的 top_k', async () => { + const calls: { opts: Record }[] = []; + // 6 筆命中,3 筆已下架 → 濾完剩 3 筆現役,均少於 top_k=5,應原樣回(不會硬湊出更多) + const matches = [ + { id: 'a1', score: 0.9 }, { id: 'dep1', score: 0.85 }, { id: 'a2', score: 0.8 }, + { id: 'dep2', score: 0.7 }, { id: 'a3', score: 0.6 }, { id: 'dep3', score: 0.5 }, + ]; + const entryMeta: Record = { + dep1: JSON.stringify({ status: 'deprecated' }), + dep2: JSON.stringify({ status: 'deprecated' }), + dep3: JSON.stringify({ status: 'deprecated' }), + }; + const captured: Captured[] = []; + const { app, env } = makeApp(captured, { ...makeSemanticEnv(calls, matches), _entryMeta: entryMeta }); + const res = await app.request('/entries/search?q=x&mode=semantic&top_k=5', {}, env); + const body = (await res.json()) as { entries: Entry[]; count: number }; + expect(body.entries.map((e) => e.id)).toEqual(['a1', 'a2', 'a3']); + expect(body.count).toBe(3); + }); + + it('include_deprecated=true → 不補位(topK=請求值)、不過濾(下架品也回傳,管理面查殘留)', async () => { + const calls: { opts: Record }[] = []; + const entryMeta = { 'dep-highest': JSON.stringify({ status: 'deprecated' }) }; + const captured: Captured[] = []; + const { app, env } = makeApp(captured, { + ...makeSemanticEnv(calls, [{ id: 'dep-highest', score: 0.971 }]), + _entryMeta: entryMeta, + }); + const res = await app.request('/entries/search?q=x&mode=semantic&top_k=10&include_deprecated=true', {}, env); + expect(calls[0].opts.topK).toBe(10); // 不補位 + const body = (await res.json()) as { entries: Entry[]; count: number }; + expect(body.entries.map((e) => e.id)).toEqual(['dep-highest']); // 保留 + expect(body.count).toBe(1); + }); +}); diff --git a/kbdb/tests/search-source-and-score.test.ts b/kbdb/tests/search-source-and-score.test.ts index 4a299f7..e1ae948 100644 --- a/kbdb/tests/search-source-and-score.test.ts +++ b/kbdb/tests/search-source-and-score.test.ts @@ -162,26 +162,32 @@ describe('#67 — route GET /entries/search(semantic)top_k / min_score / sco return makeApp(captured, makeSemanticEnv(calls)); } - it('?top_k=5&min_score=0.5 → topK 透傳、低分截掉、entry 附 score', async () => { + // 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 }[] = []; 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(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('不帶新參數 → topK=20、全量回傳(行為不變),entry 仍附 score(加欄不改形)', async () => { + it('不帶新參數 → Vectorize 補位 topK=60(預設 20×3),回應仍全量回傳(行為不變),entry 仍附 score(加欄不改形)', async () => { const calls: { opts: Record }[] = []; 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(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3 expect(body.count).toBe(3); expect(body.entries[0].score).toBe(0.9); // 原有欄位一個不少(回應形狀向後相容) @@ -189,14 +195,14 @@ describe('#67 — route GET /entries/search(semantic)top_k / min_score / sco expect(body.entries[0].entry_type).toBe('block'); }); - it('壞值防呆:top_k=abc / top_k=0 / min_score=-1 → 視同沒帶(回預設,不 400)', async () => { + 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 }[] = []; 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(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3 expect(body.count).toBe(3); // 無閾值 → 全量 } });