Files
Arcrun/kbdb/tests/search-semantic-degraded.test.ts
T
uncle6me-web 6846d6ddae fix(semantic): 故障照實說是故障——不再把壞掉說成「沒開通」(leo 2026-08-09 直令)
一、文案(portal/console/kbdb hint):語意搜尋是一安裝就提供的功能,
   降級=故障。橫幅改「語意搜尋目前故障/我們的問題/你不用做任何事」,
   拿掉「還沒開通、想開通請匯出診斷檔」這種要使用者申請開通的假框架。
   kbdb 降級回應加 degraded_reason(module_off / embed_query_failed)。

二、查詢向量化失敗不再偽裝成空結果(leo 點名的謊):
   semanticSearch 舊行為「AI 額度用完 → 回 []」會讓使用者以為
   自己的知識庫裡沒有這筆資料。改丟 EmbedQueryFailedError,
   route 誠實降級 keyword+照實告知是暫時故障。

三、源頭機制(裝好的實例為什麼會失去語意搜尋):
   - acr update:kbdb_embed 判斷 ===true → !==false。config 缺欄位時
     redeploy 會把 [[vectorize]]+[ai] binding 靜默剝掉(wrangler deploy
     整份覆蓋),一台正常實例就此壞掉。init 預設同步翻成 [Y/n]。
   -(另 repo)deploy-all.mjs ensureVectorizeIndex 失敗改致命中止。

四、順手自癒:孤兒向量/下架殘影搜尋時背景清除;空結果且 pending>0
   背景 backfill;no_index 拆「故障」vs「還沒有資料」兩態。

測試:kbdb 146/146(新增 degraded 6 案+selftest 1 案);cli 10/10;
瀏覽器端到端兩種故障畫面實測(local wrangler dev+portal 真登入)。
無 SDD 對應:leo 直令修故障(同 08-07 檢修孔前例的人閘直接授權路徑)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 02:05:12 +08:00

209 lines
9.8 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-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 兩種計數;getEntryWHERE id = ?)回可配置 entry。
function makeFakeDB(opts: {
embeddedCount?: number;
pendingCount?: number;
hydrate?: Record<string, Entry | null>;
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<T>() {
if (sql.includes('WHERE id = ?')) {
const id = String(bound[0]);
return ((opts.hydrate ?? {})[id] ?? null) as unknown as T;
}
if (sql.includes('COUNT(*)')) {
// backfillStatuspending 用 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<T>() {
// 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<unknown>[] = [];
return {
ctx: { waitUntil: (p: Promise<unknown>) => { 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('誠實降級 keyworddegraded_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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(body.empty_reason).toBe('no_index');
expect(String(body.capability_hint)).toContain('還沒有');
expect(/故障/.test(String(body.capability_hint))).toBe(false);
});
});