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:
@@ -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