diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 87798d4..705447e 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -584,3 +584,92 @@ portalDataRouter.get('/portal/data/workflows', (c) => return c.json({ success: true, workflows, total: workflows.length, read_only: true }); }), ); + +// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令): +// +// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」 +// +// 設定頁「匯出診斷檔給我們看」按鈕打這支,前端把回應存成單一 JSON 檔下載。leo 把檔轉給 +// 我方時,我方要能只靠這個檔判斷病因,不必再回頭問封測者任何問題。 +// +// 🔴 兩條紅線(規格原文): +// ① 不准把內部概念暴露給用戶——本端點只回統計/狀態,前端按鈕文案不提 KBDB/Vectorize/ +// owner_id 這類詞。 +// ② 不准洩漏知識卡內容本體——以下每一個欄位都只挑「數字」或「布林」,即使背後的 KBDB +// 端點回應含 content(如 /map 的 top_entities、triplet 的 subject/object 名稱), +// 本端點一律只讀出用得到的數字後就丟掉那個回應,不把原始內容往前端送。 +// +// 涵蓋「這次一定要涵蓋」的向量/embedding 健康狀態:embed 模組是否開(index 存在的前提)、 +// 已嵌入/待嵌入卡片數、以及 embedSelfTest(KBDB #12)—— 這是唯一能分辨「從沒嵌過」與 +// 「嵌了但 index 查不到自己」兩種故障模式的方法(Arcrun#11 的真實案例正是後者,光看 +// 計數看不出來)。 +// +// 認證:與其餘 /portal/data/* 同一道 requirePortalUser session 閘(不開放無登入存取—— +// 統計數字仍是這個實例的營運資訊,不對外公開)。 +portalDataRouter.get('/portal/data/diagnostics', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const tenant = portalTenant(c.env); + const notes: string[] = []; + + // ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。 + let embedding: Record = { checked: false }; + try { + const [statusRes, selftestRes] = await Promise.all([ + kbdbFetch(c.env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant }).toString()}`), + kbdbFetch(c.env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant }).toString()}`), + ]); + const statusBody = (await statusRes.json().catch(() => null)) as + | { success?: boolean; enabled?: boolean; pending?: number; embedded?: number } + | null; + const selftestBody = (await selftestRes.json().catch(() => null)) as + | { success?: boolean; enabled?: boolean; tested?: boolean; passed?: boolean | null; note?: string } + | null; + embedding = { + checked: true, + module_enabled: statusBody?.enabled ?? false, // Vectorize+AI binding 都在,才有「index」這回事 + cards_embedded: statusBody?.embedded ?? 0, + cards_pending: statusBody?.pending ?? 0, + self_test: { + ran: selftestBody?.tested ?? false, + // 三態:true=能搜到自己 / false=搜不到自己(index 收錄有缺)/ null=還沒東西可測或模組未開 + found_itself: selftestBody?.tested ? (selftestBody?.passed ?? null) : null, + note: selftestBody?.note ?? '', + }, + }; + } catch (e) { + notes.push(`embed 健康狀態查詢失敗:${e instanceof Error ? e.message : String(e)}`); + } + + // ② 卡片與知識圖譜規模(只取數字,不取 /map 回應裡的 narrative/top_entities 這些內容欄位)。 + let library_count = 0; + let triplet_count = 0; + try { + const mapRes = await kbdbFetch(c.env, `/map?${new URLSearchParams({ owner_id: tenant }).toString()}`); + const mapBody = (await mapRes.json().catch(() => null)) as + | { success?: boolean; libraries?: { triplet_count?: number }[] } + | null; + const libs = Array.isArray(mapBody?.libraries) ? mapBody!.libraries! : []; + library_count = libs.length; + triplet_count = libs.reduce((sum, l) => sum + (Number(l.triplet_count) || 0), 0); + } catch (e) { + notes.push(`知識庫規模查詢失敗:${e instanceof Error ? e.message : String(e)}`); + } + + // ③ 最近一次萃取(daemon → /portal/daemon/extract)成功與否:目前沒有雲端側的失敗歷史 + // 記錄可讀(該端點是同步請求/回應,失敗只回給呼叫當下的 daemon,雲端不落地保存)—— + // 誠實列出這個缺口,不假裝有數字(mindset §7 禁假綠)。 + notes.push('目前雲端沒有保存「萃取/上傳失敗」的歷史紀錄,只能看到目前的聚合計數(上面 cards_pending/cards_embedded);若要查某一次失敗的當下原因,需在失敗當下由封測者截圖同步小幫手視窗。'); + + return c.json({ + generated_at: new Date().toISOString(), + instance_url: new URL(c.req.url).origin, + bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null, + library_count, + triplet_count, + embedding, + notes, + }); + }), +); diff --git a/cypher-executor/tests/portal-data.test.ts b/cypher-executor/tests/portal-data.test.ts index c5ee432..a0c2b53 100644 --- a/cypher-executor/tests/portal-data.test.ts +++ b/cypher-executor/tests/portal-data.test.ts @@ -711,3 +711,81 @@ describe('dedupeSourcesByPage(t129 出處去重)', () => { expect(out.length).toBe(1); // 同 page_name → 合為一筆 }); }); + +// ═══════════════ 7. GET /portal/data/diagnostics(檢修孔,2026-08-07) ═══════════════ + +describe('GET /portal/data/diagnostics', () => { + it('未登入 → 401,不碰 KBDB', async () => { + const res = await get('/portal/data/diagnostics'); + expect(res.status).toBe(401); + }); + + it('登入 → 200,聚合 embed 健康狀態+規模統計+版本;只含數字/布林/字串狀態', async () => { + await seedSession('tok-diag1', 'rec_diag1'); + mockGetRecord('rec_diag1', userValues()); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' }) + .reply(200, { success: true, enabled: true, pending: 3, embedded: 80 }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' }) + .reply(200, { success: true, enabled: true, tested: true, passed: false, note: '搜不到自己' }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/map'), method: 'GET' }) + .reply(200, { + success: true, + libraries: [ + { name: 'general', triplet_count: 67, narrative: '不該出現在診斷檔', top_entities: ['密卡', '內容'] }, + ], + count: 1, + }); + + const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag1' }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + library_count: number; + triplet_count: number; + embedding: { module_enabled: boolean; cards_embedded: number; cards_pending: number; self_test: { ran: boolean; found_itself: boolean | null } }; + instance_url: string; + bundle_version: string | null; + }; + expect(body.library_count).toBe(1); + expect(body.triplet_count).toBe(67); + expect(body.embedding.module_enabled).toBe(true); + expect(body.embedding.cards_embedded).toBe(80); + expect(body.embedding.cards_pending).toBe(3); + expect(body.embedding.self_test.ran).toBe(true); + expect(body.embedding.self_test.found_itself).toBe(false); + expect(body.instance_url).toBe('http://localhost'); + // 紅線斷言:整份回應不含知識卡內容本體(/map 回應裡的 narrative/top_entities 沒被轉發) + const raw = JSON.stringify(body); + expect(raw).not.toContain('不該出現在診斷檔'); + expect(raw).not.toContain('密卡'); + }); + + it('embed 模組未開(自架未開語義搜尋)→ 誠實回 module_enabled:false,不是假裝有 index', async () => { + await seedSession('tok-diag2', 'rec_diag2'); + mockGetRecord('rec_diag2', userValues()); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' }) + .reply(200, { success: true, enabled: false, pending: 0, embedded: 0 }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' }) + .reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/map'), method: 'GET' }) + .reply(200, { success: true, libraries: [], count: 0 }); + + const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag2' }); + expect(res.status).toBe(200); + const body = (await res.json()) as { embedding: { module_enabled: boolean; self_test: { ran: boolean; found_itself: boolean | null } } }; + expect(body.embedding.module_enabled).toBe(false); + expect(body.embedding.self_test.ran).toBe(false); + expect(body.embedding.self_test.found_itself).toBeNull(); + }); +});