ceb7638d74
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。 模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」 - 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數 (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+ 每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列 (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。 - record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留, 驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。 - entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry 接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。 - 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列, LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀), GET /maintenance/relation-orphans 唯讀巡檢。 - cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS, 整檔送 /query 會在重跑時假紅)。 - 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描; 釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。 遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型 與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
9.9 KiB
TypeScript
209 lines
9.9 KiB
TypeScript
// 語意搜尋「故障要照實說是故障」的回歸測試(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 }),
|
||
src_id: null, rel_id: null, dst_id: null, 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<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(*)')) {
|
||
// 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<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('誠實降級 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<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);
|
||
});
|
||
});
|