7e3ca4c1a1
This reverts commit af3edff856.
110 lines
5.3 KiB
TypeScript
110 lines
5.3 KiB
TypeScript
// 語意搜尋回空時「為什麼空」的回歸測試(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();
|
||
});
|
||
});
|