diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 705447e..2b276d5 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -23,7 +23,7 @@ import { Hono } from 'hono'; import type { Context } from 'hono'; import type { Bindings } from '../types'; -import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled } from './portal'; +import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, listRecordsByTemplate, LIBRARY_TEMPLATE } from './portal'; import { graphBase } from './kbdb-proxy'; import { executeWebhookGraph } from '../actions/webhook-handlers'; @@ -642,21 +642,80 @@ portalDataRouter.get('/portal/data/diagnostics', (c) => notes.push(`embed 健康狀態查詢失敗:${e instanceof Error ? e.message : String(e)}`); } - // ② 卡片與知識圖譜規模(只取數字,不取 /map 回應裡的 narrative/top_entities 這些內容欄位)。 + // ② 卡片與知識圖譜規模(只取數字,不取內容欄位)。 + // + // 🔴 2026-08-08 修正(leo 實測抓到:3 庫、大量 triplets,診斷檔卻回 0/0): + // 原本讀 GET /map,那是 library_map**快取 block**(kbdb/src/actions/library-map.ts), + // 只能靠 POST /map/recompute 產生。核實過:整個 repo 沒有任何呼叫點會打 + // /map/recompute(`grep -rn "map/recompute" --include=*.ts` 只中 map.ts 自己的路由定義與 + // mcp 的說明字串)——library-map SDD(system-dev/docs/3-specs/library-map/design.md, + // status: draft)的「ingest 尾端自動重算(M3)」從沒接上。⇒ /map 的 libraries 對**任何 + // 租戶**恆是空陣列,library_count/triplet_count 恆為 0,與實際資料量無關,是全租戶通病, + // 不是 leo 這個實例特有。 + // + // 改走 GET /portal/admin/libraries(本檔以外、portal.ts 973 行)驗證過在用的**即時查詢** + // 同一套組合,不依賴任何快取: + // - listRecordsByTemplate(portal_library):已登記的庫(t159) + // - GET /entries/libraries:資料裡實際蓋章出現過的庫,登記與否都算(t52, + // 「蓋章即現身」);'general' 是未標庫的系統 fallback 桶,不算使用者眼中的一個庫, + // 與 admin/libraries 同慣例排除。 + // - GET /records/triplet-stats:per-library 即時聚合 SQL(t142,COUNT,非快取)。 let library_count = 0; let triplet_count = 0; + const ownerParam = new URLSearchParams({ owner_id: tenant }).toString(); 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 }[] } + const [registeredLibs, autoRes, tripletRes] = await Promise.all([ + listRecordsByTemplate(c.env, LIBRARY_TEMPLATE).catch(() => []), + kbdbFetch(c.env, `/entries/libraries?${ownerParam}`), + kbdbFetch(c.env, `/records/triplet-stats?${ownerParam}`), + ]); + const knownLibs = new Set( + registeredLibs.map((r) => (r.values.name ?? '').trim()).filter((n): n is string => !!n), + ); + const autoBody = (await autoRes.json().catch(() => null)) as { success?: boolean; libraries?: string[] } | null; + for (const name of autoBody?.libraries ?? []) { + const n = String(name ?? '').trim(); + if (n && n !== 'general') knownLibs.add(n); + } + library_count = knownLibs.size; + + const tripletBody = (await tripletRes.json().catch(() => null)) as + | { success?: boolean; stats?: { library: string; 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); + triplet_count = (tripletBody?.stats ?? []).reduce((sum, s) => sum + (Number(s.triplet_count) || 0), 0); } catch (e) { notes.push(`知識庫規模查詢失敗:${e instanceof Error ? e.message : String(e)}`); } + // ②.5 統計自我檢查(呼應下面 embedding.self_test 的精神——leo 直接指令:「不要讓『查不到』 + // 和『沒有』長得一樣」)。library_count/triplet_count 兩者都是 0 時,才另外花一次查詢, + // 用完全不同的路徑(不分庫、不分模板,只問「這個 owner_id 底下到底有沒有任何 entries」) + // 做交叉驗證——如果探測到有資料,代表問題出在查詢方式或 owner_id 對不上(2026-08-01 + // t161 前科:手動補的 record owner_id 存成 None,kbdb_query 全量查得到、按 owner_id 過濾 + // 的畫面永遠空,比真的沒資料更難查);如果探測也是空,才比較像真的是空庫。 + let library_scope_check: Record = { ran: false }; + if (library_count === 0 && triplet_count === 0) { + try { + const probeRes = await kbdbFetch(c.env, `/entries?${new URLSearchParams({ owner_id: tenant, limit: '1' }).toString()}`); + const probeBody = (await probeRes.json().catch(() => null)) as { total?: number } | null; + const total = probeBody?.total ?? 0; + library_scope_check = { + ran: true, + any_entries_found: total > 0, + note: + total > 0 + ? `這個租戶底下查得到其他資料(entries 共 ${total} 筆),但庫/三元組統計仍回 0——像是查詢方式或租戶對不上,不像真的沒資料,需要人再查一次` + : '這個租戶底下完全查不到任何資料——比較像是真的還沒有資料,不是查詢方式錯了', + }; + } catch (e) { + library_scope_check = { + ran: true, + any_entries_found: null, + note: `自我探測查詢本身失敗:${e instanceof Error ? e.message : String(e)}`, + }; + } + } + // ③ 最近一次萃取(daemon → /portal/daemon/extract)成功與否:目前沒有雲端側的失敗歷史 // 記錄可讀(該端點是同步請求/回應,失敗只回給呼叫當下的 daemon,雲端不落地保存)—— // 誠實列出這個缺口,不假裝有數字(mindset §7 禁假綠)。 @@ -668,6 +727,7 @@ portalDataRouter.get('/portal/data/diagnostics', (c) => bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null, library_count, triplet_count, + library_scope_check, embedding, notes, }); diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 618ffa5..75e6e87 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -38,7 +38,7 @@ const LOCK_TTL_SECONDS = 15 * 60; // 鎖 15 分鐘(KV TTL 自然過期) const DEFAULT_SESSION_TTL = 604800; // 7 天(design §4.3,比 console 30 天緊) const USER_TEMPLATE = 'portal_user'; -const LIBRARY_TEMPLATE = 'portal_library'; +export const LIBRARY_TEMPLATE = 'portal_library'; // ── 基礎 helpers ──────────────────────────────────────────────────────────── diff --git a/cypher-executor/tests/portal-data.test.ts b/cypher-executor/tests/portal-data.test.ts index a0c2b53..560b787 100644 --- a/cypher-executor/tests/portal-data.test.ts +++ b/cypher-executor/tests/portal-data.test.ts @@ -720,7 +720,10 @@ describe('GET /portal/data/diagnostics', () => { expect(res.status).toBe(401); }); - it('登入 → 200,聚合 embed 健康狀態+規模統計+版本;只含數字/布林/字串狀態', async () => { + it('登入 → 200,聚合 embed 健康狀態+規模統計(即時查,非 /map 快取)+版本;只含數字/布林/字串狀態', async () => { + // 2026-08-08 修復對應測試:library_count/triplet_count 改走 listRecordsByTemplate(portal_library) + // + /entries/libraries + /records/triplet-stats(與 GET /portal/admin/libraries 同一套即時查), + // 不再靠 /map(library_map 快取,recompute 從未被呼叫,恆回空——這正是 08-07 leo 實測抓到的病根)。 await seedSession('tok-diag1', 'rec_diag1'); mockGetRecord('rec_diag1', userValues()); fetchMock @@ -731,41 +734,47 @@ describe('GET /portal/data/diagnostics', () => { .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' }) .reply(200, { success: true, enabled: true, tested: true, passed: false, note: '搜不到自己' }); + // 已登記庫:1 筆(kb),values 帶不該外流的內容欄位(display_name/description)驗紅線。 + mockLibraryList([ + { record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '不該出現在診斷檔', description: '密卡內容' } }, + ]); + // 資料裡實際蓋章出現過的庫:kb(與登記簿重複,去重)+notes(未登記但蓋章過,t52「蓋章即現身」)+general(fallback 桶,排除不算庫)。 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, - }); + .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) + .reply(200, { success: true, libraries: ['general', 'kb', 'notes'], count: 3 }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 40 }, { library: 'notes', triplet_count: 27 }] }); 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; + library_scope_check: { ran: boolean }; 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.library_count).toBe(2); // kb(登記簿+資料面重複,去重)+notes;general 不算 + expect(body.triplet_count).toBe(67); // 40+27,實際聚合 SQL 算出,非快取 + expect(body.library_scope_check.ran).toBe(false); // 數字不是 0,不需要自我探測 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 沒被轉發) + // 紅線斷言:整份回應不含知識卡內容本體(登記簿 values 裡的 display_name/description 沒被轉發,只取了 name 算數) const raw = JSON.stringify(body); expect(raw).not.toContain('不該出現在診斷檔'); expect(raw).not.toContain('密卡'); + expect(raw).not.toContain('"kb"'); // 連庫名本身都不外流,只回數字 }); - it('embed 模組未開(自架未開語義搜尋)→ 誠實回 module_enabled:false,不是假裝有 index', async () => { + it('embed 模組未開(自架未開語義搜尋)→ 誠實回 module_enabled:false,不是假裝有 index;庫/三元組真的是空 → 自我探測也回空,不誤判為查詢錯誤', async () => { await seedSession('tok-diag2', 'rec_diag2'); mockGetRecord('rec_diag2', userValues()); fetchMock @@ -776,16 +785,75 @@ describe('GET /portal/data/diagnostics', () => { .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' }) .reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' }); + mockLibraryList([]); fetchMock .get(KBDB) - .intercept({ path: (p: string) => p.startsWith('/map'), method: 'GET' }) + .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) .reply(200, { success: true, libraries: [], count: 0 }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [] }); + // library_count/triplet_count 都是 0 → 觸發自我探測;這裡探測也回真的空(total:0)。 + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' }) + .reply(200, { success: true, entries: [], count: 0, total: 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 } } }; + const body = (await res.json()) as { + library_count: number; + triplet_count: number; + library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string }; + embedding: { module_enabled: boolean; self_test: { ran: boolean; found_itself: boolean | null } }; + }; + expect(body.library_count).toBe(0); + expect(body.triplet_count).toBe(0); + expect(body.library_scope_check.ran).toBe(true); + expect(body.library_scope_check.any_entries_found).toBe(false); expect(body.embedding.module_enabled).toBe(false); expect(body.embedding.self_test.ran).toBe(false); expect(body.embedding.self_test.found_itself).toBeNull(); }); + + it('統計自我檢查抓到 t161 同型病:庫/三元組回 0,但這個租戶底下其實查得到其他資料 → 標「像是查詢方式或租戶對不上」而非誤判成真的沒有資料', async () => { + await seedSession('tok-diag3', 'rec_diag3'); + mockGetRecord('rec_diag3', 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 模組未開' }); + mockLibraryList([]); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) + .reply(200, { success: true, libraries: [], count: 0 }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [] }); + // 自我探測:這個租戶底下其實有 12 筆 entries——庫/三元組統計卻回 0,兩者矛盾,該被標記。 + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' }) + .reply(200, { success: true, entries: [{ id: 'e1' }], count: 1, total: 12 }); + + const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag3' }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + library_count: number; + triplet_count: number; + library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string }; + }; + expect(body.library_count).toBe(0); + expect(body.triplet_count).toBe(0); + expect(body.library_scope_check.ran).toBe(true); + expect(body.library_scope_check.any_entries_found).toBe(true); + expect(body.library_scope_check.note).toContain('查詢方式或租戶對不上'); + }); });