// 語意搜尋「故障要照實說是故障」的回歸測試(2026-08-09 leo 直令)。 // // 事故:portal 曾把「kbdb 缺 VECTORIZE/AI binding(=壞了)」顯示成「語意搜尋還沒開通, // 想開通請匯出診斷檔給我們」——把 bug 美化成沒提供的功能,會製造「幫我開通」的客服工單, // 而真正的故障沒人修。leo 原話:「沒有人會把 bug 美化成沒提供沒開通」。 // // 三條鐵則(本檔全部驗死): // 1. 模組不在(module_off)=故障:文案說「故障/我們的問題/你不用做任何事」, // 禁出現「開通/未啟用/尚未提供」這類把壞說成沒有的字眼,也不要求使用者任何動作。 // 2. 查詢向量化失敗(embed_query_failed)=故障:**不准回空結果集**(舊行為= // 使用者以為自己的庫裡沒有這筆資料)。誠實降級 keyword+帶 degraded_reason。 // 3. 索引與資料不同步(孤兒向量/下架殘影)→ 搜尋順手自癒(背景刪向量),不留給用戶撞。 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: 't1', 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', embed: true }) : JSON.stringify({ embed: true }), created_at: 1, updated_at: 1, }; } // fake D1:紀錄所有 prepare 過的 SQL(驗自癒有沒有真的動手);COUNT 依 SQL 內容回 // embedded/pending 兩種計數;getEntry(WHERE id = ?)回可配置 entry。 function makeFakeDB(opts: { embeddedCount?: number; pendingCount?: number; hydrate?: Record; pendingRows?: Entry[]; } = {}) { const sqls: string[] = []; const prepare = (sql: string) => { sqls.push(sql); let bound: unknown[] = []; const stmt = { bind(...args: unknown[]) { bound = args; return stmt; }, async first() { if (sql.includes('WHERE id = ?')) { const id = String(bound[0]); return ((opts.hydrate ?? {})[id] ?? null) as unknown as T; } if (sql.includes('COUNT(*)')) { // backfillStatus:pending 用 BACKFILL_PREDICATE(含 is_embedded = 0),embedded 用 is_embedded = 1 if (sql.includes('is_embedded = 0')) return { c: opts.pendingCount ?? 0 } as unknown as T; return { c: opts.embeddedCount ?? 0 } as unknown as T; } return null as unknown as T; }, async all() { // backfillEmbeddings 的候選 SELECT(含 is_embedded = 0) if (sql.includes('is_embedded = 0') && sql.includes('SELECT *')) { return { results: (opts.pendingRows ?? []) as unknown as T[] }; } return { results: [] as T[] }; }, async run() { return { success: true }; }, }; return stmt; }; return { db: { prepare } as unknown as D1Database, sqls }; } function makeApp() { const app = new Hono<{ Bindings: Bindings }>(); app.route('/entries', entryRoutes); return app; } /** 收集 waitUntil 的 promise,測試結尾 await 全部,讓背景自癒動作跑完再斷言。 */ function makeCtx() { const tasks: Promise[] = []; return { ctx: { waitUntil: (p: Promise) => { tasks.push(p); }, passThroughOnException() {}, props: {} } as unknown as ExecutionContext, flush: async () => { await Promise.allSettled(tasks); return tasks.length; }, }; } const NO_BLAME_USER = (hint: string) => { // 禁把故障說成「沒提供/沒開通」;禁要求使用者做「申請開通」類動作 expect(/開通|尚未啟用|未啟用|還沒啟用|尚未提供|沒有提供/.test(hint)).toBe(false); expect(/請聯絡我們(開通|啟用)|匯出診斷/.test(hint)).toBe(false); // 必須講明是系統端的問題、使用者不用動作 expect(/我們(系統)?的問題|系統的問題/.test(hint)).toBe(true); }; describe('mode=semantic 但 embed 模組不在(module_off)——故障,不是「沒開通」', () => { it('誠實降級 keyword+degraded_reason=module_off,文案照實說故障、不叫使用者做事', async () => { const app = makeApp(); const { db } = makeFakeDB(); const env = { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings; // 無 AI/VECTORIZE const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env); expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body.mode).toBe('keyword'); expect(body.requested_mode).toBe('semantic'); expect(body.degraded_reason).toBe('module_off'); const hint = body.capability_hint as string; expect(hint).toContain('故障'); NO_BLAME_USER(hint); // admin_hint 保留技術細節給維運者 expect(String(body.admin_hint)).toMatch(/VECTORIZE|binding/); }); }); describe('查詢向量化失敗(embed_query_failed)——不准偽裝成「查無資料」', () => { it('AI.run 丟錯(額度用完)→ 200 誠實降級 keyword,不回空語意結果', async () => { const app = makeApp(); const { db } = makeFakeDB(); const env = { DB: db, ENVIRONMENT: 'test', AI: { async run() { throw new Error('3040: daily limit exceeded'); } }, VECTORIZE: { async query() { throw new Error('不應該走到 Vectorize'); } }, } as unknown as Bindings; const res = await app.request('/entries/search?q=閉環機&mode=semantic&owner_id=t1', {}, env); expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body.mode).toBe('keyword'); expect(body.requested_mode).toBe('semantic'); expect(body.degraded_reason).toBe('embed_query_failed'); const hint = body.capability_hint as string; expect(hint).toContain('故障'); NO_BLAME_USER(hint); expect(String(body.admin_hint)).toContain('daily limit exceeded'); }); it('AI.run 回不出向量(形狀異常)→ 同樣走誠實降級,不是空結果', async () => { const app = makeApp(); const { db } = makeFakeDB(); const env = { DB: db, ENVIRONMENT: 'test', AI: { async run() { return {}; } }, // 沒有 data VECTORIZE: { async query() { return { matches: [] }; } }, } as unknown as Bindings; const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env); const body = (await res.json()) as Record; expect(body.degraded_reason).toBe('embed_query_failed'); expect(body.mode).toBe('keyword'); }); }); describe('索引與資料不同步 → 搜尋順手自癒(不留給下一個用戶撞)', () => { it('孤兒向量+下架殘影:背景 deleteByIds 兩顆、殘影 is_embedded 歸零', async () => { const app = makeApp(); const deleted: string[][] = []; const { db, sqls } = makeFakeDB({ embeddedCount: 5, hydrate: { live1: mkEntry('live1'), dep1: mkEntry('dep1', { deprecated: true }), gone1: null }, }); const env = { DB: db, ENVIRONMENT: 'test', AI: { async run() { return { data: [[0.1, 0.2]] }; } }, VECTORIZE: { async query() { return { matches: [ { id: 'live1', score: 0.9 }, { id: 'dep1', score: 0.8 }, { id: 'gone1', score: 0.7 } ] }; }, async deleteByIds(ids: string[]) { deleted.push(ids); }, }, } as unknown as Bindings; const { ctx, flush } = makeCtx(); const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env, ctx); const body = (await res.json()) as Record; expect(body.count).toBe(1); // 正常結果不受自癒影響 await flush(); expect(deleted.flat().sort()).toEqual(['dep1', 'gone1']); // 殘影(dep1)另外把 is_embedded 歸零,讓 D1 與 Vectorize 不說兩套話 expect(sqls.some((s) => s.includes('SET is_embedded = 0'))).toBe(true); }); it('no_index 且 pending>0(資料在、索引從沒建成=故障)→ 文案不怪用戶+背景觸發 backfill', async () => { const app = makeApp(); let aiCalls = 0; const { db } = makeFakeDB({ embeddedCount: 0, pendingCount: 3, pendingRows: [mkEntry('p1')] }); const env = { DB: db, ENVIRONMENT: 'test', AI: { async run() { aiCalls++; return { data: [[0.1, 0.2]] }; } }, VECTORIZE: { async query() { return { matches: [] }; }, async upsert() {}, async deleteByIds() {} }, } as unknown as Bindings; const { ctx, flush } = makeCtx(); const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env, ctx); const body = (await res.json()) as Record; expect(body.empty_reason).toBe('no_index'); const hint = body.capability_hint as string; NO_BLAME_USER(hint); await flush(); // backfill 有真的跑(查詢那次 + 補嵌那批 ≥ 2 次 AI.run) expect(aiCalls).toBeGreaterThanOrEqual(2); }); it('no_index 且 pending=0(庫真的還沒內容)→ 誠實說還沒有資料,不謊稱故障', async () => { const app = makeApp(); const { db } = makeFakeDB({ embeddedCount: 0, pendingCount: 0 }); const env = { DB: db, ENVIRONMENT: 'test', AI: { async run() { return { data: [[0.1, 0.2]] }; } }, VECTORIZE: { async query() { return { matches: [] }; } }, } as unknown as Bindings; const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env); const body = (await res.json()) as Record; expect(body.empty_reason).toBe('no_index'); expect(String(body.capability_hint)).toContain('還沒有'); expect(/故障/.test(String(body.capability_hint))).toBe(false); }); });