fix(kbdb): /entries/search 服務端濾 deprecated(keyword+semantic),修下架不生效
daemon-beta t24(t11 斷點①②,總管 0.971 親復現):rag_takedown_direct 下架只把 metadata_json.status 標 deprecated(軟刪,append-only),但 /entries/search 兩 mode 都不濾,已下架內容照樣回傳——semantic 甚至最高分照吐。過去唯一的濾層只在 cypher-executor/portal-data.ts 客端(Arcrun#46),沒部署 rag_chat 的實例等於完全沒濾。 - entry-crud.ts:新增 json_extract($.status) NOT_DEPRECATED_PREDICATE(同 source/library 既有 json_extract 模式,issue #5.1/#18;不用 LIKE 避免 JSON 序列化格式誤判); searchEntries 新增 includeDeprecated 參數(尾端新增,向後相容)。 新增 isDeprecatedEntry:semantic 路徑用(Vectorize metadata 沒存 status,只能 hydrate 回完整 entry 後 JS 側判斷)。 - entries.ts:/entries/search 新增 include_deprecated 開關(預設 false,濾掉;true 給 管理面查殘留)。semantic 分支補位——過濾生效時先以 topK×3(封頂 100)向 Vectorize 多撈,hydrate+濾完再截斷回 caller 要求的量,避免命中大半下架時整頁被吃光 (t11 ZZ-T10 實測案例)。 - Vectorize 向量殘留本 PR 不動(只做查詢時過濾,not 同步刪向量)——取捨見 PR 描述。 測試:新增 search-deprecated-filter.test.ts 15 案(keyword 濾/include_deprecated 開關/ semantic 濾+補位+封頂+截斷/isDeprecatedEntry 單元);同步更新 search-source-and-score.test.ts 3 案的 topK 期望值(route 補位改變送進 Vectorize 的實際 topK,回應對外契約不變)。 kbdb 全套 60/60 綠、tsc --noEmit 乾淨。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Entry[]> {
|
||||
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))
|
||||
|
||||
@@ -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<typeof e> => 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' });
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string | null> = {}) {
|
||||
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 = ?')) {
|
||||
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<string, unknown> = {}) {
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
app.route('/entries', entryRoutes);
|
||||
const env = { DB: makeCaptureDB(captured, (extraEnv._entryMeta as Record<string, string | null>) ?? {}), 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<string, unknown> }[],
|
||||
matches: { id: string; score: number }[],
|
||||
) {
|
||||
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: 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<string, unknown> }[] = [];
|
||||
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<string, unknown> }[] = [];
|
||||
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<string, unknown> }[] = [];
|
||||
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<string, unknown> }[] = [];
|
||||
// 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<string, string | null> = {
|
||||
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<string, unknown> }[] = [];
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<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(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<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(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<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(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3
|
||||
expect(body.count).toBe(3); // 無閾值 → 全量
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user