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>
This commit is contained in:
uncle6me-web
2026-08-09 02:05:12 +08:00
parent 8eb10049b8
commit 6846d6ddae
10 changed files with 425 additions and 49 deletions
+15
View File
@@ -89,6 +89,21 @@ describe('embedSelfTest(檢修孔:卡片自我查詢,驗證 index 真的
expect(r.passed).toBe(false);
});
it('向量化本身失敗(AI 額度用完)→ tested:falsenote 說明故障,不 throw 也不假 passed', async () => {
const store = [mkEntry('e1', '取樣內容', 'o1')];
const env = {
DB: makeFakeDB(store),
ENVIRONMENT: 'test',
AI: { async run() { throw new Error('3040: daily limit'); } },
VECTORIZE: { async query() { return { matches: [] }; } },
} as unknown as Bindings;
const r = await embedSelfTest(env, { owner_id: 'o1' });
expect(r.enabled).toBe(true);
expect(r.tested).toBe(false);
expect(r.passed).toBeNull();
expect(r.note).toContain('沒跑成');
});
it('依 owner_id 隔離:別的租戶的已嵌入卡片不會被拿來測', async () => {
const store = [mkEntry('e1', 'content', 'other-tenant')];
const env = makeEnv(store, { matches: [] });
+208
View File
@@ -0,0 +1,208 @@
// 語意搜尋「故障要照實說是故障」的回歸測試(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);
});
});