Files
Arcrun/kbdb/tests/search-semantic-empty-reason.test.ts
T
uncle6me-web c7a0b317cb 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>
2026-08-07 22:53:51 +08:00

110 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 語意搜尋回空時「為什麼空」的回歸測試(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();
});
});