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))
|
||||
|
||||
Reference in New Issue
Block a user