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:
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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=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<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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user