From 9344562258f8ff03cebf54fbd494bf7087f16511 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 7 Aug 2026 18:35:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(kbdb):=20embed=20=E8=87=AA=E6=88=91?= =?UTF-8?q?=E6=AA=A2=E6=9F=A5=E7=AB=AF=E9=BB=9E=EF=BC=88=E6=AA=A2=E4=BF=AE?= =?UTF-8?q?=E5=AD=94=E7=AC=AC=E4=B8=80=E5=A1=8A=EF=BC=8C2026-08-07=20leo?= =?UTF-8?q?=20=E7=9B=B4=E6=8E=A5=E6=8C=87=E4=BB=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /embed/selftest?owner_id= —— 挑一筆已標記「已嵌入」的卡片,拿它自己的內容做一次 真實語義查詢,檢查「自己是否搜得到自己」。backfillStatus 的 pending/embedded 計數 看不出 Arcrun#11 那種「嵌了但查不到」的故障模式(metadata index 事後才建、既有向量 沒被收錄),本端點是唯一能端到端驗證 index 真的可用的方法。 隱私邊界:只回 {enabled, tested, passed, note} 四個布林/字串欄位,不回卡片內容、 不回 entry id(測試 embed-selftest.test.ts 最後一案專門斷言不洩漏)。 12/12 kbdb vitest 全綠(含既有 embed-backfill 6 案未壞)。 --- kbdb/src/embed.ts | 57 ++++++++++++++++ kbdb/src/routes/embed.ts | 12 +++- kbdb/tests/embed-selftest.test.ts | 109 ++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 kbdb/tests/embed-selftest.test.ts diff --git a/kbdb/src/embed.ts b/kbdb/src/embed.ts index 716578b..c65b749 100644 --- a/kbdb/src/embed.ts +++ b/kbdb/src/embed.ts @@ -283,6 +283,63 @@ export async function backfillStatus( return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 }; } +export interface SelfTestResult { + enabled: boolean; // embed 模組是否開(binding 都在) + tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗) + passed: boolean | null; // 拿已嵌入卡片的內容查自己,能不能搜到自己(null=沒測) + note: string; // 給人看的一句話結論,供檢修孔診斷檔直接引用 +} + +/** + * Embed 自我檢查(檢修孔用,2026-08-07 leo 直接指令:「先把檢修孔做出來發版」)。 + * + * 為什麼需要這個,不只是 backfillStatus 的 pending/embedded 計數:08-05 撞過的真實故障 + * 是「is_embedded=1(已嵌入)但語義搜尋還是搜不到」——metadata index 事後才建,既有向量 + * 沒被收錄(Arcrun#11)。計數看不出這種病,因為計數只問「有沒有嵌」,不問「嵌完查得到嗎」。 + * 本函式挑一筆「已標記已嵌入」的既有 entry,拿它自己的內容做一次真實語義查詢,檢查 + * 「自己是否搜得到自己」——這是唯一能端到端驗證 index 真的可用的方法。 + * + * 隱私邊界(檢修孔規格紅線:診斷檔不准帶卡片內容本體):本函式只回布林 + 一句話 note, + * 不回傳卡片內容、不回傳 entry id。取樣內容只在函式內部這一次查詢中用過即丟。 + */ +export async function embedSelfTest( + env: Bindings, + opts: { owner_id?: string } = {}, +): Promise { + if (!embedEnabled(env)) { + return { enabled: false, tested: false, passed: null, note: 'embed 模組未開(缺 Vectorize/AI binding),語義搜尋這條路目前不存在' }; + } + const conds = ["is_embedded = 1", "content IS NOT NULL AND content <> ''"]; + const params: unknown[] = []; + if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); } + const where = conds.join(' AND '); + const row = await env.DB + .prepare(`SELECT * FROM entries WHERE ${where} ORDER BY updated_at DESC LIMIT 1`) + .bind(...params) + .first(); + if (!row) { + return { enabled: true, tested: false, passed: null, note: '尚無任何卡片被標記為「已嵌入」,無法自我檢查(可能是還沒卡片,也可能是嵌入從未成功過)' }; + } + const sample = (row.content ?? '').trim().slice(0, 200); + if (!sample) { + return { enabled: true, tested: false, passed: null, note: '取樣卡片內容為空,跳過自我檢查' }; + } + // min_score:0——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。 + const hits = await semanticSearch(env, sample, { owner_id: opts.owner_id, topK: 10, min_score: 0 }); + if (hits === null) { + return { enabled: false, tested: false, passed: null, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' }; + } + const passed = hits.some((h) => h.id === row.id); + return { + enabled: true, + tested: true, + passed, + note: passed + ? '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的' + : '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——像是 index 沒收錄到這批向量(需要重新 reindex)', + }; +} + export interface SemanticHit { id: string; score: number; diff --git a/kbdb/src/routes/embed.ts b/kbdb/src/routes/embed.ts index a013d78..55c6cee 100644 --- a/kbdb/src/routes/embed.ts +++ b/kbdb/src/routes/embed.ts @@ -8,7 +8,7 @@ // base 對內容語意無知:只認通用 metadata.embed===true 旗標,不知 triplet/wiki(解耦)。 import { Hono } from 'hono'; import type { Bindings } from '../types'; -import { embedEnabled, backfillEmbeddings, backfillStatus } from '../embed'; +import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest } from '../embed'; export const embedRoutes = new Hono<{ Bindings: Bindings }>(); @@ -56,4 +56,14 @@ embedRoutes.get('/backfill/status', async (c) => { return c.json({ success: true, ...status }); }); +// GET /embed/selftest?owner_id= — 語義自我檢查(檢修孔,2026-08-07): +// 挑一筆已嵌入的卡片,拿它自己的內容查自己,只回布林診斷(不回卡片內容、不回 entry id)。 +// 計數(backfill/status)看不出「嵌了但查不到」這種故障模式(Arcrun#11 撞過的真實案例), +// 本端點端到端驗證 index 真的可用。模組未開仍誠實回 enabled:false(不 409,讓檢修孔 +// 永遠能拿到一個可解讀的結論,不必先判斷該不該打這支)。 +embedRoutes.get('/selftest', async (c) => { + const result = await embedSelfTest(c.env, { owner_id: c.req.query('owner_id') || undefined }); + return c.json({ success: true, ...result }); +}); + export default embedRoutes; diff --git a/kbdb/tests/embed-selftest.test.ts b/kbdb/tests/embed-selftest.test.ts new file mode 100644 index 0000000..9f9f779 --- /dev/null +++ b/kbdb/tests/embed-selftest.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { embedSelfTest } from '../src/embed'; +import type { Bindings, Entry } from '../src/types'; + +// ── Minimal in-memory fakes ─────────────────────────────────────────────── +// embedSelfTest issues exactly one DB statement: +// SELECT * FROM entries WHERE is_embedded = 1 AND content <> '' [AND owner_id = ?] +// ORDER BY updated_at DESC LIMIT 1 +// The fake's `first()` filters the in-memory store accordingly and returns the +// last match (proxy for "ORDER BY updated_at DESC LIMIT 1" given store insertion order). +function mkEntry(id: string, content: string, ownerId = 'leo', is_embedded = 1): Entry { + return { + id, content, entry_type: 'block', owner_id: ownerId, parent_id: null, page_name: null, + refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded, + confidence: null, metadata_json: JSON.stringify({ embed: true }), created_at: 1, updated_at: 1, + }; +} + +function makeFakeDB(store: Entry[]) { + const prepare = (_sql: string) => { + let bound: unknown[] = []; + const stmt = { + bind(...args: unknown[]) { bound = args; return stmt; }, + async first() { + const ownerId = bound.length > 0 ? String(bound[0]) : undefined; + const rows = store.filter( + (e) => e.is_embedded === 1 && (e.content ?? '').trim() !== '' && (!ownerId || e.owner_id === ownerId), + ); + return (rows.length > 0 ? rows[rows.length - 1] : null) as unknown as T; + }, + async all() { return { results: [] as T[] }; }, + async run() { return { success: true }; }, + }; + return stmt; + }; + return { prepare } as unknown as D1Database; +} + +function makeEnv( + store: Entry[], + opts: { withBindings?: boolean; matches?: { id: string; score: number }[] } = {}, +): Bindings { + const withBindings = opts.withBindings ?? true; + return { + DB: makeFakeDB(store), + ENVIRONMENT: 'test', + ...(withBindings + ? { + AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } }, + VECTORIZE: { async query() { return { matches: opts.matches ?? [] }; } }, + } + : {}), + } as unknown as Bindings; +} + +describe('embedSelfTest(檢修孔:卡片自我查詢,驗證 index 真的可用)', () => { + it('module off → enabled:false, tested:false, passed:null(誠實不假綠)', async () => { + const env = makeEnv([mkEntry('e1', 'hello')], { withBindings: false }); + const r = await embedSelfTest(env); + expect(r.enabled).toBe(false); + expect(r.tested).toBe(false); + expect(r.passed).toBeNull(); + expect(typeof r.note).toBe('string'); + }); + + it('沒有任何已嵌入卡片 → tested:false, passed:null(非失敗,只是還沒東西可測)', async () => { + const env = makeEnv([]); + const r = await embedSelfTest(env); + expect(r.enabled).toBe(true); + expect(r.tested).toBe(false); + expect(r.passed).toBeNull(); + }); + + it('自我查詢能搜到自己 → passed:true', async () => { + const store = [mkEntry('e1', 'doorbell workflow content')]; + const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] }); + const r = await embedSelfTest(env); + expect(r.enabled).toBe(true); + expect(r.tested).toBe(true); + expect(r.passed).toBe(true); + }); + + it('自我查詢搜不到自己 → passed:false(Arcrun#11 那種「嵌了但查不到」故障模式)', async () => { + const store = [mkEntry('e1', 'doorbell workflow content')]; + const env = makeEnv(store, { matches: [{ id: 'some-other-id', score: 0.5 }] }); + const r = await embedSelfTest(env); + expect(r.enabled).toBe(true); + expect(r.tested).toBe(true); + expect(r.passed).toBe(false); + }); + + it('依 owner_id 隔離:別的租戶的已嵌入卡片不會被拿來測', async () => { + const store = [mkEntry('e1', 'content', 'other-tenant')]; + const env = makeEnv(store, { matches: [] }); + const r = await embedSelfTest(env, { owner_id: 'leo' }); + expect(r.enabled).toBe(true); + expect(r.tested).toBe(false); + expect(r.passed).toBeNull(); + }); + + it('回應絕不含卡片內容或 entry id(隱私紅線)', async () => { + const store = [mkEntry('e1', 'this is the secret card body, must never leak')]; + const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] }); + const r = await embedSelfTest(env); + const json = JSON.stringify(r); + expect(json).not.toContain('e1'); + expect(json).not.toContain('secret card body'); + }); +});