diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts index ec4aa5e..357c0c3 100644 --- a/kbdb/src/routes/entries.ts +++ b/kbdb/src/routes/entries.ts @@ -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 由呼叫端既有的 fallback(cypher-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 LIKE(base,永遠可用)。 -// - 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_type:base 通用 filter(caller 傳任意 type,如 workflow;base 不寫死語意,workflow-discovery Q4)。 // - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extract+NULL→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: '語義查詢需先開 vectorize(embed 模組)。叫 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===0(Vectorize 端零命中,含 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' }); } diff --git a/kbdb/tests/search-semantic-empty-reason.test.ts b/kbdb/tests/search-semantic-empty-reason.test.ts new file mode 100644 index 0000000..ee2dd62 --- /dev/null +++ b/kbdb/tests/search-semantic-empty-reason.test.ts @@ -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 D1:COUNT 查詢回傳可配置的 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() { + if (sql.includes('WHERE id = ?')) { + return (opts.hydrateEntry ?? null) as unknown as T; + } + return { c: embeddedCount } as unknown as T; + }, + async all() { 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[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; + 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; + 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=0,hits.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; + 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; + expect(body.mode).toBe('semantic'); + expect(body.count).toBe(1); + expect(body.empty_reason).toBeUndefined(); + expect(body.capability_hint).toBeUndefined(); + }); +});