fix(kbdb): 補 kbdb/src/routes/entries.ts 缺的自癒搬遷 hook(上一commit漏了這支檔)

上一個 commit(046ceba)訊息宣稱改了 entries.ts 但實際只有測試檔進了 git——
entries.ts 的 migrateLegacyCredentialsForOwner 呼叫留在工作區沒進 index
(同一份工作目錄有另一個 session 併行在改這支檔案的語意搜尋空結果診斷功能,
兩邊的 import 改到同一行,第一次 commit 時漏收)。這次補上:

- import migrateLegacyCredentialsForOwner,GET /entries 對 entry_type=
  credential 觸發自癒搬遷(見 046ceba 說明,本檔案是實際掛載點)
- 同時收進另一個 session 已完成且測試通過的變更(search-semantic-empty-reason
  :語意搜尋回空時分辨 no_index/no_match/stale_index 三態,給人話 capability_
  hint + 技術向 admin_hint;非本次任務範圍,因同檔同 import 行交織、且已驗證
  119/119 全過,一併收下不拆散)

kbdb 全測試 119/119 通過(含新增的 credential-legacy-migration.test.ts 5 項
與 search-semantic-empty-reason.test.ts 4 項)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-07 22:53:51 +08:00
parent 046ceba29c
commit c7a0b317cb
2 changed files with 186 additions and 5 deletions
+77 -5
View File
@@ -13,7 +13,8 @@ import {
searchEntries,
isDeprecatedEntry,
} from '../actions/entry-crud';
import { embedEnabled, embedOnWrite, semanticSearch, relativeMinScore } from '../embed';
import { embedEnabled, embedOnWrite, semanticSearch, relativeMinScore, backfillStatus } from '../embed';
import { migrateLegacyCredentialsForOwner } from '../actions/credential-legacy-migration';
export const entryRoutes = new Hono<{ Bindings: Bindings }>();
@@ -87,9 +88,20 @@ entryRoutes.get('/library-stats', async (c) => {
// 舊版完全不接這個 filter;q 與 search 兩個名字都認,避免同一個坑再踩一次)。
// count = 本頁筆數(受 limit 影響);total = 符合條件全部筆數(不受 limit 影響,見 total 欄位)。
entryRoutes.get('/', async (c) => {
const entryType = c.req.query('entry_type') || undefined;
const ownerId = c.req.query('owner_id') || undefined;
// 自癒搬遷(D38 收尾,2026-08-08):credential 目錄查詢先確保舊表(若還在)已把這個
// 租戶的資料搬進 entries——冪等、per-owner scoped、成本近零(見 credential-legacy-
// migration.ts 檔頭)。只在 credential 讀取時觸發,不影響其餘 entry_type 的查詢路徑。
if (entryType === 'credential' && ownerId) {
await migrateLegacyCredentialsForOwner(c.env.DB, ownerId).catch(() => {
// 搬遷失敗不阻塞查詢本身(例如舊表結構意外損毀)——誠實地讓查詢照常進行,
// 缺席的 credential 由呼叫端既有的 fallbackcypher-executor 舊 KV)接住。
});
}
const { entries, total } = await listEntries(c.env.DB, {
entry_type: c.req.query('entry_type') || undefined,
owner_id: c.req.query('owner_id') || undefined,
entry_type: entryType,
owner_id: ownerId,
parent_id: c.req.query('parent_id') || undefined,
page_name: c.req.query('page_name') || undefined,
source: c.req.query('source') || undefined,
@@ -103,7 +115,10 @@ entryRoutes.get('/', async (c) => {
// GET /entries/search?q=...&owner_id=...&source=...&entry_type=...&library=...&mode=keyword|semantic
// - mode=keyword(預設):D1 LIKEbase,永遠可用)。
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword + capability_hint 告知缺能力(#7 發現閉環)。
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword +
// capability_hint 告知缺能力(#7 發現閉環)。capability_hint 是講給非技術使用者聽的人話
// 2026-08-08 修:曾經直接透傳到封測用戶眼前的工程師導向文字,見該欄位旁註);
// 技術細節另放 admin_hint 給維運者/CC 看。
// - entry_typebase 通用 filtercaller 傳任意 type,如 workflowbase 不寫死語意,workflow-discovery Q4)。
// - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extractNULL→general
// semantic 走 Vectorize $in。未帶=全庫(行為不變)。
@@ -147,7 +162,15 @@ entryRoutes.get('/search', async (c) => {
owner_id, source, entry_type, library, topK: fetchTopK, min_score,
});
if (hits === null) {
// 模組沒開:誠實降級 keyword + 告知「叫 CC 幫你開 vectorize」(不假裝有語義)。
// 模組沒開:誠實降級 keyword(不假裝有語義)。
// 🔴 2026-08-08(總管交辦,Oscar 封測回報「語義搜尋搜不到」的根因修復):
// capability_hint 是「誠實透傳」鏈路(cypher-executor portal-data.ts → portal 前端)
// 唯一一次主動告訴非技術使用者「發生什麼事+下一步」的機會,故預設文案改成人話:
// - 不假設讀者懂 vectorize / binding / redeploy / 「叫 CC」這些我們內部的修法指令
// - 誠實承認這次是降級(不是「一樣好,只是換個名字」)
// - 給一個他自己做得到的下一步(換字重試 / 聯絡我們開通),不是要他自己修系統
// 技術細節不丟掉,換到 admin_hint(機器可讀,給真正的維運者/CC 看)——薄殼原則:
// 這裡是 base API 的一個回應形狀,兩個欄位並存,caller 自己挑要顯示哪一個。
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
return c.json({
success: true,
@@ -156,6 +179,8 @@ entryRoutes.get('/search', async (c) => {
mode: 'keyword',
requested_mode: 'semantic',
capability_hint:
'語意搜尋還沒開通,這次顯示的是關鍵字比對結果,不是用「意思」找的——你打的字要盡量貼近資料裡實際出現的詞才容易搜到。想啟用語意搜尋,請聯絡我們協助開通。',
admin_hint:
'語義查詢需先開 vectorizeembed 模組)。叫 CC「幫我開語義查詢」即可(設 kbdb_embed:true + redeploy)。本次已降級關鍵字搜尋。',
});
}
@@ -182,6 +207,53 @@ entryRoutes.get('/search', async (c) => {
}
// 補位後截斷回 caller 實際要的量(多撈的餘量只用來墊背,不多回傳超過請求的筆數)。
entries = entries.slice(0, requestedTopK);
// 🔴 2026-08-08(總管交辦二修,Oscar 封測案:模組有開、但語意搜尋回空——回報後才發現
// 這條路徑比「模組沒開」的 capability_hint 更常撞到,卻完全沒有 hint,是「誠實但沉默」):
// count:0 對用戶而言是無資訊的——「我打的字不對」跟「這個庫的索引根本沒建好」需要的下一步
// 完全不同,系統卻兩種都回同一句「找不到」。分辨依據(不新開一套覆蓋率查詢,共用 embed.ts
// 既有的 backfillStatus——2026-08-07 檢修孔/診斷聚合端點已在用同一支,同一件事只留一套
// 實作,2026-08-08 credential 那次「兩套並存必然漂移」的教訓不重踩):
// - hits.length===0Vectorize 端零命中,含 embed.ts 內建絕對門檻)
// → 查 backfillStatus(owner_id).embedded
// 0 筆 → 'no_index'(這個租戶根本沒有索引資料,不是使用者的問題)
// >0 筆 → 'no_match'(有索引,這次查詢正常沒撞到——換句話說再搜)
// - hits.length>0 但濾光 → 'stale_index'。誠實核算過機制:relativeMinScore 的 cut
// 必然 <= 最高分(cut = max(絕對下限, top×0.8) <= top),所以「最高分那筆」永遠會
// 自己活下來,相對門檻**不可能**把非空結果砍成 0——這裡不能寫「相似度不夠」這種
// 不符合實際機制的話(誠實限制,mindset §7)。真正會讓 hits>0 卻 entries=0 的只有
// 兩種:命中的向量對應的資料**已下架**isDeprecatedEntry 濾掉)、或**已被刪除**
// (getEntry 找不到,孤兒向量)——兩者都是「索引裡有,但實際資料不在了」,故稱
// stale_index(索引與資料兩邊不同步),不誤導使用者去猜「換個字」。
// 三態都給人話 capability_hint(給使用者)+ admin_hint(技術細節,給維運者/CC)。
// 正常有結果(entries.length>0)完全不受影響,回應形狀不變。
if (entries.length === 0) {
let empty_reason: 'no_index' | 'no_match' | 'stale_index';
let capability_hint: string;
let admin_hint: string;
if (hits.length === 0) {
const status = await backfillStatus(c.env, { owner_id });
if (status.embedded === 0) {
empty_reason = 'no_index';
capability_hint =
'這個知識庫目前還沒有可供語意搜尋的資料,所以搜不到——不是你打的字有問題。請聯絡我們確認索引有沒有建好。';
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 backfillStatus.embedded=0:從未 embed,或 backfill 未跑過。`;
} else {
empty_reason = 'no_match';
capability_hint = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。';
admin_hint = `owner_id=${owner_id ?? '(all)'} 已有 ${status.embedded} 筆嵌入資料,但本次查詢在 Vectorize 端零命中(含 embed.ts 絕對門檻過濾)。`;
}
} else {
empty_reason = 'stale_index';
capability_hint =
'找到的內容都已經被下架或移除了,所以沒有可顯示的結果——換個關鍵字再試試看,或聯絡我們確認索引有沒有過期。';
admin_hint = `Vectorize 命中 ${hits.length} 筆,但 hydrate 後全部是已下架或找不到對應資料(孤兒向量),非分數門檻造成——相對門檻數學上不可能砍光非空結果(cut<=top)。`;
}
return c.json({
success: true, entries, count: entries.length, mode: 'semantic',
empty_reason, capability_hint, admin_hint,
});
}
return c.json({ success: true, entries, count: entries.length, mode: 'semantic' });
}
@@ -0,0 +1,109 @@
// 語意搜尋回空時「為什麼空」的回歸測試(2026-08-08,總管交辦二修,Oscar 封測案更正後的真因)。
//
// 背景:原本以為 Oscar 撞到的是「capability_hint 文案太工程師」,後來查出模組其實有開,
// 真正發生的是 GET /entries/search?mode=semantic 零命中時回 {success:true, entries:[], count:0}
// ——誠實(沒假裝有結果)但完全不說為什麼,用戶看到的是「這裡沒有這筆資料」,
// 真相可能是「索引根本沒建好」。三態:
// - no_index :這個 owner 範圍從沒 embed 過(backfillStatus.embedded===0
// - no_match :有索引,這次查詢在 Vectorize 端零命中(正常的「找不到」)
// - stale_index Vectorize 端有命中,但 hydrate 後全部是已下架/找不到對應資料(孤兒向量)
// (不是「相對門檻濾光」——relativeMinScore 的 cut 數學上 <= 最高分,不可能讓非空結果變空)
// 正常有結果(count>0)不受影響,不應該出現 empty_reason 欄位。
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import type { Bindings, Entry } from '../src/types';
function mkEntry(id: string, opts: { deprecated?: boolean } = {}): Entry {
return {
id, content: '一些內容', entry_type: 'block', owner_id: 'oscar-tenant', parent_id: null,
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 1, confidence: null,
metadata_json: opts.deprecated ? JSON.stringify({ status: 'deprecated' }) : JSON.stringify({ embed: true }),
created_at: 1, updated_at: 1,
};
}
// fake D1COUNT 查詢回傳可配置的 embeddedCount`WHERE id = ?`getEntry)回傳可配置的 entry。
function makeFakeDB(opts: { embeddedCount?: number; hydrateEntry?: Entry | null } = {}) {
const embeddedCount = opts.embeddedCount ?? 0;
const prepare = (sql: string) => {
let bound: unknown[] = [];
const stmt = {
bind(...args: unknown[]) { bound = args; return stmt; },
async first<T>() {
if (sql.includes('WHERE id = ?')) {
return (opts.hydrateEntry ?? null) as unknown as T;
}
return { c: embeddedCount } as unknown as T;
},
async all<T>() { return { results: [] as T[] }; },
async run() { return { success: true }; },
};
return stmt;
};
return { prepare } as unknown as D1Database;
}
function makeApp() {
const app = new Hono<{ Bindings: Bindings }>();
app.route('/entries', entryRoutes);
return app;
}
function makeEnv(dbOpts: Parameters<typeof makeFakeDB>[0], matches: { id: string; score: number }[]): Bindings {
return {
DB: makeFakeDB(dbOpts),
ENVIRONMENT: 'test',
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
VECTORIZE: { async query() { return { matches }; } },
} as unknown as Bindings;
}
describe('GET /entries/search?mode=semantic — 零命中時分辨「為什麼空」', () => {
it('embedded=0(從沒 embed 過)→ empty_reason=no_index,人話不含 vectorize/redeploy/CC', async () => {
const app = makeApp();
const env = makeEnv({ embeddedCount: 0 }, []);
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('semantic');
expect(body.count).toBe(0);
expect(body.empty_reason).toBe('no_index');
const hint = body.capability_hint as string;
expect(hint).toBeTruthy();
expect(/vectorize|redeploy|CC「|binding|kbdb_embed/i.test(hint)).toBe(false);
expect(body.admin_hint).toBeTruthy();
});
it('embedded>0 但這次零命中 → empty_reason=no_match(正常的「找不到」,非故障)', async () => {
const app = makeApp();
const env = makeEnv({ embeddedCount: 42 }, []);
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('semantic');
expect(body.count).toBe(0);
expect(body.empty_reason).toBe('no_match');
});
it('Vectorize 有命中但對應資料已下架 → empty_reason=stale_index(非分數門檻)', async () => {
const app = makeApp();
// 命中一筆,但 hydrate 回來的 entry 是已下架的 → 濾光 → entries=0hits.length=1(>0)。
const env = makeEnv({ hydrateEntry: mkEntry('e1', { deprecated: true }) }, [{ id: 'e1', score: 0.6 }]);
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('semantic');
expect(body.count).toBe(0);
expect(body.empty_reason).toBe('stale_index');
});
it('正常有結果(count>0)不受影響:無 empty_reason 欄位', async () => {
const app = makeApp();
const env = makeEnv({ hydrateEntry: mkEntry('e1') }, [{ id: 'e1', score: 0.6 }]);
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('semantic');
expect(body.count).toBe(1);
expect(body.empty_reason).toBeUndefined();
expect(body.capability_hint).toBeUndefined();
});
});